diff --git a/lib.php b/lib.php index 1c47b60..9bc83fa 100644 --- a/lib.php +++ b/lib.php @@ -32,1313 +32,1366 @@ defined('MOODLE_INTERNAL') || die(); -class enrol_iserv_plugin extends enrol_plugin { - protected $enroltype = 'enrol_iserv'; - static protected $error_log_tag = 'ENROL ISERV'; - static protected $idnumber_course_category = 'iserv_courses'; - protected $teacher_array = array(); - protected $auth_ldap; - - // Message logging modes - const LOG_NORMAL = 0; - const LOG_DEBUG = 1; - const LOG_MTRACE = 2; - - public function __construct() { - global $CFG; - require_once($CFG->libdir . '/accesslib.php'); - require_once($CFG->libdir . '/ldaplib.php'); - require_once($CFG->libdir . '/moodlelib.php'); - require_once($CFG->libdir . '/enrollib.php'); - require_once($CFG->libdir . '/dml/moodle_database.php'); - require_once($CFG->dirroot . '/group/lib.php'); - require_once($CFG->dirroot . '/auth/ldap/auth.php'); - require_once($CFG->dirroot . '/course/lib.php'); - - $this->load_config(); - // Make sure we get sane defaults for critical values. - $this->config->ldapencoding = $this->get_config('ldapencoding', 'utf-8'); - $this->config->user_type = $this->get_config('user_type', 'default'); - - $ldap_usertypes = ldap_supported_usertypes(); - $this->config->user_type_name = $ldap_usertypes[$this->config->user_type]; - unset($ldap_usertypes); - - $default = ldap_getdefaults(); - // Use defaults if values not given. Dont use this->get_config() - // here to be able to check for 0 and false values too. - foreach ($default as $key => $value) { - // Watch out - 0, false are correct values too, so we can't use $this->get_config(). - if (!isset($this->config->{$key}) or $this->config->{$key} == '') { - $this->config->{$key} = $value[$this->config->user_type]; - } - } - } - - /** - * Custom message logging function - * @param string $text Message text to be logged - * @param string $func Optional: Current function the message is logged from - * @param bool $mode Optional: Specify logging mode, see LOG_ constants in class - */ - static function debuglog ($text, $func = "Generic", $mode = 0) { - $error_log_tag = self::$error_log_tag; - $now = date ("H:i:s"); - $line = "[{$error_log_tag} -> {$func} @ {$now}] {$text}"; - - if (defined("CLI_SCRIPT")) $mode = -1; - - switch ($mode) { - case -1: - print ($line . PHP_EOL); - break; - case self::LOG_DEBUG: - debugging ($line, DEBUG_DEVELOPER); - break; - case self::LOG_MTRACE: - mtrace ($line); - break; - default: - debugging ($line); - } - } - - /** - * Get courses from LDAP and sync them with Moodle courses, then - * ensure users are properly enrolled into those courses - * - * @param string $userid User ID to sync enrolments with - */ - public function sync_courses ($userid = "*") { - if ($this -> config -> courses_autocreate || $this -> config -> courses_autoremove) { - self::debuglog ( - "Syncing courses for user {$userid}", - "sync_courses", - self::LOG_DEBUG - ); - - $ldap_courses = $this -> get_courses_ldap(); - $moodle_courses = $this -> get_courses_moodle(); - - if ($this -> config -> courses_autocreate) { - $to_add = array_diff_key ($ldap_courses, $moodle_courses); - - if (!empty($to_add)) { - $this -> create_courses ($to_add); - } - } - - if ($this -> config -> courses_autoremove) { - $to_remove = array_diff_key ($moodle_courses, $ldap_courses); - - if (!empty($to_remove)) { - $this -> remove_courses (array_keys($to_remove)); - } - } - } - - if ($userid && strcmp($userid,"*") !== 0) { - $this -> sync_course_enrolments_user ($userid); - } else { - $this -> sync_course_enrolments(); - } - - return true; - } - - /** - * create a list of class courses, whose names are given in an array - * - * @param array $classes - */ - private function create_courses ($courses = []) { - global $DB; - if (empty($courses)) return; - - $courses_category = self::get_courses_category($this->config); - if (!$courses_category) return; - - $template = $DB -> get_record ( - 'course', - array( - 'id' => $this -> config -> courses_template - ), - '*', - IGNORE_MISSING - ); - - if ($template) { - $template = $template->id; - } - - foreach ($courses as $shortname => $fullname) { - $this -> create_course ( - $shortname, - $courses_category -> id, - $fullname, - $template - ); - - $newcourses = true; - } - if ($newcourses) { - context_coursecat::instance ( $courses_category -> id ) -> mark_dirty (); - } - } - - /** - * Remove all courses given in array from course category - * - * @param array $courses Courses to remove - */ - private function remove_courses ($courses = array()) { - global $DB; - - if (empty($courses)) return; - - $courses_category = self::get_courses_category ($this->config); - if (!$courses_category) return; - - $ids = array (); - foreach ($courses as $course) { - $record = $DB -> get_record ( - 'course', - array ( - 'shortname' => $course, - 'category' => $courses_category -> id - ), - '*' - ); - - $ids[] = $record -> id; - } - - foreach ($ids as $id) { - $this -> remove_course ($id); - } - } - - /** - * Create a new course either as duplicate from $template or as new empty course - * - * @param string $name Short name of new course - * @param integer $category_id ID of parent category - * @param string $fullname Full name of new course - * @param number $template Template ID to clone new course from - * - * @return course object|false - */ - private function create_course ($name, $category_id, $fullname = null, $template = 0) { - global $CFG; - require_once ($CFG->dirroot . '/course/externallib.php'); - require_once ($CFG->dirroot . '/course/lib.php'); - - $course = false; - if ($fullname === null) $fullname = $course; - - self::debuglog( - "Creating course {$fullname} @{$name} in category {$category_id} with template {$template}", - "create_course" - ); - - if (!$template) { - $data = new stdclass(); - $data -> shortname = $name; - $data -> fullname = $fullname; - $data -> visible = 1; - $data -> category = $category_id; - - try { - $course = create_course($data); - self::debuglog( - "Course {$fullname} @{$name} successfully created", - "create_course", - self::LOG_MTRACE - ); - } catch (Exception $e) { - self::debuglog( - "Course {$fullname} @{$name} failed to create: " . $e->getMessage(), - "create_course" - ); - } - } else { - try { - $course = core_course_external::duplicate_course($template, $fullname, $name, $category_id, 1); - $course = get_course($course["id"], false); - - self::debuglog( - "Course {$fullname} @{$name} successfully duplicated from template", - "create_course", - self::LOG_MTRACE - ); - } catch ( Exception $e ) { - self::debuglog( - "Course {$fullname} @{$name} failed to duplicate: " . $e->getMessage(), - "create_course" - ); - } - } - - return $course; - } - - /** - * Remove a course - * - * @param int $course_id ID of course to remove - * - * @return true True on success - * @return false False on failure - */ - private function remove_course ($course_id) { - global $CFG; - require_once ($CFG->libdir . '/moodlelib.php'); - return delete_course($course_id); - } - - /** - * Search for courses based on LDAP groups - * - * @param string $userid Optional: ID of the user to get courses for - * - * @return array Associative array of internal group name and group description - * @return false False on failure - */ - private function get_courses_ldap ($userid = "*") { - // Create course filter - if ($this -> config -> coursemapping_use_prefixes) { - $prefixes = explode (',', $this -> config -> coursemapping_prefixes); - foreach ($prefixes as $prefix) { - $pattern[] = "({$this -> config -> group_attribute}={$prefix})"; - } - $pattern = '(|' . implode ($pattern) . ')'; - } else if ($this -> config -> coursemapping_use_attribute) { - $pattern = "({$this -> config -> coursemapping_attribute}={$this -> config -> coursemapping_attribute_value})"; - } else { - self::debuglog ( - "Invalid settings, enable either coursemapping_use_prefix or coursemapping_use_attribute", - "get_courses_ldap" - ); - return false; - } - - self::debuglog ( - "Using filter {$pattern} to fetch courses for {$userid}", - "get_courses_ldap", - self::LOG_DEBUG - ); - - return $this -> ldap_get_grouplist ($userid, $pattern); - } - - /** - * Returns an array of class \core_course_list_element objects for the $userid - * - * @param string $userid ID of user to get Moodle courses for - * - * @return array Course array of Moodle courses - * - * @uses $USER - */ - private function get_courses_moodle ($userid = "*") { - global $USER; - $return_var = array (); - - if ($userid == "*") { - $user = null; - } else { - $user = \core_user::get_user_by_username ($userid, 'id', null, IGNORE_MISSING); - if (!$user) { - self::debuglog ( - "Could not find user with ID {$userid}", - "get_courses_moodle" - ); - return $return_var; - } - } - - $courses_category = self::get_courses_category($this -> config); - if (!$courses_category) { - self::debuglog ( - "Courses category not found", - "get_courses_moodle" - ); - return $return_var; - } - - // Sloppy fix for something I haven't figured out yet, to get all courses, - // set $USER->id to 1, which means guest, otherwise no courses are returned - $previous_user_id = $USER -> id; - $USER -> id = 1; - - $courselist = $courses_category -> get_courses(); - foreach ($courselist as $record) { - if ($record -> visible) { - $context = context_course::instance($record->id); - if (is_null($user) || is_enrolled($context, $user, '', true)) { - $return_var [$record->shortname] = $record; - } - } - } - - $USER -> id = $previous_user_id; - return $return_var; - } - - /** - * Fetches and creates the class category if necessary - * - * @param object $config The configuration object from $this -> - * - * @return object Returns the category object on success - * @return false Returns false on failure - * - * @uses $DB - */ - private static function get_courses_category ($config) { - global $DB; - - // Fetch category object from DB - $category_obj = $DB -> get_record ( - 'course_categories', - array ( - 'idnumber' => self::$idnumber_course_category, - 'parent' => 0 - ), - 'id', - IGNORE_MULTIPLE - ); - - // Create class category if needed - if (! $category_obj) { - if (isset ($config -> courses_category_autocreate) && $config -> courses_category_autocreate) { - $category_obj = self::create_category ( - $config -> courses_category, - self::$idnumber_course_category, - get_string ('courses_category_description', 'enrol_iserv') - ); - - if ($category_obj) { - self::debuglog ( - "Created courses category {$category_obj -> id}", - "get_course_category", - self::LOG_DEBUG - ); - } else { - self::debuglog ( - "Could not autocreate courses category", - "get_course_category" - ); - return false; - } - } - } else { - self::debuglog ( - "Trying to get course category with ID {$category_obj -> id}", - "get_course_category", - self::LOG_DEBUG - ); - - $category_obj = \core_course_category::get ( - $category_obj -> id, - IGNORE_MISSING, - true - ); - } - - if (! $category_obj) { - self::debuglog ( - "Courses category " . self::$idnumber_course_category . " not found", - "get_course_category" - ); - return false; - } - - return $category_obj; - } - - /** - * Renames the courses category - * - * @param string $name New courses category name - * - * @param true True on success - * @param false False on failure - */ - public function rename_courses_category ($name) { - global $DB; - - if (! $name || $name == "") return false; - - self::debuglog ( - "Renaming courses category to {$name}", - "rename_courses_category", - self::LOG_DEBUG - ); - - $category = self::get_courses_category ($this->config); - - $result = $DB -> set_field ( - 'course_categories', - 'name', - $name, - array ('id' => $category->id) - ); - - if ($result) { - self::debuglog ( - "Success, marking category as dirty", - "rename_courses_category", - self::LOG_DEBUG - ); - - context_coursecat::instance ($category -> id) -> mark_dirty (); - return true; - } - - self::debuglog ( - "Renaming courses category failed", - "rename_courses_category", - self::LOG_DEBUG - ); - - return false; - } - - /** - * This function creates a course category and fixes the category path. - * @param string $name New category name - * @param string $idnumber New category idnumber - * @param string $description Descriptive text for the new course category - * @param object $parent course_categories parent object or 0 for top level category - * @param int $sortorder special sort order, 99999 order at end - * @param int $visible Specifies if the category is visible or hidden - * - * @return object Returns the new category object on success - * @return false Returns false on failure - * @uses $DB; - */ - private static function create_category ($name, $idnumber, $description, $parent = 0, $sortorder = 0, $visible = 1) { - global $DB; - - self::debuglog( - "Creating {$name} @{$idnumber} with sortorder ({$sortorder})", - "create_category", - self::LOG_DEBUG - ); - - $data = new stdClass(); - $data -> name = $name; - $data -> idnumber = $idnumber; - $data -> description = $description; - $data -> parent = $parent; - $data -> visible = $visible; - - $category = \core_course_category::create ($data); - - if (!$category) { - self::debuglog( - "Could not create new course category {$category->name} @{$category->idnumber}", - "create_category" - ); - return false; - } - - if ($sortorder != 0) { - self::debuglog ( - "Changing course sortorder to {$sortorder}", - "create_category", - self::LOG_DEBUG - ); - - $DB -> set_field ( - 'course_categories', - 'sortorder', - $sortorder, - array( - 'id' => $category->id - ) - ); - context_coursecat::instance ($category -> id) -> mark_dirty(); - fix_course_sortorder(); - } - - return $category; - } - - /** - * Get all groups from LDAP which match search criteria defined in settings - * - * A $group_pattern of the form "(|(cn=05*)(cn=06*)...)" can be provided, otherwise - * a default $group_pattern is generated. - * - * @param string $username Limits groups to those which username is a member of - * @param string $group_pattern LDAP filter pattern to filter groups - * - * @return array Associative array of interal group name and group description - * @return false False on failure - */ - private function ldap_get_grouplist($username = "*", $group_pattern = null) { - self::debuglog ( - "Started fetching groups for {$username}", - "ldap_get_grouplist", - self::LOG_DEBUG - ); - - $auth_ldap = $this -> auth_ldap; - if (!isset($auth_ldap) or empty($auth_ldap)) { - $this -> auth_ldap = $auth_ldap = get_auth_plugin('ldap'); - } - - self::debuglog ( - "LDAP connecting...", - "ldap_get_grouplist", - self::LOG_DEBUG - ); - - $ldapconnection = $auth_ldap->ldap_connect(); - if (!$ldapconnection) { - self::debuglog ( - "LDAP connection failed", - "ldap_get_grouplist" - ); - - return false; - } - - self::debuglog ( - "LDAP connected", - "ldap_get_grouplist", - self::LOG_DEBUG - ); - - // Create LDAP filter if username is specified - if ($username == "*") { - $filter = ""; - } else { - $filter = "({$this->config->group_member_attribute}={$username})"; - } - - // Use generic LDAP group pattern if none was provided - if (is_null ($group_pattern)) { - $group_pattern = $this -> ldap_generate_group_pattern (); - } - - $filter = "(&{$group_pattern}{$filter}(objectclass={$this -> config -> group_object_class}))"; - $contexts = explode(';', $this -> config -> group_contexts); - $found_groups = array (); - - // Iterate through all group contexts to look up any matching groups - foreach ($contexts as $context) { - $context = trim ($context); - if (empty ($context)) continue; - - if ($this -> config -> group_search_subtree) { - // Search groups in this context and subcontexts - $ldap_result = ldap_search ($ldapconnection, $context, $filter, array ( - $this -> config -> group_attribute - )); - } else { - // Search only in the current context - $ldap_result = ldap_list ($ldapconnection, $context, $filter, array ( - $this -> config -> group_attribute, - $this -> config -> group_fullname_attribute - )); - } - - $groups = ldap_get_entries($ldapconnection, $ldap_result); - - // Add found groups to found_groups array - for ($i = 0; $i < count($groups) - 1; $i++) { - $group_name = $groups[$i][$this -> config -> group_attribute][0]; - $group_desc = $groups[$i][$this -> config -> group_fullname_attribute][0]; - $found_groups[$group_name] = $group_desc; - } - } - - self::debuglog ( - "LDAP closing...", - "ldap_get_grouplist", - self::LOG_DEBUG - ); - $auth_ldap->ldap_close(); - self::debuglog ( - "LDAP closed...", - "ldap_get_grouplist", - self::LOG_DEBUG - ); - - return $found_groups; - } - - /** - * This function finds the user in the teacher array. - * - * @param string $userid ID of the user to check - */ - private function is_teacher($userid) { - self::debuglog ( - "Checking user {$userid}", - "is_teacher", - self::LOG_DEBUG - ); - - if (empty($userid)) { - self::debuglog ( - "Function called with empty userid", - "is_teacher" - ); - return false; - } - - if (empty($this->teacher_array)) { - $this->init_teacher_array(); - } - - return in_array($userid, $this->teacher_array); - } - - /** - * This function inits the teacher_array once - * - * @return true True on success - * @return false False on failure - */ - private function init_teacher_array() { - self::debuglog ( - "Initializing array", - "init_teacher_array", - self::LOG_DEBUG - ); - - $this -> teacher_array = $this -> ldap_get_role_members ($this -> config -> teachers_role_name, true); - - self::debuglog ( - "Finished", - "init_teacher_array", - self::LOG_DEBUG - ); - - if (empty($this -> teacher_array)) { - return false; - } - - return true; - } - - /** - * Search for group members on the IServ server within a specified group - * - * @param string $group Group to fetch members from - * - * @return string[] Array of users on success - * @return false False on failure - */ - private function ldap_get_group_members ($group) { - global $CFG, $DB; - - self::debuglog ( - "Fetching members for group {$group}...", - "ldap_get_group_members", - self::LOG_DEBUG - ); - - $return_var = array (); - $members = array (); - $auth_ldap = $this->auth_ldap; - - if (!isset($auth_ldap) or empty($auth_ldap)) { - $this->auth_ldap = $auth_ldap = get_auth_plugin('ldap'); - } - - self::debuglog ( - "LDAP connecting...", - "ldap_get_group_members", - self::LOG_DEBUG - ); - $ldapconnection = $auth_ldap->ldap_connect(); - - $group = core_text::convert($group, 'utf-8', $this -> config -> ldapencoding); - - if (!$ldapconnection) { - self::debuglog ( - "LDAP connection failed", - "ldap_get_group_members" - ); - return $return_var; - } - - self::debuglog ( - "LDAP connected", - "ldap_get_group_members", - self::LOG_DEBUG - ); - $group_query = "(&(cn=" . trim($group) . ")(objectClass={$this -> config -> group_object_class}))"; - $contexts = explode(';', $this -> config -> group_contexts); - - // Iterate trough all contexts and try to find the group there - foreach ($contexts as $context) { - $context = trim($context); - if (empty ($context)) continue; - - self::debuglog ( - "LDAP Search in {$context} filters {$group_query}", - "ldap_get_group_members", - self::LOG_DEBUG - ); - $ldap_result = ldap_search ($ldapconnection, $context, $group_query); - - if (!empty ($ldap_result) AND ldap_count_entries ($ldapconnection, $ldap_result)) { - self::debuglog ( - "Fetching entries with ldap_get_entries...", - "ldap_get_group_members", - self::LOG_DEBUG - ); - - $entries = ldap_get_entries($ldapconnection, $ldap_result); - $group_member_attribute = strtolower ($this -> config -> group_member_attribute); - - // Iterate over all matching groups in the current context and collect their members - $totalMembers = 0; - foreach ($entries as $entry) { - if (isset ($entry[$group_member_attribute])) { - $memberCountInThisContext = count ($entry[$group_member_attribute]); - - // Iterate through all members - for ($g = 0; $g < ($memberCountInThisContext - 1); $g++) { - $member = trim ($entry[$group_member_attribute][$g]); - - // Skip blank members - if ($member == "") continue; - - // Grab CN from DN if necessary - if ($this -> config -> group_member_attribute_is_dn) { - if (! $member = $this -> get_cn_from_dn ($member)) { - self::debuglog ( - "Failed to fetch CN from DN '{$member}', check setting role_member_attribute_is_dn!", - "ldap_get_group_members" - ); - } - } - - // Add member to members array if they're not already part of it - if (! in_array ($member, $members)) { - $members[] = $member; - $totalMembers ++; - - self::debuglog ( - "Found member {$member}", - "ldap_get_group_members", - self::LOG_DEBUG - ); - } - } - } - } - - self::debuglog ( - "Number of members found for {$this -> config -> group_member_attribute}: {$totalMembers}" , - "ldap_get_group_members", - self::LOG_DEBUG - ); - } - } - - self::debuglog ( - "LDAP closing...", - "ldap_get_group_members", - self::LOG_DEBUG - ); - $auth_ldap->ldap_close(); - self::debuglog ( - "LDAP closed", - "ldap_get_group_members", - self::LOG_DEBUG - ); - - // Generate SELECT statement for DB call - foreach ($members as $member) { - if (isset($select)) { - $select = "{$select},'{$member}'"; - } else { - $select = "'{$member}'"; - } - } - - // Fetch users from DB to match IDs to usernames - if (isset($select)) { - $select = "username IN ({$select})"; - $members = $DB -> get_recordset_select('user', $select, null, null, 'id,username'); - foreach ($members as $member) { - $return_var[$member -> id] = $member -> username; - } - } else { - self::debuglog ( - "No users found", - "ldap_get_group_members" - ); - } - return $return_var; - } - - /** - * Get all users that are member of an IServ role - * - * @param string $role Role to fetch members from - * - * @return string[] Array of users on success - * @return false False on failure - */ - private function ldap_get_role_members ($role) { - global $CFG, $DB; - - self::debuglog ( - "Fetching members for role {$role}...", - "ldap_get_role_members", - self::LOG_DEBUG - ); - - $return_var = array (); - $members = array (); - $auth_ldap = $this->auth_ldap; - - if (!isset($auth_ldap) or empty($auth_ldap)) { - $this->auth_ldap = $auth_ldap = get_auth_plugin('ldap'); - } - - self::debuglog ( - "LDAP connecting...", - "ldap_get_role_members", - self::LOG_DEBUG - ); - $ldapconnection = $auth_ldap->ldap_connect(); - - $role = core_text::convert($role, 'utf-8', $this -> config -> ldapencoding); - - if (!$ldapconnection) { - self::debuglog ( - "LDAP connection failed", - "ldap_get_role_members" - ); - return $return_var; - } - - self::debuglog ( - "LDAP connected", - "ldap_get_role_members", - self::LOG_DEBUG - ); - - $role = trim($role); - $role_query = "(&(cn={$role})(objectClass={$this -> config -> role_object_class}))"; - $contexts = explode(';', $this -> config -> role_contexts); - - // Iterate trough all contexts and try to find the role there - foreach ($contexts as $context) { - $context = trim($context); - if (empty ($context)) continue; - - self::debuglog ( - "LDAP Search in {$context} filters {$role_query}", - "ldap_get_role_members", - self::LOG_DEBUG - ); - $ldap_result = ldap_search ($ldapconnection, $context, $role_query); - - if (!empty ($ldap_result) AND ldap_count_entries ($ldapconnection, $ldap_result)) { - self::debuglog ( - "Fetching entries with ldap_get_entries...", - "ldap_get_role_members", - self::LOG_DEBUG - ); - - $entries = ldap_get_entries($ldapconnection, $ldap_result); - $role_member_attribute = strtolower ($this -> config -> role_member_attribute); - - // Iterate over all matching roles in the current context and collect their members - $totalMembers = 0; - foreach ($entries as $entry) { - if (isset ($entry[$role_member_attribute])) { - $memberCountInThisContext = count ($entry[$role_member_attribute]); - - // Iterate through all members - for ($g = 0; $g < ($memberCountInThisContext - 1); $g++) { - $member = trim ($entry[$role_member_attribute][$g]); - - // Skip blank members - if ($member == "") continue; - - // Grab CN from DN if necessary - if ($this -> config -> role_member_attribute_is_dn) { - if (! $member = $this -> get_cn_from_dn ($member)) { - self::debuglog ( - "Failed to fetch CN from DN '{$member}', check setting role_member_attribute_is_dn!", - "ldap_get_role_members" - ); - } - } - - // Add member to members array if they're not already part of it - if (! in_array ($member, $members)) { - $members[] = $member; - $totalMembers ++; - - self::debuglog ( - "Found member {$member}", - "ldap_get_role_members", - self::LOG_DEBUG - ); - } - } - } - } - - self::debuglog ( - "Number of members found for {$this -> config -> role_member_attribute}: {$totalMembers}" , - "ldap_get_role_members", - self::LOG_DEBUG - ); - } - } - - self::debuglog ( - "LDAP closing...", - "ldap_get_role_members", - self::LOG_DEBUG - ); - $auth_ldap->ldap_close(); - self::debuglog ( - "LDAP closed", - "ldap_get_role_members", - self::LOG_DEBUG - ); - - // Generate SELECT statement for DB call - foreach ($members as $member) { - if (isset($select)) { - $select = "{$select},'{$member}'"; - } else { - $select = "'{$member}'"; - } - } - - // Fetch users from DB to match IDs to usernames - if (isset($select)) { - $select = "username IN ({$select})"; - $members = $DB -> get_recordset_select('user', $select, null, null, 'id,username'); - foreach ($members as $member) { - $return_var[$member -> id] = $member -> username; - } - } else { - self::debuglog ( - "No users found", - "ldap_get_role_members" - ); - } - - return $return_var; - } - - /** - * Returns only the CN of a DN - * - * @param string $dn DN to grab the CN from - * - * @return string CN of the DN - */ - private function get_cn_from_dn ($dn) { - preg_match ("~^cn=(.*?),~", $dn, $matches); - if (count($matches) == 2) { - return $matches[1]; - } - - return false; - } - - /** - * Generate an LDAP search pattern including all groups - * - * @return string - */ - private function ldap_generate_group_pattern() { - $pattern[] = "(objectClass={$this->config->group_object_class})"; - $pattern = '(|' . implode($pattern) . ')'; - return $pattern; - } - - /** - * Syncs course enrolments for a single user - * - * @param string $userid - */ - public function sync_course_enrolments_user ($userid) { - global $DB; - if (!$userid) return; - - self::debuglog ( - "Syncing course enrolments for {$userid}", - "sync_course_enrolments_user", - self::LOG_DEBUG - ); - - $user = \core_user::get_user_by_username ($userid, 'id'); - - if ($this -> is_teacher ($userid)) { - $role = $this -> config -> courses_teacher_role; - } else { - $role = $this -> config -> courses_student_role; - } - - $ldap_courses = $this -> get_courses_ldap ($userid); - $moodle_courses = $this -> get_courses_moodle ($userid); - - $to_enrol = array_diff_key ($ldap_courses, $moodle_courses); - $to_unenrol = array_diff_key ($moodle_courses, $ldap_courses); - - $all_moodle_courses = $this -> get_courses_moodle (); - - self::debuglog ( - "Enrolling user {$userid} to following courses: " . implode (", ", $to_enrol), - "sync_course_enrolments_user", - self::LOG_DEBUG - ); - foreach ($to_enrol as $name => $course) { - $courseObj = $all_moodle_courses[$name]; - - $enrol_instance = $this -> get_enrol_instance ($courseObj); - $this -> enrol_user ($enrol_instance, $user -> id, $role); - } - - self::debuglog ( - "Unenrolling user {$userid} from following courses: " . implode (", ", array_keys($to_unenrol)), - "sync_course_enrolments_user", - self::LOG_DEBUG - ); - foreach ($to_unenrol as $course) { - $enrol_instance = $this -> get_enrol_instance ($course); - $this -> unenrol_user ($enrol_instance, $user -> id); - } - } - - /** - * Ensures proper user enrolment for all IServ-originating courses - */ - public function sync_course_enrolments () { - $courses_category = self::get_courses_category ($this->config); - if (!$courses_category) { - return; - } - - $moodle_courses = $this -> get_courses_moodle(); - - foreach ($moodle_courses as $name => $course) { - $ldap_members = $this -> ldap_get_group_members($name, true); - - $context = context_course::instance ($course -> id); - $enrol_instance = $this -> get_enrol_instance ($course); - - if (!$enrol_instance) { - self::debuglog ( - "Could not get enrol instance for course {$name} @{$course -> id}, ignoring...", - "sync_course_enrolments" - ); - continue; - } - - $moodle_members = self::get_enrolled_usernames ($context); - $this -> course_enrolunenrol ($course, $enrol_instance, $moodle_members, $ldap_members); - } - } - - /** - * Get array of in-course enrolled users - * - * @param context $context Course context - * @return string[] Array of enrolled usernames - */ - private static function get_enrolled_usernames ($context) { - $moodle_user_objects = get_enrolled_users ($context); - $enrolled = array(); - - foreach($moodle_user_objects as $user) { - $enrolled[] = $user->username; - } - - return $enrolled; - } - - /** - * Gets the enrol instance for a course - * - * @param course $course Course to the get enrol instance for - * - * @return stdClass Enrol instance - */ - private function get_enrol_instance($course) { - global $DB; - - $enrol_instance = $DB -> get_record ( - 'enrol', - array( - 'enrol' => $this -> get_name(), - 'courseid' => $course -> id - ) - ); - - if (!$enrol_instance) { - $instanceid = $this -> add_default_instance ($course); - - if ($instanceid === null) { - $instanceid = $this->add_instance($course); - } - - $enrol_instance = $DB -> get_record ( - 'enrol', - array( - 'id' => $instanceid - ) - ); - } - - return $enrol_instance; - } - - /** - * Enrolls and unenrolls users from a course depending on a diff between two username arrays - * - * @param object $course Course object - * @param stdClass $enrol_instance Enrol instance to use for enrolling and Unenrolling - * @param array $current_state Array with currently enrolled users - * @param array $new_state Array with users that shall be enrolled afterwards - */ - private function course_enrolunenrol ($course, $enrol_instance, $current_state, $new_state) { - $to_enrol = array_diff ($new_state, $current_state); - $to_unenrol = array_diff ($current_state, $new_state); - $to_enrol_teachers = array (); - $to_enrol_students = array (); - - foreach ($to_enrol as $user) { - if ($this -> is_teacher ($user)) { - $to_enrol_teachers[] = $user; - } else { - $to_enrol_students[] = $user; - } - } - - if (!empty($to_enrol) || !empty($to_unenrol)) { - self::debuglog ( - "Users to enrol to {$course->shortname}: " . implode(",", $to_enrol), - "course_enrolunenrol", - self::LOG_DEBUG - ); - self::debuglog ( - "Users to unenrol from {$course->shortname}: " . implode(",", $to_unenrol), - "course_enrolunenrol", - self::LOG_DEBUG - ); - } - - if (!empty ($to_enrol_teachers)) { - $this -> course_enrol ( - $course, - $enrol_instance, - $to_enrol_teachers, - $this -> config -> courses_teacher_role - ); - } - - if (!empty ($to_enrol_students)) { - $this -> course_enrol ( - $course, - $enrol_instance, - $to_enrol_students, - $this -> config -> courses_student_role - ); - } - - if (!empty ($to_unenrol)) { - $this -> course_unenrol ( - $course, - $enrol_instance, - $to_unenrol - ); - } - } - - /** - * Enrolls users to a course with a specified role - * - * @param object $course Course object - * @param stdClass $enrol_instance Enrol instance to use for enrolling - * @param array $users Array of usernames to enrol - * @param int $role ID of role to assign to users - */ - private function course_enrol ($course, $enrol_instance, $users, $role) { - global $DB; - - if (!is_array($users)) $users = array($users); - - foreach ($users as $username) { - $user = $DB -> get_record ( - 'user', - array ( - 'username' => $username, - 'auth' => 'ldap' - ) - ); - - if (!$user) { - self::debuglog ( - "User {$username} not found in LDAP users", - "course_enrol" - ); - continue; - } - - $this -> enrol_user ($enrol_instance, $user->id, $role); - self::debuglog ( - "Enrolled user {$username} @{$user -> id} into course {$course -> shortname} @{$course -> id} with role @{$role}", - "course_enrol", - self::LOG_MTRACE - ); - } - } - - /** - * Unenrolls users from a course - * - * @param object $course Course object - * @param stdClass $enrol_instance Enrol instance to use for Unenrolling - * @param array $users Array of usernames to unenrol - */ - private function course_unenrol ($course, $enrol_instance, $users) { - global $DB; - if (!is_array($users)) $users = array($users); - - foreach ($users as $username) { - $user = $DB->get_record ( - 'user', - array ( - 'username' => $username, - 'auth' => 'ldap' - ) - ); - - if (!$user) { - self::debuglog ( - "User {$username} not found in LDAP users", - "course_enrol" - ); - continue; - } - - $this -> unenrol_user ($enrol_instance, $user->id); - - self::debuglog ( - "Unenrolled user {$username} @{$user -> id} from course {$course -> shortname} @{$course -> id}", - "course_unenrol", - self::LOG_MTRACE - ); - } - } +class enrol_iserv_plugin extends enrol_plugin +{ + protected $enroltype = 'enrol_iserv'; + static protected $error_log_tag = 'ENROL ISERV'; + static protected $idnumber_course_category = 'iserv_courses'; + protected $teacher_array = array(); + protected $auth_ldap; + + // Message logging modes + const LOG_NORMAL = 0; + const LOG_DEBUG = 1; + const LOG_MTRACE = 2; + + public function __construct() + { + global $CFG; + require_once($CFG->libdir . '/accesslib.php'); + require_once($CFG->libdir . '/ldaplib.php'); + require_once($CFG->libdir . '/moodlelib.php'); + require_once($CFG->libdir . '/enrollib.php'); + require_once($CFG->libdir . '/dml/moodle_database.php'); + require_once($CFG->dirroot . '/group/lib.php'); + require_once($CFG->dirroot . '/auth/ldap/auth.php'); + require_once($CFG->dirroot . '/course/lib.php'); + + $this->load_config(); + // Make sure we get sane defaults for critical values. + $this->config->ldapencoding = $this->get_config('ldapencoding', 'utf-8'); + $this->config->user_type = $this->get_config('user_type', 'default'); + + $ldap_usertypes = ldap_supported_usertypes(); + $this->config->user_type_name = $ldap_usertypes[$this->config->user_type]; + unset($ldap_usertypes); + + $default = ldap_getdefaults(); + // Use defaults if values not given. Dont use this->get_config() + // here to be able to check for 0 and false values too. + foreach ($default as $key => $value) { + // Watch out - 0, false are correct values too, so we can't use $this->get_config(). + if (!isset($this->config->{$key}) or $this->config->{$key} == '') { + $this->config->{$key} = $value[$this->config->user_type]; + } + } + } + + /** + * Custom message logging function + * @param string $text Message text to be logged + * @param string $func Optional: Current function the message is logged from + * @param bool $mode Optional: Specify logging mode, see LOG_ constants in class + */ + static function debuglog($text, $func = "Generic", $mode = 0) + { + $error_log_tag = self::$error_log_tag; + $now = date("H:i:s"); + $line = "[{$error_log_tag} -> {$func} @ {$now}] {$text}"; + + if (defined("CLI_SCRIPT")) + $mode = -1; + + switch ($mode) { + case -1: + print ($line . PHP_EOL); + break; + case self::LOG_DEBUG: + debugging($line, DEBUG_DEVELOPER); + break; + case self::LOG_MTRACE: + mtrace($line); + break; + default: + debugging($line); + } + } + + /** + * Get courses from LDAP and sync them with Moodle courses, then + * ensure users are properly enrolled into those courses + * + * @param string $userid User ID to sync enrolments with + */ + public function sync_courses($userid = "*") + { + if ($this->config->courses_autocreate || $this->config->courses_autoremove) { + self::debuglog( + "Syncing courses for user {$userid}", + "sync_courses", + self::LOG_DEBUG + ); + + $ldap_courses = $this->get_courses_ldap(); + $moodle_courses = $this->get_courses_moodle(); + + if ($this->config->courses_autocreate) { + $to_add = array_diff_key($ldap_courses, $moodle_courses); + + if (!empty($to_add)) { + $this->create_courses($to_add); + } + } + + if ($this->config->courses_autoremove) { + $to_remove = array_diff_key($moodle_courses, $ldap_courses); + + if (!empty($to_remove)) { + $this->remove_courses(array_keys($to_remove)); + } + } + } + + if ($userid && strcmp($userid, "*") !== 0) { + $this->sync_course_enrolments_user($userid); + } else { + $this->sync_course_enrolments(); + } + + return true; + } + + /** + * create a list of class courses, whose names are given in an array + * + * @param array $classes + */ + private function create_courses($courses = []) + { + global $DB; + if (empty($courses)) + return; + + $courses_category = self::get_courses_category($this->config); + if (!$courses_category) + return; + + $template = $DB->get_record( + 'course', + array( + 'id' => $this->config->courses_template + ), + '*', + IGNORE_MISSING + ); + + if ($template) { + $template = $template->id; + } + + foreach ($courses as $shortname => $fullname) { + $this->create_course( + $shortname, + $courses_category->id, + $fullname, + $template + ); + + $newcourses = true; + } + if ($newcourses) { + context_coursecat::instance($courses_category->id)->mark_dirty(); + } + } + + /** + * Remove all courses given in array from course category + * + * @param array $courses Courses to remove + */ + private function remove_courses($courses = array()) + { + global $DB; + + if (empty($courses)) + return; + + $courses_category = self::get_courses_category($this->config); + if (!$courses_category) + return; + + $ids = array(); + foreach ($courses as $course) { + $record = $DB->get_record( + 'course', + array( + 'shortname' => $course, + 'category' => $courses_category->id + ), + '*' + ); + + $ids[] = $record->id; + } + + foreach ($ids as $id) { + $this->remove_course($id); + } + } + + /** + * Create a new course either as duplicate from $template or as new empty course + * + * @param string $name Short name of new course + * @param integer $category_id ID of parent category + * @param string $fullname Full name of new course + * @param number $template Template ID to clone new course from + * + * @return course object|false + */ + private function create_course($name, $category_id, $fullname = null, $template = 0) + { + global $CFG; + require_once($CFG->dirroot . '/course/externallib.php'); + require_once($CFG->dirroot . '/course/lib.php'); + + $course = false; + if ($fullname === null) + $fullname = $course; + + self::debuglog( + "Creating course {$fullname} @{$name} in category {$category_id} with template {$template}", + "create_course" + ); + + if (!$template) { + $data = new stdclass(); + $data->shortname = $name; + $data->fullname = $fullname; + $data->visible = 1; + $data->category = $category_id; + + try { + $course = create_course($data); + self::debuglog( + "Course {$fullname} @{$name} successfully created", + "create_course", + self::LOG_MTRACE + ); + } catch (Exception $e) { + self::debuglog( + "Course {$fullname} @{$name} failed to create: " . $e->getMessage(), + "create_course" + ); + } + } else { + try { + $course = core_course_external::duplicate_course($template, $fullname, $name, $category_id, 1); + $course = get_course($course["id"], false); + + self::debuglog( + "Course {$fullname} @{$name} successfully duplicated from template", + "create_course", + self::LOG_MTRACE + ); + } catch (Exception $e) { + self::debuglog( + "Course {$fullname} @{$name} failed to duplicate: " . $e->getMessage(), + "create_course" + ); + } + } + + return $course; + } + + /** + * Remove a course + * + * @param int $course_id ID of course to remove + * + * @return true True on success + * @return false False on failure + */ + private function remove_course($course_id) + { + global $CFG; + require_once($CFG->libdir . '/moodlelib.php'); + return delete_course($course_id); + } + + /** + * Search for courses based on LDAP groups + * + * @param string $userid Optional: ID of the user to get courses for + * + * @return array Associative array of internal group name and group description + * @return false False on failure + */ + private function get_courses_ldap($userid = "*") + { + // Used by a few conditionals below + $pattern = []; + + // Create course filter + if ($this->config->coursemapping_use_prefixes) { + $prefixes = explode(',', $this->config->coursemapping_prefixes); + foreach ($prefixes as $prefix) { + $pattern[] = "({$this->config->group_attribute}={$prefix})"; + } + $pattern = '(|' . implode($pattern) . ')'; + } else if ($this->config->coursemapping_use_attribute) { + // If coursemapping attribute value contains a comma, treat it as list of valid attributes (OR'ed) + if (strstr($this->config->coursemapping_attribute_value, '|') !== false) { + foreach (explode('|', $this->config->coursemapping_attribute_value) as $prefix) { + $pattern[] = "({$this->config->coursemapping_attribute}={$prefix})"; + } + $pattern = '(|' . implode($pattern) . ')'; + } else { + $pattern = "({$this->config->coursemapping_attribute}={$this->config->coursemapping_attribute_value})"; + } + } else { + self::debuglog( + "Invalid settings, enable either coursemapping_use_prefix or coursemapping_use_attribute", + "get_courses_ldap" + ); + return false; + } + + self::debuglog( + "Using filter {$pattern} to fetch courses for {$userid}", + "get_courses_ldap", + self::LOG_DEBUG + ); + + return $this->ldap_get_grouplist($userid, $pattern); + } + + /** + * Returns an array of class \core_course_list_element objects for the $userid + * + * @param string $userid ID of user to get Moodle courses for + * + * @return array Course array of Moodle courses + * + * @uses $USER + */ + private function get_courses_moodle($userid = "*") + { + global $USER; + $return_var = array(); + + if ($userid == "*") { + $user = null; + } else { + $user = \core_user::get_user_by_username($userid, 'id', null, IGNORE_MISSING); + if (!$user) { + self::debuglog( + "Could not find user with ID {$userid}", + "get_courses_moodle" + ); + return $return_var; + } + } + + $courses_category = self::get_courses_category($this->config); + if (!$courses_category) { + self::debuglog( + "Courses category not found", + "get_courses_moodle" + ); + return $return_var; + } + + // Sloppy fix for something I haven't figured out yet, to get all courses, + // set $USER->id to 1, which means guest, otherwise no courses are returned + $previous_user_id = $USER->id; + $USER->id = 1; + + $courselist = $courses_category->get_courses(); + foreach ($courselist as $record) { + if ($record->visible) { + $context = context_course::instance($record->id); + if (is_null($user) || is_enrolled($context, $user, '', true)) { + $return_var[$record->shortname] = $record; + } + } + } + + $USER->id = $previous_user_id; + return $return_var; + } + + /** + * Fetches and creates the class category if necessary + * + * @param object $config The configuration object from $this -> + * + * @return object Returns the category object on success + * @return false Returns false on failure + * + * @uses $DB + */ + private static function get_courses_category($config) + { + global $DB; + + // Fetch category object from DB + $category_obj = $DB->get_record( + 'course_categories', + array( + 'idnumber' => self::$idnumber_course_category, + 'parent' => 0 + ), + 'id', + IGNORE_MULTIPLE + ); + + // Create class category if needed + if (!$category_obj) { + if (isset($config->courses_category_autocreate) && $config->courses_category_autocreate) { + $category_obj = self::create_category( + $config->courses_category, + self::$idnumber_course_category, + get_string('courses_category_description', 'enrol_iserv') + ); + + if ($category_obj) { + self::debuglog( + "Created courses category {$category_obj->id}", + "get_course_category", + self::LOG_DEBUG + ); + } else { + self::debuglog( + "Could not autocreate courses category", + "get_course_category" + ); + return false; + } + } + } else { + self::debuglog( + "Trying to get course category with ID {$category_obj->id}", + "get_course_category", + self::LOG_DEBUG + ); + + $category_obj = \core_course_category::get( + $category_obj->id, + IGNORE_MISSING, + true + ); + } + + if (!$category_obj) { + self::debuglog( + "Courses category " . self::$idnumber_course_category . " not found", + "get_course_category" + ); + return false; + } + + return $category_obj; + } + + /** + * Renames the courses category + * + * @param string $name New courses category name + * + * @param true True on success + * @param false False on failure + */ + public function rename_courses_category($name) + { + global $DB; + + if (!$name || $name == "") + return false; + + self::debuglog( + "Renaming courses category to {$name}", + "rename_courses_category", + self::LOG_DEBUG + ); + + $category = self::get_courses_category($this->config); + + $result = $DB->set_field( + 'course_categories', + 'name', + $name, + array('id' => $category->id) + ); + + if ($result) { + self::debuglog( + "Success, marking category as dirty", + "rename_courses_category", + self::LOG_DEBUG + ); + + context_coursecat::instance($category->id)->mark_dirty(); + return true; + } + + self::debuglog( + "Renaming courses category failed", + "rename_courses_category", + self::LOG_DEBUG + ); + + return false; + } + + /** + * This function creates a course category and fixes the category path. + * @param string $name New category name + * @param string $idnumber New category idnumber + * @param string $description Descriptive text for the new course category + * @param object $parent course_categories parent object or 0 for top level category + * @param int $sortorder special sort order, 99999 order at end + * @param int $visible Specifies if the category is visible or hidden + * + * @return object Returns the new category object on success + * @return false Returns false on failure + * @uses $DB; + */ + private static function create_category($name, $idnumber, $description, $parent = 0, $sortorder = 0, $visible = 1) + { + global $DB; + + self::debuglog( + "Creating {$name} @{$idnumber} with sortorder ({$sortorder})", + "create_category", + self::LOG_DEBUG + ); + + $data = new stdClass(); + $data->name = $name; + $data->idnumber = $idnumber; + $data->description = $description; + $data->parent = $parent; + $data->visible = $visible; + + $category = \core_course_category::create($data); + + if (!$category) { + self::debuglog( + "Could not create new course category {$category->name} @{$category->idnumber}", + "create_category" + ); + return false; + } + + if ($sortorder != 0) { + self::debuglog( + "Changing course sortorder to {$sortorder}", + "create_category", + self::LOG_DEBUG + ); + + $DB->set_field( + 'course_categories', + 'sortorder', + $sortorder, + array( + 'id' => $category->id + ) + ); + context_coursecat::instance($category->id)->mark_dirty(); + fix_course_sortorder(); + } + + return $category; + } + + /** + * Get all groups from LDAP which match search criteria defined in settings + * + * A $group_pattern of the form "(|(cn=05*)(cn=06*)...)" can be provided, otherwise + * a default $group_pattern is generated. + * + * @param string $username Limits groups to those which username is a member of + * @param string $group_pattern LDAP filter pattern to filter groups + * + * @return array Associative array of interal group name and group description + * @return false False on failure + */ + private function ldap_get_grouplist($username = "*", $group_pattern = null) + { + self::debuglog( + "Started fetching groups for {$username}", + "ldap_get_grouplist", + self::LOG_DEBUG + ); + + $auth_ldap = $this->auth_ldap; + if (!isset($auth_ldap) or empty($auth_ldap)) { + $this->auth_ldap = $auth_ldap = get_auth_plugin('ldap'); + } + + self::debuglog( + "LDAP connecting...", + "ldap_get_grouplist", + self::LOG_DEBUG + ); + + $ldapconnection = $auth_ldap->ldap_connect(); + if (!$ldapconnection) { + self::debuglog( + "LDAP connection failed", + "ldap_get_grouplist" + ); + + return false; + } + + self::debuglog( + "LDAP connected", + "ldap_get_grouplist", + self::LOG_DEBUG + ); + + // Create LDAP filter if username is specified + if ($username == "*") { + $filter = ""; + } else { + $filter = "({$this->config->group_member_attribute}={$username})"; + } + + // Use generic LDAP group pattern if none was provided + if (is_null($group_pattern)) { + $group_pattern = $this->ldap_generate_group_pattern(); + } + + $filter = "(&{$group_pattern}{$filter}(objectclass={$this->config->group_object_class}))"; + $contexts = explode(';', $this->config->group_contexts); + $found_groups = array(); + + // Iterate through all group contexts to look up any matching groups + foreach ($contexts as $context) { + $context = trim($context); + if (empty($context)) + continue; + + if ($this->config->group_search_subtree) { + // Search groups in this context and subcontexts + $ldap_result = ldap_search($ldapconnection, $context, $filter, array( + $this->config->group_attribute + )); + } else { + // Search only in the current context + $ldap_result = ldap_list($ldapconnection, $context, $filter, array( + $this->config->group_attribute, + $this->config->group_fullname_attribute + )); + } + + $groups = ldap_get_entries($ldapconnection, $ldap_result); + + // Add found groups to found_groups array + for ($i = 0; $i < count($groups) - 1; $i++) { + $group_name = $groups[$i][$this->config->group_attribute][0]; + $group_desc = $groups[$i][$this->config->group_fullname_attribute][0]; + $found_groups[$group_name] = $group_desc; + } + } + + self::debuglog( + "LDAP closing...", + "ldap_get_grouplist", + self::LOG_DEBUG + ); + $auth_ldap->ldap_close(); + self::debuglog( + "LDAP closed...", + "ldap_get_grouplist", + self::LOG_DEBUG + ); + + return $found_groups; + } + + /** + * This function finds the user in the teacher array. + * + * @param string $userid ID of the user to check + */ + private function is_teacher($userid) + { + self::debuglog( + "Checking user {$userid}", + "is_teacher", + self::LOG_DEBUG + ); + + if (empty($userid)) { + self::debuglog( + "Function called with empty userid", + "is_teacher" + ); + return false; + } + + if (empty($this->teacher_array)) { + $this->init_teacher_array(); + } + + return in_array($userid, $this->teacher_array); + } + + /** + * This function inits the teacher_array once + * + * @return true True on success + * @return false False on failure + */ + private function init_teacher_array() + { + self::debuglog( + "Initializing array", + "init_teacher_array", + self::LOG_DEBUG + ); + + $this->teacher_array = $this->ldap_get_role_members($this->config->teachers_role_name, true); + + self::debuglog( + "Finished", + "init_teacher_array", + self::LOG_DEBUG + ); + + if (empty($this->teacher_array)) { + return false; + } + + return true; + } + + /** + * Search for group members on the IServ server within a specified group + * + * @param string $group Group to fetch members from + * + * @return string[] Array of users on success + * @return false False on failure + */ + private function ldap_get_group_members($group) + { + global $CFG, $DB; + + self::debuglog( + "Fetching members for group {$group}...", + "ldap_get_group_members", + self::LOG_DEBUG + ); + + $return_var = array(); + $members = array(); + $auth_ldap = $this->auth_ldap; + + if (!isset($auth_ldap) or empty($auth_ldap)) { + $this->auth_ldap = $auth_ldap = get_auth_plugin('ldap'); + } + + self::debuglog( + "LDAP connecting...", + "ldap_get_group_members", + self::LOG_DEBUG + ); + $ldapconnection = $auth_ldap->ldap_connect(); + + $group = core_text::convert($group, 'utf-8', $this->config->ldapencoding); + + if (!$ldapconnection) { + self::debuglog( + "LDAP connection failed", + "ldap_get_group_members" + ); + return $return_var; + } + + self::debuglog( + "LDAP connected", + "ldap_get_group_members", + self::LOG_DEBUG + ); + $group_query = "(&(cn=" . trim($group) . ")(objectClass={$this->config->group_object_class}))"; + $contexts = explode(';', $this->config->group_contexts); + + // Iterate trough all contexts and try to find the group there + foreach ($contexts as $context) { + $context = trim($context); + if (empty($context)) + continue; + + self::debuglog( + "LDAP Search in {$context} filters {$group_query}", + "ldap_get_group_members", + self::LOG_DEBUG + ); + $ldap_result = ldap_search($ldapconnection, $context, $group_query); + + if (!empty($ldap_result) AND ldap_count_entries($ldapconnection, $ldap_result)) { + self::debuglog( + "Fetching entries with ldap_get_entries...", + "ldap_get_group_members", + self::LOG_DEBUG + ); + + $entries = ldap_get_entries($ldapconnection, $ldap_result); + $group_member_attribute = strtolower($this->config->group_member_attribute); + + // Iterate over all matching groups in the current context and collect their members + $totalMembers = 0; + foreach ($entries as $entry) { + if (isset($entry[$group_member_attribute])) { + $memberCountInThisContext = count($entry[$group_member_attribute]); + + // Iterate through all members + for ($g = 0; $g < ($memberCountInThisContext - 1); $g++) { + $member = trim($entry[$group_member_attribute][$g]); + + // Skip blank members + if ($member == "") + continue; + + // Grab CN from DN if necessary + if ($this->config->group_member_attribute_is_dn) { + if (!$member = $this->get_cn_from_dn($member)) { + self::debuglog( + "Failed to fetch CN from DN '{$member}', check setting role_member_attribute_is_dn!", + "ldap_get_group_members" + ); + } + } + + // Add member to members array if they're not already part of it + if (!in_array($member, $members)) { + $members[] = $member; + $totalMembers++; + + self::debuglog( + "Found member {$member}", + "ldap_get_group_members", + self::LOG_DEBUG + ); + } + } + } + } + + self::debuglog( + "Number of members found for {$this->config->group_member_attribute}: {$totalMembers}", + "ldap_get_group_members", + self::LOG_DEBUG + ); + } + } + + self::debuglog( + "LDAP closing...", + "ldap_get_group_members", + self::LOG_DEBUG + ); + $auth_ldap->ldap_close(); + self::debuglog( + "LDAP closed", + "ldap_get_group_members", + self::LOG_DEBUG + ); + + // Generate SELECT statement for DB call + foreach ($members as $member) { + if (isset($select)) { + $select = "{$select},'{$member}'"; + } else { + $select = "'{$member}'"; + } + } + + // Fetch users from DB to match IDs to usernames + if (isset($select)) { + $select = "username IN ({$select})"; + $members = $DB->get_recordset_select('user', $select, null, null, 'id,username'); + foreach ($members as $member) { + $return_var[$member->id] = $member->username; + } + } else { + self::debuglog( + "No users found", + "ldap_get_group_members" + ); + } + return $return_var; + } + + /** + * Get all users that are member of an IServ role + * + * @param string $role Role to fetch members from + * + * @return string[] Array of users on success + * @return false False on failure + */ + private function ldap_get_role_members($role) + { + global $CFG, $DB; + + self::debuglog( + "Fetching members for role {$role}...", + "ldap_get_role_members", + self::LOG_DEBUG + ); + + $return_var = array(); + $members = array(); + $auth_ldap = $this->auth_ldap; + + if (!isset($auth_ldap) or empty($auth_ldap)) { + $this->auth_ldap = $auth_ldap = get_auth_plugin('ldap'); + } + + self::debuglog( + "LDAP connecting...", + "ldap_get_role_members", + self::LOG_DEBUG + ); + $ldapconnection = $auth_ldap->ldap_connect(); + + $role = core_text::convert($role, 'utf-8', $this->config->ldapencoding); + + if (!$ldapconnection) { + self::debuglog( + "LDAP connection failed", + "ldap_get_role_members" + ); + return $return_var; + } + + self::debuglog( + "LDAP connected", + "ldap_get_role_members", + self::LOG_DEBUG + ); + + $role = trim($role); + $role_query = "(&(cn={$role})(objectClass={$this->config->role_object_class}))"; + $contexts = explode(';', $this->config->role_contexts); + + // Iterate trough all contexts and try to find the role there + foreach ($contexts as $context) { + $context = trim($context); + if (empty($context)) + continue; + + self::debuglog( + "LDAP Search in {$context} filters {$role_query}", + "ldap_get_role_members", + self::LOG_DEBUG + ); + $ldap_result = ldap_search($ldapconnection, $context, $role_query); + + if (!empty($ldap_result) AND ldap_count_entries($ldapconnection, $ldap_result)) { + self::debuglog( + "Fetching entries with ldap_get_entries...", + "ldap_get_role_members", + self::LOG_DEBUG + ); + + $entries = ldap_get_entries($ldapconnection, $ldap_result); + $role_member_attribute = strtolower($this->config->role_member_attribute); + + // Iterate over all matching roles in the current context and collect their members + $totalMembers = 0; + foreach ($entries as $entry) { + if (isset($entry[$role_member_attribute])) { + $memberCountInThisContext = count($entry[$role_member_attribute]); + + // Iterate through all members + for ($g = 0; $g < ($memberCountInThisContext - 1); $g++) { + $member = trim($entry[$role_member_attribute][$g]); + + // Skip blank members + if ($member == "") + continue; + + // Grab CN from DN if necessary + if ($this->config->role_member_attribute_is_dn) { + if (!$member = $this->get_cn_from_dn($member)) { + self::debuglog( + "Failed to fetch CN from DN '{$member}', check setting role_member_attribute_is_dn!", + "ldap_get_role_members" + ); + } + } + + // Add member to members array if they're not already part of it + if (!in_array($member, $members)) { + $members[] = $member; + $totalMembers++; + + self::debuglog( + "Found member {$member}", + "ldap_get_role_members", + self::LOG_DEBUG + ); + } + } + } + } + + self::debuglog( + "Number of members found for {$this->config->role_member_attribute}: {$totalMembers}", + "ldap_get_role_members", + self::LOG_DEBUG + ); + } + } + + self::debuglog( + "LDAP closing...", + "ldap_get_role_members", + self::LOG_DEBUG + ); + $auth_ldap->ldap_close(); + self::debuglog( + "LDAP closed", + "ldap_get_role_members", + self::LOG_DEBUG + ); + + // Generate SELECT statement for DB call + foreach ($members as $member) { + if (isset($select)) { + $select = "{$select},'{$member}'"; + } else { + $select = "'{$member}'"; + } + } + + // Fetch users from DB to match IDs to usernames + if (isset($select)) { + $select = "username IN ({$select})"; + $members = $DB->get_recordset_select('user', $select, null, null, 'id,username'); + foreach ($members as $member) { + $return_var[$member->id] = $member->username; + } + } else { + self::debuglog( + "No users found", + "ldap_get_role_members" + ); + } + + return $return_var; + } + + /** + * Returns only the CN of a DN + * + * @param string $dn DN to grab the CN from + * + * @return string CN of the DN + */ + private function get_cn_from_dn($dn) + { + preg_match("~^cn=(.*?),~", $dn, $matches); + if (count($matches) == 2) { + return $matches[1]; + } + + return false; + } + + /** + * Generate an LDAP search pattern including all groups + * + * @return string + */ + private function ldap_generate_group_pattern() + { + $pattern[] = "(objectClass={$this->config->group_object_class})"; + $pattern = '(|' . implode($pattern) . ')'; + return $pattern; + } + + /** + * Syncs course enrolments for a single user + * + * @param string $userid + */ + public function sync_course_enrolments_user($userid) + { + global $DB; + if (!$userid) + return; + + self::debuglog( + "Syncing course enrolments for {$userid}", + "sync_course_enrolments_user", + self::LOG_DEBUG + ); + + $user = \core_user::get_user_by_username($userid, 'id'); + + if ($this->is_teacher($userid)) { + $role = $this->config->courses_teacher_role; + } else { + $role = $this->config->courses_student_role; + } + + $ldap_courses = $this->get_courses_ldap($userid); + $moodle_courses = $this->get_courses_moodle($userid); + + $to_enrol = array_diff_key($ldap_courses, $moodle_courses); + $to_unenrol = array_diff_key($moodle_courses, $ldap_courses); + + $all_moodle_courses = $this->get_courses_moodle(); + + self::debuglog( + "Enrolling user {$userid} to following courses: " . implode(", ", $to_enrol), + "sync_course_enrolments_user", + self::LOG_DEBUG + ); + foreach ($to_enrol as $name => $course) { + $courseObj = $all_moodle_courses[$name]; + + $enrol_instance = $this->get_enrol_instance($courseObj); + $this->enrol_user($enrol_instance, $user->id, $role); + } + + self::debuglog( + "Unenrolling user {$userid} from following courses: " . implode(", ", array_keys($to_unenrol)), + "sync_course_enrolments_user", + self::LOG_DEBUG + ); + foreach ($to_unenrol as $course) { + $enrol_instance = $this->get_enrol_instance($course); + $this->unenrol_user($enrol_instance, $user->id); + } + } + + /** + * Ensures proper user enrolment for all IServ-originating courses + */ + public function sync_course_enrolments() + { + $courses_category = self::get_courses_category($this->config); + if (!$courses_category) { + return; + } + + $moodle_courses = $this->get_courses_moodle(); + + foreach ($moodle_courses as $name => $course) { + $ldap_members = $this->ldap_get_group_members($name, true); + + $context = context_course::instance($course->id); + $enrol_instance = $this->get_enrol_instance($course); + + if (!$enrol_instance) { + self::debuglog( + "Could not get enrol instance for course {$name} @{$course->id}, ignoring...", + "sync_course_enrolments" + ); + continue; + } + + $moodle_members = self::get_enrolled_usernames($context); + $this->course_enrolunenrol($course, $enrol_instance, $moodle_members, $ldap_members); + } + } + + /** + * Get array of in-course enrolled users + * + * @param context $context Course context + * @return string[] Array of enrolled usernames + */ + private static function get_enrolled_usernames($context) + { + $moodle_user_objects = get_enrolled_users($context); + $enrolled = array(); + + foreach ($moodle_user_objects as $user) { + $enrolled[] = $user->username; + } + + return $enrolled; + } + + /** + * Gets the enrol instance for a course + * + * @param course $course Course to the get enrol instance for + * + * @return stdClass Enrol instance + */ + private function get_enrol_instance($course) + { + global $DB; + + $enrol_instance = $DB->get_record( + 'enrol', + array( + 'enrol' => $this->get_name(), + 'courseid' => $course->id + ) + ); + + if (!$enrol_instance) { + $instanceid = $this->add_default_instance($course); + + if ($instanceid === null) { + $instanceid = $this->add_instance($course); + } + + $enrol_instance = $DB->get_record( + 'enrol', + array( + 'id' => $instanceid + ) + ); + } + + return $enrol_instance; + } + + /** + * Enrolls and unenrolls users from a course depending on a diff between two username arrays + * + * @param object $course Course object + * @param stdClass $enrol_instance Enrol instance to use for enrolling and Unenrolling + * @param array $current_state Array with currently enrolled users + * @param array $new_state Array with users that shall be enrolled afterwards + */ + private function course_enrolunenrol($course, $enrol_instance, $current_state, $new_state) + { + $to_enrol = array_diff($new_state, $current_state); + $to_unenrol = array_diff($current_state, $new_state); + $to_enrol_teachers = array(); + $to_enrol_students = array(); + + foreach ($to_enrol as $user) { + if ($this->is_teacher($user)) { + $to_enrol_teachers[] = $user; + } else { + $to_enrol_students[] = $user; + } + } + + if (!empty($to_enrol) || !empty($to_unenrol)) { + self::debuglog( + "Users to enrol to {$course->shortname}: " . implode(",", $to_enrol), + "course_enrolunenrol", + self::LOG_DEBUG + ); + self::debuglog( + "Users to unenrol from {$course->shortname}: " . implode(",", $to_unenrol), + "course_enrolunenrol", + self::LOG_DEBUG + ); + } + + if (!empty($to_enrol_teachers)) { + $this->course_enrol( + $course, + $enrol_instance, + $to_enrol_teachers, + $this->config->courses_teacher_role + ); + } + + if (!empty($to_enrol_students)) { + $this->course_enrol( + $course, + $enrol_instance, + $to_enrol_students, + $this->config->courses_student_role + ); + } + + if (!empty($to_unenrol)) { + $this->course_unenrol( + $course, + $enrol_instance, + $to_unenrol + ); + } + } + + /** + * Enrolls users to a course with a specified role + * + * @param object $course Course object + * @param stdClass $enrol_instance Enrol instance to use for enrolling + * @param array $users Array of usernames to enrol + * @param int $role ID of role to assign to users + */ + private function course_enrol($course, $enrol_instance, $users, $role) + { + global $DB; + + if (!is_array($users)) + $users = array($users); + + foreach ($users as $username) { + $user = $DB->get_record( + 'user', + array( + 'username' => $username, + 'auth' => 'ldap' + ) + ); + + if (!$user) { + self::debuglog( + "User {$username} not found in LDAP users", + "course_enrol" + ); + continue; + } + + $this->enrol_user($enrol_instance, $user->id, $role); + self::debuglog( + "Enrolled user {$username} @{$user->id} into course {$course->shortname} @{$course->id} with role @{$role}", + "course_enrol", + self::LOG_MTRACE + ); + } + } + + /** + * Unenrolls users from a course + * + * @param object $course Course object + * @param stdClass $enrol_instance Enrol instance to use for Unenrolling + * @param array $users Array of usernames to unenrol + */ + private function course_unenrol($course, $enrol_instance, $users) + { + global $DB; + if (!is_array($users)) + $users = array($users); + + foreach ($users as $username) { + $user = $DB->get_record( + 'user', + array( + 'username' => $username, + 'auth' => 'ldap' + ) + ); + + if (!$user) { + self::debuglog( + "User {$username} not found in LDAP users", + "course_enrol" + ); + continue; + } + + $this->unenrol_user($enrol_instance, $user->id); + + self::debuglog( + "Unenrolled user {$username} @{$user->id} from course {$course->shortname} @{$course->id}", + "course_unenrol", + self::LOG_MTRACE + ); + } + } } \ No newline at end of file diff --git a/version.php b/version.php index b32395b..7cd2756 100644 --- a/version.php +++ b/version.php @@ -32,9 +32,9 @@ defined('MOODLE_INTERNAL') || die(); -$plugin -> version = 2023102210; // The current plugin version (Date: YYYYMMDDXX). -$plugin -> requires = 2015051100; // Requires Moodle version 2.9 -$plugin -> component = 'enrol_iserv'; // Full name of the plugin (used for diagnostics). -$plugin -> maturity = MATURITY_BETA; // Beta, nees testing. -$plugin -> release = '2.3 (Build: 2023081202)'; -$plugin -> dependencies = array('auth_ldap' => ANY_VERSION); +$plugin->version = 2026021601; // The current plugin version (Date: YYYYMMDDXX). +$plugin->requires = 2015051100; // Requires Moodle version 2.9 +$plugin->component = 'enrol_iserv'; // Full name of the plugin (used for diagnostics). +$plugin->maturity = MATURITY_BETA; // Beta, nees testing. +$plugin->release = "2.3 (Build: {$plugin->version})"; +$plugin->dependencies = array('auth_ldap' => ANY_VERSION);