├── .github └── workflows │ └── moodle-ci.yml ├── .gitignore ├── CHANGELOG.md ├── README.md ├── classes ├── privacy │ └── provider.php └── task │ ├── attribute_sync_task.php │ └── group_sync_task.php ├── cli ├── sync_cohorts.php └── sync_cohorts_attribute.php ├── db └── tasks.php ├── lang └── en │ └── local_ldap.php ├── locallib.php ├── pix └── icon.gif ├── settings.php ├── tests ├── sync_ad_test.php ├── sync_base_testcase.php └── sync_rfc2307_test.php └── version.php /.github/workflows/moodle-ci.yml: -------------------------------------------------------------------------------- 1 | name: Moodle Plugin CI 2 | on: 3 | push: 4 | branches: 5 | - '*' 6 | pull_request: 7 | branches: 8 | - '*' 9 | schedule: 10 | - cron: '0 8 * * 5' 11 | defaults: 12 | run: 13 | shell: bash -l {0} 14 | jobs: 15 | test: 16 | runs-on: ubuntu-22.04 17 | env: 18 | IGNORE_PATHS: tests/sync_base_testcase.php 19 | CODECHECKER_IGNORE_PATHS: tests/sync_base_testcase.php,locallib.php 20 | PHPCPD_IGNORE_PATHS: tests/sync_base_testcase.php,locallib.php 21 | 22 | services: 23 | postgres: 24 | image: postgres:14 25 | env: 26 | POSTGRES_USER: 'postgres' 27 | POSTGRES_HOST_AUTH_METHOD: 'trust' 28 | ports: 29 | - 5432:5432 30 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 3 31 | mariadb: 32 | image: mariadb:10 33 | env: 34 | MYSQL_USER: 'root' 35 | MYSQL_ALLOW_EMPTY_PASSWORD: "true" 36 | ports: 37 | - 3306:3306 38 | options: --health-cmd="mysqladmin ping" --health-interval 10s --health-timeout 5s --health-retries 3 39 | ldap: 40 | image: bitnami/openldap 41 | ports: 42 | - 3389:3389 43 | env: 44 | LDAP_ADMIN_USERNAME: admin 45 | LDAP_ADMIN_PASSWORD: password 46 | LDAP_ROOT: dc=example,dc=com 47 | LDAP_PORT_NUMBER: 3389 48 | 49 | strategy: 50 | fail-fast: false 51 | matrix: 52 | php: ['8.1', '8.2', '8.3', '8.4'] 53 | moodle-branch: ['MOODLE_404_STABLE', 'MOODLE_405_STABLE', 'MOODLE_500_STABLE', 'main'] 54 | database: [pgsql, mariadb] 55 | exclude: 56 | - {moodle-branch: 'MOODLE_404_STABLE', php: '8.4', database: 'pgsql'} 57 | - {moodle-branch: 'MOODLE_404_STABLE', php: '8.4', database: 'mariadb'} 58 | - {moodle-branch: 'MOODLE_405_STABLE', php: '8.4', database: 'pgsql'} 59 | - {moodle-branch: 'MOODLE_405_STABLE', php: '8.4', database: 'mariadb'} 60 | - {moodle-branch: 'MOODLE_500_STABLE', php: '8.1', database: 'pgsql'} 61 | - {moodle-branch: 'MOODLE_500_STABLE', php: '8.1', database: 'mariadb'} 62 | - {moodle-branch: 'main', php: '8.1', database: 'pgsql'} 63 | - {moodle-branch: 'main', php: '8.1', database: 'mariadb'} 64 | 65 | steps: 66 | - name: Check out repository code 67 | uses: actions/checkout@v3 68 | with: 69 | path: plugin 70 | 71 | - name: Setup PHP ${{ matrix.php }} 72 | uses: shivammathur/setup-php@v2 73 | with: 74 | php-version: ${{ matrix.php }} 75 | extensions: ${{ matrix.extensions }} 76 | ini-values: max_input_vars=5000 77 | coverage: none 78 | 79 | - name: Initialise moodle-plugin-ci 80 | run: | 81 | composer create-project -n --no-dev --prefer-dist moodlehq/moodle-plugin-ci ci ^4 82 | echo $(cd ci/bin; pwd) >> $GITHUB_PATH 83 | echo $(cd ci/vendor/bin; pwd) >> $GITHUB_PATH 84 | sudo locale-gen en_AU.UTF-8 85 | echo "NVM_DIR=$HOME/.nvm" >> $GITHUB_ENV 86 | - name: Install moodle-plugin-ci 87 | run: | 88 | moodle-plugin-ci install --plugin ./plugin --db-host=127.0.0.1 89 | env: 90 | DB: ${{ matrix.database }} 91 | MOODLE_BRANCH: ${{ matrix.moodle-branch }} 92 | 93 | - name: Install Moodle 94 | run: | 95 | nvm use ${{ matrix.node-versions }} 96 | moodle-plugin-ci install -vvv --plugin ./plugin --db-host=127.0.0.1 --node-version=${{ matrix.node-versions }} 97 | moodle-plugin-ci add-config '$CFG->auth = "manual,ldap";' 98 | moodle-plugin-ci add-config 'define("TEST_AUTH_LDAP_HOST_URL", "ldap://localhost:3389");' 99 | moodle-plugin-ci add-config 'define("TEST_AUTH_LDAP_BIND_DN", "cn=admin,dc=example,dc=com");' 100 | moodle-plugin-ci add-config 'define("TEST_AUTH_LDAP_BIND_PW", "password");' 101 | moodle-plugin-ci add-config 'define("TEST_AUTH_LDAP_DOMAIN", "dc=example,dc=com");' 102 | env: 103 | DB: ${{ matrix.database }} 104 | MOODLE_BRANCH: ${{ matrix.moodle-version }} 105 | 106 | - name: PHP Lint 107 | if: ${{ always() }} 108 | run: moodle-plugin-ci phplint 109 | 110 | - name: PHP Copy/Paste Detector 111 | continue-on-error: true # This step will show errors but will not fail 112 | if: ${{ always() }} 113 | run: moodle-plugin-ci phpcpd 114 | 115 | - name: PHP Mess Detector 116 | continue-on-error: true # This step will show errors but will not fail 117 | if: ${{ always() }} 118 | run: moodle-plugin-ci phpmd 119 | 120 | - name: Moodle Code Checker 121 | if: ${{ always() }} 122 | run: moodle-plugin-ci codechecker 123 | 124 | - name: Moodle PHPDoc Checker 125 | if: ${{ always() }} 126 | run: moodle-plugin-ci phpdoc 127 | 128 | - name: Validating 129 | if: ${{ always() }} 130 | run: moodle-plugin-ci validate 131 | 132 | - name: Check upgrade savepoints 133 | if: ${{ always() }} 134 | run: moodle-plugin-ci savepoints 135 | 136 | - name: Mustache Lint 137 | if: ${{ always() }} 138 | run: moodle-plugin-ci mustache 139 | 140 | - name: Grunt 141 | if: ${{ always() }} 142 | run: moodle-plugin-ci grunt --max-lint-warnings 0 143 | 144 | - name: PHPUnit tests 145 | if: ${{ always() }} 146 | run: moodle-plugin-ci phpunit 147 | 148 | - name: Behat features 149 | if: ${{ always() }} 150 | run: moodle-plugin-ci behat --profile chrome 151 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v4.4.0 (April 24,2024) 4 | 5 | - Dropped support for Moodle 4.1-4.3 6 | - Added support for Moodle 4.4 7 | 8 | ## v4.1.0 (March 13, 2024) 9 | 10 | - Migrate CI builds to Github Actions 11 | - Code cleanup 12 | - Fixed issue with empty nested groups (props [#@AdamosD7](https://github.com/AdamosD7)) 13 | - Dropped support for Moodle 3.11-4.0 14 | 15 | ## v3.11.1 (April 6, 2023) 16 | 17 | - Fixed AD pagination issue introduced in v3.11.0 18 | - Fixed issue with empty groups in AD (props [@djlauk](https://github.com/djlauk)) 19 | 20 | ## v3.11.0 (February 27, 2023) 21 | 22 | - Dropped support for Moodle 3.7-3.10 23 | - Fixed countable issues under PHP 8.0 24 | - Rebuilt pagination to remove pre-PHP 7.3 support 25 | 26 | ## 3.7.1 (May 17, 2021) 27 | 28 | - Prevent mass-unenrollment when a connection fails 29 | 30 | ## 3.7.0 (November 9, 2020) 31 | 32 | - Update pagination for PHP 7.4 33 | - Change default branch to "main" 34 | - Update CI tool to version 3 35 | - Dropped support for Moodle 3.6 36 | 37 | ## 3.6.0 (June 15, 2020) 38 | 39 | - Code cleanup 40 | - Streamlined unit testing matrix 41 | - Dropped support for Moodle 3.4 and 3.5 42 | 43 | ## 3.4.2 (May 19, 2019) 44 | 45 | - Minor code cleanup and internal documentation fixes 46 | 47 | ## 3.4.1 (September 7, 2018) 48 | 49 | - Fixed bug where attribute syncing could fail in large Active Directory environments 50 | - Fixed bug where group syncing could fail in large Active Directory environments 51 | - Updated tests to use large data sets 52 | - Added optional unit test support for Active Directory 53 | 54 | ## 3.4.0 (May 4, 2018) 55 | 56 | - Updated for GDPR compliance 57 | - Fixed bug where parentheses were not filtered correctly (thanks to [@cperves](https://github.com/cperves) for the report) 58 | 59 | ## 3.3.0 (August 9, 2017) 60 | 61 | - Changed version numbering to match stable version 62 | - Bugfix for [MDL-57558](https://tracker.moodle.org/browse/MDL-57558): attribute sync was broken by Moodle 3.3.1 63 | 64 | ## 2.0.1 (April 24, 2017) 65 | 66 | - Updated tests to support [MDL-12689](https://tracker.moodle.org/browse/MDL-12689) 67 | 68 | ## 2.0.0 (July 15, 2015) 69 | 70 | - Official support for Moodle 2.9-Moodle 3.1 71 | - Migrated CLI script to scheduled task 72 | - Unit test coverage for OpenLDAP 73 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | LDAP syncing scripts 2 | ===================== 3 | 4 | ![Moodle Plugin CI](https://github.com/LafColITS/moodle-local_ldap/workflows/Moodle%20Plugin%20CI/badge.svg) 5 | 6 | This plugin synchronizes Moodle cohorts against an LDAP directory using either group memberships or attribute values. This is a continuation of Patrick Pollet's [local_ldap](https://github.com/patrickpollet/moodle_local_ldap) plugin, which in turn was inspired by [MDL-25011](https://tracker.moodle.org/browse/MDL-25011) and [MDL-25054](https://tracker.moodle.org/browse/MDL-25054). 7 | 8 | Requirements 9 | ------------ 10 | - Moodle 4.4 (build 2024042200 or later) 11 | - OpenLDAP or Active Directory 12 | 13 | Installation 14 | ------------ 15 | Copy the ldap folder into your /local directory and visit your Admin Notification page to complete the installation. You must have either the CAS or LDAP authentication method enabled. 16 | 17 | Configuration 18 | ------------- 19 | Depending on your environment the plugin may work with default options. Configuration settings include the group class (`groupOfNames` by default) and whether to automatically import all found LDAP groups as cohorts. By default this setting is disabled. 20 | 21 | Usage 22 | ----- 23 | Previous versions of this plugin used a CLI script. This is deprecated in favor of two [scheduled tasks](https://docs.moodle.org/31/en/Scheduled_tasks), one for syncing by group and another for syncing by attribute. Both are configured to run hourly and are disabled by default. 24 | 25 | Testing 26 | ------- 27 | The code is tested against OpenLDAP on Travis CI. If you have a local Active Directory environment you may run the tests against it. See [PHPUnit#LDAP](https://docs.moodle.org/dev/PHPUnit#LDAP) for more information. You will need to set an additional constant, `TEST_AUTH_LDAP_USER_TYPE`, to `ad`. 28 | 29 | Author 30 | ----- 31 | - Charles Fulton (fultonc@lafayette.edu) 32 | - Patrick Pollet 33 | -------------------------------------------------------------------------------- /classes/privacy/provider.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Privacy implementation for local_ldap.. 19 | * 20 | * @package local_ldap 21 | * @copyright 2018 Lafayette College ITS 22 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 | */ 24 | 25 | namespace local_ldap\privacy; 26 | 27 | /** 28 | * Privacy subsystem for local_ldap implementing null_provider. 29 | * 30 | * @package local_ldap 31 | * @copyright 2018 Lafayette College ITS 32 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 33 | */ 34 | class provider implements 35 | // This plugin does not store any personal user data. 36 | \core_privacy\local\metadata\null_provider { 37 | 38 | /** 39 | * Get the language string identifier with the component's language 40 | * file to explain why this plugin stores no data. 41 | * 42 | * @return string 43 | */ 44 | public static function get_reason(): string { 45 | return 'privacy:metadata'; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /classes/task/attribute_sync_task.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Scheduled task to sync cohorts based on attribute. 19 | * 20 | * @package local_ldap 21 | * @copyright 2016 Lafayette College ITS 22 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 | */ 24 | 25 | namespace local_ldap\task; 26 | 27 | defined('MOODLE_INTERNAL') || die(); 28 | 29 | require_once($CFG->dirroot.'/local/ldap/locallib.php'); 30 | 31 | /** 32 | * Scheduled task to sync cohorts based on attributes. 33 | * 34 | * @package local_ldap 35 | * @copyright 2016 Lafayette College ITS 36 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 37 | */ 38 | class attribute_sync_task extends \core\task\scheduled_task { 39 | /** 40 | * Get the name of the task. 41 | * 42 | * @return string the name of the task 43 | */ 44 | public function get_name() { 45 | return get_string('attributesynctask', 'local_ldap'); 46 | } 47 | 48 | /** 49 | * Execute the task. 50 | * 51 | * @see local_ldap::sync_cohorts_by_attribute() 52 | */ 53 | public function execute() { 54 | if ($plugin = new \local_ldap()) { 55 | $plugin->sync_cohorts_by_attribute(); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /classes/task/group_sync_task.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Scheduled task to sync cohorts based on group membership. 19 | * 20 | * @package local_ldap 21 | * @copyright 2016 Lafayette College ITS 22 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 | */ 24 | 25 | namespace local_ldap\task; 26 | 27 | defined('MOODLE_INTERNAL') || die(); 28 | 29 | require_once($CFG->dirroot.'/local/ldap/locallib.php'); 30 | 31 | /** 32 | * Scheduled task to sync cohorts based on group membership. 33 | * 34 | * @package local_ldap 35 | * @copyright 2016 Lafayette College ITS 36 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 37 | */ 38 | class group_sync_task extends \core\task\scheduled_task { 39 | /** 40 | * Get the name of the task. 41 | * 42 | * @return string the name of the task 43 | */ 44 | public function get_name() { 45 | return get_string('groupsynctask', 'local_ldap'); 46 | } 47 | 48 | /** 49 | * Execute the task. 50 | * 51 | * @see local_ldap::sync_cohorts_by_group() 52 | */ 53 | public function execute() { 54 | if ($plugin = new \local_ldap()) { 55 | $plugin->sync_cohorts_by_group(); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /cli/sync_cohorts.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Cohort sync with LDAP groups script. This is deprecated. 19 | * 20 | * @package local_ldap 21 | * @copyright 2010 Patrick Pollet - based on code by Jeremy Guittirez 22 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 | */ 24 | 25 | define('CLI_SCRIPT', true); 26 | 27 | require(__DIR__.'/../../../config.php'); // Global moodle config file. 28 | require_once($CFG->dirroot.'/local/ldap/locallib.php'); 29 | require_once($CFG->libdir.'/clilib.php'); 30 | 31 | // Ensure errors are well explained. 32 | set_debugging(DEBUG_DEVELOPER, true); 33 | 34 | if ( !is_enabled_auth('cas') && !is_enabled_auth('ldap')) { 35 | cli_problem('[LOCAL LDAP] ' . get_string('pluginnotenabled', 'auth_ldap')); 36 | die; 37 | } 38 | 39 | cli_problem('[LOCAL LDAP] The cohort sync cron has been deprecated. Please use the scheduled task instead.'); 40 | 41 | // Abort execution of the CLI script if the local_ldap\task\group_sync_task is enabled. 42 | $taskdisabled = \core\task\manager::get_scheduled_task('local_ldap\task\group_sync_task'); 43 | if (!$taskdisabled->get_disabled()) { 44 | cli_error('[LOCAL LDAP] The scheduled task group_sync_task is enabled, the cron execution has been aborted.'); 45 | } 46 | 47 | $localldap = new local_ldap(); 48 | $localldap->sync_cohorts_by_group(); 49 | -------------------------------------------------------------------------------- /cli/sync_cohorts_attribute.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Cohort sync with LDAP attribute script. This is deprecated. 19 | * 20 | * @package local_ldap 21 | * @copyright 2010 Patrick Pollet - based on code by Jeremy Guittirez 22 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 | */ 24 | 25 | define('CLI_SCRIPT', true); 26 | 27 | require(__DIR__.'/../../../config.php'); // Global moodle config file. 28 | require_once($CFG->dirroot.'/local/ldap/locallib.php'); 29 | require_once($CFG->libdir.'/clilib.php'); 30 | 31 | // Ensure errors are well explained. 32 | set_debugging(DEBUG_DEVELOPER, true); 33 | 34 | if ( !is_enabled_auth('cas') && !is_enabled_auth('ldap')) { 35 | cli_problem('[LOCAL LDAP] ' . get_string('pluginnotenabled', 'auth_ldap')); 36 | die; 37 | } 38 | 39 | cli_problem('[LOCAL LDAP] The cohort sync cron has been deprecated. Please use the scheduled task instead.'); 40 | 41 | $plugin = new auth_plugin_cohort(); 42 | 43 | // Abort execution of the CLI script if the local_ldap\task\group_sync_task is enabled. 44 | $taskdisabled = \core\task\manager::get_scheduled_task('local_ldap\task\attribute_sync_task'); 45 | if (!$taskdisabled->get_disabled()) { 46 | cli_error('[LOCAL LDAP] The scheduled task attributes_sync_task is enabled, the cron execution has been aborted.'); 47 | } 48 | 49 | $localldap = new local_ldap(); 50 | $localldap->sync_cohorts_by_attribute(); 51 | -------------------------------------------------------------------------------- /db/tasks.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Task definitions for local_ldap. 19 | * 20 | * @package local_ldap 21 | * @category task 22 | * @copyright 2016 Lafayette College ITS 23 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 24 | */ 25 | 26 | defined('MOODLE_INTERNAL') || die(); 27 | 28 | $tasks = [ 29 | [ 30 | 'classname' => 'local_ldap\task\attribute_sync_task', 31 | 'blocking' => 0, 32 | 'minute' => '0', 33 | 'hour' => '*', 34 | 'day' => '*', 35 | 'month' => '*', 36 | 'dayofweek' => '*', 37 | 'disabled' => 1, 38 | ], 39 | [ 40 | 'classname' => 'local_ldap\task\group_sync_task', 41 | 'blocking' => 0, 42 | 'minute' => '0', 43 | 'hour' => '*', 44 | 'day' => '*', 45 | 'month' => '*', 46 | 'dayofweek' => '*', 47 | 'disabled' => 1, 48 | ], 49 | ]; 50 | -------------------------------------------------------------------------------- /lang/en/local_ldap.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * local_ldap language strings. 19 | * 20 | * @package local_ldap 21 | * @copyright 2013 Patrick Pollet 22 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 | */ 24 | 25 | $string['attributesynctask'] = 'Synchronize cohorts from LDAP attributes'; 26 | $string['cohort_synching_ldap_attribute_attribute'] = 'Attribute name to search'; 27 | $string['cohort_synching_ldap_attribute_attribute_desc'] = 'Adjust to the LDAP user\'s attribute to search for (respect case)'; 28 | $string['cohort_synching_ldap_attribute_autocreate_cohorts'] = 'Autocreate missing cohorts'; 29 | $string['cohort_synching_ldap_attribute_autocreate_cohorts_desc'] = 'If selected will create missing cohorts automatically'; 30 | $string['cohort_synching_ldap_attribute_idnumbers'] = 'Target cohorts idnumbers'; 31 | $string['cohort_synching_ldap_attribute_idnumbers_desc'] = 'A comma-separated list of target cohort idnumbers; if missing all distinct values of the attribute will produce a synced cohort'; 32 | $string['cohort_synching_ldap_attribute_objectclass'] = 'User class'; 33 | $string['cohort_synching_ldap_attribute_objectclass_desc'] = 'Use to override default value inherited from LDAP or CAS auth plugin (respect case)'; 34 | $string['cohort_synching_ldap_groups_autocreate_cohorts'] = 'Autocreate missing cohorts'; 35 | $string['cohort_synching_ldap_groups_autocreate_cohorts_desc'] = 'If selected will create missing cohorts automatically'; 36 | $string['cohort_synchronized_with_attribute'] = 'Cohort synchronized with LDAP attribute {$a}'; 37 | $string['cohort_synchronized_with_group'] = 'Cohort synchronized with LDAP group {$a}'; 38 | $string['group_attribute'] = 'Group attribute'; 39 | $string['group_attribute_desc'] = 'Naming attribute of your LDAP groups, usually cn '; 40 | $string['group_class'] = 'Group class'; 41 | $string['group_class_desc'] = 'Set if your groups are of another class such as group, groupOfNames...'; 42 | $string['groupsynctask'] = 'Synchronize cohorts from LDAP groups'; 43 | $string['pluginname'] = 'LDAP syncing scripts'; 44 | $string['privacy:metadata'] = 'The LDAP syncing scripts do not store any data.'; 45 | $string['process_nested_groups'] = 'Process nested groups'; 46 | $string['process_nested_groups_desc'] = 'If selected, LDAP groups included in groups will be processed'; 47 | $string['real_user_attribute'] = 'Real user class'; 48 | $string['real_user_attribute_desc'] = 'Use if your user_attribute is in mixed case in LDAP (sAMAccountName), but not in Moodle\'s CAS/LDAP settings'; 49 | $string['synccohortattribute'] = 'Sync Moodle\'s cohorts with LDAP attribute'; 50 | $string['synccohortattribute_info'] = ''; 51 | $string['synccohortgroup'] = 'Sync Moodle\'s cohorts with LDAP groups'; 52 | $string['synccohortgroup_info'] = ''; 53 | -------------------------------------------------------------------------------- /locallib.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Code for handling synching Moodle's cohorts with LDAP 19 | * 20 | * @package local_ldap 21 | * @copyright 2013 onwards Patrick Pollet 22 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 | */ 24 | 25 | defined('MOODLE_INTERNAL') || die(); 26 | 27 | require_once($CFG->dirroot . '/group/lib.php'); 28 | require_once($CFG->dirroot . '/cohort/lib.php'); 29 | require_once($CFG->dirroot . '/auth/ldap/auth.php'); 30 | 31 | /** 32 | * LDAP cohort sychronization. 33 | * 34 | * @package local_ldap 35 | * @copyright 2013 onwards Patrick Pollet 36 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 37 | */ 38 | class local_ldap extends auth_plugin_ldap { 39 | 40 | /** @var array Avoid infinite loop with nested groups in 'funny' directories. */ 41 | private $antirecursionarray; 42 | 43 | /** @var array Cache for found group dns. */ 44 | private $groupdnscache; 45 | 46 | /** 47 | * Constructor. 48 | */ 49 | public function __construct() { 50 | // Revision March 2013 needed to fetch the proper LDAP parameters 51 | // host, context ... from table config_plugins see comments in https://tracker.moodle.org/browse/MDL-25011. 52 | if (is_enabled_auth('cas')) { 53 | $this->authtype = 'cas'; 54 | $this->roleauth = 'auth_cas'; 55 | $this->errorlogtag = '[AUTH CAS] '; 56 | } else if (is_enabled_auth('ldap')) { 57 | $this->authtype = 'ldap'; 58 | $this->roleauth = 'auth_ldap'; 59 | $this->errorlogtag = '[AUTH LDAP] '; 60 | } else { 61 | return false; 62 | } 63 | 64 | // Fetch basic settings from LDAP or CAS auth plugin. 65 | $this->init_plugin($this->authtype); 66 | 67 | // Get my specific settings. 68 | $extra = get_config('local_ldap'); 69 | $this->merge_config($extra, 'group_attribute', 'cn'); 70 | $this->merge_config($extra, 'group_class', 'groupOfNames'); 71 | $this->merge_config($extra, 'process_nested_groups', 0); 72 | $this->merge_config($extra, 'cohort_synching_ldap_attribute_attribute', 'eduPersonAffiliation'); 73 | $this->merge_config($extra, 'cohort_synching_ldap_attribute_idnumbers', ''); 74 | $this->merge_config($extra, 'cohort_synching_ldap_groups_autocreate_cohorts', false); 75 | $this->merge_config($extra, 'cohort_synching_ldap_attribute_autocreate_cohorts', false); 76 | 77 | // Moodle DO convert to lowercase all LDAP attributes in setting screens 78 | // this cause an issue when searching LDAP group members when user's naming attribute 79 | // is in mixed case in the LDAP , such as sAMAccountName instead of samaccountname 80 | // If your cohorts are not populated by this script try setting this value. 81 | if (!empty($extra->real_user_attribute)) { 82 | $this->config->user_attribute = $extra->real_user_attribute; 83 | } 84 | 85 | // Override if needed the object class defined in Moodle's LDAP settings 86 | // useful to restrict this synching to a certain category of LDAP users such as students. 87 | if (! empty($extra->cohort_synching_ldap_attribute_objectclass)) { 88 | $this->config->objectclass = $extra->cohort_synching_ldap_attribute_objectclass; 89 | } 90 | 91 | // Cache for found groups dn; used for nested groups processing. 92 | $this->groupdnscache = []; 93 | $this->antirecursionarray = []; 94 | } 95 | 96 | /** 97 | * 98 | * merge configuration setting 99 | * @param unknown_type $from 100 | * @param unknown_type $key 101 | * @param unknown_type $default 102 | */ 103 | private function merge_config($from, $key, $default) { 104 | if (!empty($from->$key)) { 105 | $this->config->$key = $from->$key; 106 | } else { 107 | $this->config->$key = $default; 108 | } 109 | } 110 | 111 | /** 112 | * Return all groups declared in LDAP. 113 | * 114 | * @param string $filter Ldap filter to search on. 115 | * @return string[] 116 | */ 117 | public function ldap_get_grouplist($filter = "*") { 118 | global $CFG; 119 | 120 | $ldapconnection = $this->ldap_connect(); 121 | 122 | $fresult = []; 123 | 124 | $servercontrols = []; 125 | 126 | if ($filter == "*") { 127 | $filter = "(&(" . $this->config->group_attribute . "=*)(objectclass=" . $this->config->group_class . "))"; 128 | } 129 | 130 | if (!empty($CFG->cohort_synching_ldap_groups_contexts)) { 131 | $contexts = explode(';', $CFG->cohort_synching_ldap_groups_contexts); 132 | } else { 133 | $contexts = explode(';', $this->config->contexts); 134 | } 135 | 136 | if (!empty ($this->config->create_context)) { 137 | array_push($contexts, $this->config->create_context); 138 | } 139 | 140 | $ldappagedresults = ldap_paged_results_supported($this->config->ldap_version, $ldapconnection); 141 | $ldapcookie = ''; 142 | 143 | foreach ($contexts as $context) { 144 | $context = trim($context); 145 | if (empty ($context)) { 146 | continue; 147 | } 148 | 149 | do { 150 | if ($ldappagedresults) { 151 | $servercontrols = [ 152 | [ 153 | 'oid' => LDAP_CONTROL_PAGEDRESULTS, 'value' => [ 154 | 'size' => $this->config->pagesize, 'cookie' => $ldapcookie, 155 | ], 156 | ], 157 | ]; 158 | } 159 | if ($this->config->search_sub) { 160 | // Use ldap_search to find first group from subtree. 161 | $ldapresult = ldap_search($ldapconnection, $context, $filter, [$this->config->group_attribute], 162 | 0, -1, -1, LDAP_DEREF_NEVER, $servercontrols); 163 | } else { 164 | // Search only in this context. 165 | $ldapresult = ldap_list($ldapconnection, $context, $filter, [$this->config->group_attribute], 166 | 0, -1, -1, LDAP_DEREF_NEVER, $servercontrols); 167 | } 168 | $groups = ldap_get_entries($ldapconnection, $ldapresult); 169 | 170 | if ($ldappagedresults) { 171 | // Get next server cookie to know if we'll need to continue searching. 172 | $ldapcookie = ''; 173 | ldap_parse_result($ldapconnection, $ldapresult, $errcode, $matcheddn, 174 | $errmsg, $referrals, $controls); 175 | if (isset($controls[LDAP_CONTROL_PAGEDRESULTS]['value']['cookie'])) { 176 | $ldapcookie = $controls[LDAP_CONTROL_PAGEDRESULTS]['value']['cookie']; 177 | } 178 | } 179 | 180 | // Add found groups to list. 181 | for ($i = 0; $i < count($groups) - 1; $i++) { 182 | $groupcn = $groups[$i][$this->config->group_attribute][0]; 183 | array_push($fresult, ($groupcn)); 184 | 185 | // Keep the dn/cn in cache for processing. 186 | if ($this->config->process_nested_groups) { 187 | $groupdn = $groups[$i]['dn']; 188 | $this->groupdnscache[$groupdn] = $groupcn; 189 | } 190 | } 191 | } while ($ldappagedresults && $ldapcookie !== null && $ldapcookie != ''); 192 | } 193 | $this->ldap_close(true); 194 | return $fresult; 195 | } 196 | 197 | /** 198 | * Search for group members on a openLDAP directory. 199 | * 200 | * @param string $group the group name 201 | * @return array Array of usernames or boolean on failure. 202 | */ 203 | private function ldap_get_group_members_rfc($group) { 204 | global $CFG; 205 | 206 | $ret = []; 207 | $ldapconnection = $this->ldap_connect(); 208 | 209 | $group = core_text::convert($group, 'utf-8', $this->config->ldapencoding); 210 | 211 | if (!$ldapconnection) { 212 | return false; 213 | } 214 | 215 | $queryg = "(&({$this->config->group_attribute}=" . ldap_filter_addslashes(trim($group)) . ")(objectClass={$this->config->group_class}))"; 216 | 217 | if (!empty($CFG->cohort_synching_ldap_groups_contexts)) { 218 | $contexts = explode(';', $CFG->cohort_synching_ldap_groups_contexts); 219 | } else { 220 | $contexts = explode(';', $this->config->contexts); 221 | } 222 | 223 | if (!empty ($this->config->create_context)) { 224 | array_push($contexts, $this->config->create_context); 225 | } 226 | 227 | foreach ($contexts as $context) { 228 | $context = trim($context); 229 | if (empty ($context)) { 230 | continue; 231 | } 232 | 233 | // Get results; bail out on failure. 234 | $resultg = ldap_search($ldapconnection, $context, $queryg); 235 | if (!$resultg) { 236 | return false; 237 | } 238 | 239 | if (!empty ($resultg) && ldap_count_entries($ldapconnection, $resultg)) { 240 | $groupe = ldap_get_entries($ldapconnection, $resultg); 241 | 242 | if (is_countable($groupe[0][$this->config->memberattribute])) { 243 | for ($g = 0; $g < (count($groupe[0][$this->config->memberattribute]) - 1); $g++) { 244 | 245 | $memberstring = trim($groupe[0][$this->config->memberattribute][$g]); 246 | if ($memberstring != "") { 247 | // Try to speed the search if the member value is 248 | // either a simple username (thus must match the Moodle username) 249 | // or xx=username with xx = the user attribute name matching Moodle's username 250 | // such as uid=jdoe,ou=xxxx,ou=yyyyy. 251 | $member = explode(",", $memberstring); 252 | if (count($member) > 1) { 253 | $memberparts = explode("=", trim($member[0])); 254 | 255 | // Caution in Moodle LDAP attributes names are converted to lowercase 256 | // see process_config in auth/ldap/auth.php. 257 | $found = core_text::strtolower($memberparts[0]) == core_text::strtolower($this->config->user_attribute); 258 | 259 | // No need to search LDAP in that case. 260 | if ($found && empty($this->config->no_speedup_ldap)) { 261 | // In Moodle usernames are always converted to lowercase 262 | // see auto creating or synching users in auth/ldap/auth.php. 263 | $ret[] = core_text::strtolower($memberparts[1]); 264 | } else { 265 | // Fetch Moodle username from LDAP or process nested group. 266 | if ($this->config->memberattribute_isdn) { 267 | // Rev 1.2 nested groups. 268 | if ($this->config->process_nested_groups && ($groupcn = $this->is_ldap_group($memberstring))) { 269 | // In case of funny directory where groups are member of groups. 270 | if (array_key_exists($memberstring, $this->antirecursionarray)) { 271 | unset($this->antirecursionarray[$memberstring]); 272 | continue; 273 | } 274 | $this->antirecursionarray[$memberstring] = 1; 275 | $tmp = $this->ldap_get_group_members_rfc($groupcn); 276 | if (!is_array($tmp)) { 277 | return false; 278 | } 279 | unset($this->antirecursionarray[$memberstring]); 280 | $ret = array_merge($ret, $tmp); 281 | } else { 282 | if ($cpt = $this->get_username_byattr($memberparts[0], $memberparts[1])) { 283 | $ret[] = $cpt; 284 | } 285 | } 286 | } // Else nothing to add. 287 | } 288 | } else { 289 | $ret[] = core_text::strtolower($memberstring); 290 | } 291 | } 292 | } 293 | } 294 | } 295 | } 296 | $this->ldap_close(); 297 | return $ret; 298 | } 299 | 300 | /** 301 | * Search for group members in Active Directory. 302 | * 303 | * Differs from ldap_get_group_members_rfc() because of problems in Active Directory 304 | * if there are more than 999 members. See http://forums.sun.com/thread.jspa?threadID=578347. 305 | * 306 | * @param string $group the group name 307 | * @return array Array of usernames or boolean on failure 308 | */ 309 | private function ldap_get_group_members_ad($group) { 310 | global $CFG; 311 | 312 | $ret = []; 313 | $ldapconnection = $this->ldap_connect(); 314 | if (!$ldapconnection) { 315 | return false; 316 | } 317 | 318 | $group = core_text::convert($group, 'utf-8', $this->config->ldapencoding); 319 | 320 | $queryg = "(&({$this->config->group_attribute}=" . ldap_filter_addslashes(trim($group)) . ")(objectClass={$this->config->group_class}))"; 321 | 322 | $size = 999; 323 | 324 | if (!empty($CFG->cohort_synching_ldap_groups_contexts)) { 325 | $contexts = explode(';', $CFG->cohort_synching_ldap_groups_contexts); 326 | } else { 327 | $contexts = explode(';', $this->config->contexts); 328 | } 329 | 330 | if (!empty ($this->config->create_context)) { 331 | array_push($contexts, $this->config->create_context); 332 | } 333 | 334 | foreach ($contexts as $context) { 335 | $context = trim($context); 336 | if (empty ($context)) { 337 | continue; 338 | } 339 | $start = 0; 340 | $end = $size; 341 | $fini = false; 342 | 343 | while (!$fini) { 344 | // Recherche paginée par paquet de 1000. TODO: Translate. 345 | // Get results; bail out on failure. 346 | $attribut = $this->config->memberattribute . ";range=" . $start . '-' . $end; 347 | $resultg = ldap_search($ldapconnection, $context, $queryg, [$attribut]); 348 | if (!$resultg) { 349 | return false; 350 | } 351 | 352 | if (!empty ($resultg) && ldap_count_entries($ldapconnection, $resultg)) { 353 | $groupe = ldap_get_entries($ldapconnection, $resultg); 354 | 355 | // There are two possibilities why the result in the 356 | // response from AD does not contain the attribute we 357 | // requested. 358 | // 359 | // 1. AD changed the attribute name to indicate that 360 | // this is the last page of the result. In this 361 | // case AD will set the higher bound of the range 362 | // to `*`, e.g. `member;range=2000-*`. 363 | // 364 | // 2. The group is empty, i.e. has no members at all. 365 | if (is_countable($groupe) && empty ($groupe[0][$attribut])) { 366 | $attribut = $this->config->memberattribute . ";range=" . $start . '-*'; 367 | $fini = true; 368 | 369 | // If also the changed attribute is not in the result 370 | // the group must be empty. 371 | if (empty($groupe[0][$attribut])) { 372 | continue; 373 | } 374 | } 375 | 376 | if (!is_countable($groupe)) { 377 | $fini = true; 378 | } else { 379 | for ($g = 0; $g < (count($groupe[0][$attribut]) - 1); $g++) { 380 | 381 | $memberstring = trim($groupe[0][$attribut][$g]); 382 | if ($memberstring != "") { 383 | // In AD, group object's member values are always full DNs. 384 | if ($this->config->process_nested_groups && ($groupcn = $this->is_ldap_group($memberstring))) { 385 | // Recursive call in case of funny directory where groups are member of groups. 386 | if (array_key_exists($memberstring, $this->antirecursionarray)) { 387 | unset($this->antirecursionarray[$memberstring]); 388 | continue; 389 | } 390 | 391 | $this->antirecursionarray[$memberstring] = 1; 392 | $tmp = $this->ldap_get_group_members_ad($groupcn); 393 | if (!is_array($tmp)) { 394 | return false; 395 | } 396 | unset($this->antirecursionarray[$memberstring]); 397 | $ret = array_merge($ret, $tmp); 398 | } else { 399 | if ($cpt = $this->get_username_bydn($memberstring)) { 400 | $ret[] = $cpt; 401 | } 402 | } 403 | } 404 | } 405 | } 406 | } else { 407 | $fini = true; 408 | } 409 | $start = $start + $size; 410 | $end = $end + $size; 411 | } 412 | } 413 | $this->ldap_close(); 414 | return $ret; 415 | } 416 | 417 | /** 418 | * Returns a Moodle username from an LDAP attribute search 419 | * @param string $attr the name of the naming attribute (cn, samaccountname ...) 420 | * @param string $value the value of the naming attribute to search (e.g : John Doe) 421 | * @return string or false 422 | */ 423 | private function get_username_byattr($attr, $value) { 424 | // Build a filter; note than nested groups will be removed here, so they are NOT supported. 425 | $filter = '(&('.$this->config->user_attribute.'=*)'.$this->config->objectclass.')'; 426 | $filter = '(&'.$filter.'('.$attr.'='.$value.'))'; 427 | 428 | // Call Moodle ldap_get_userlist that return it as an array with Moodle user attributes names. 429 | $matchings = $this->ldap_get_userlist($filter); 430 | 431 | // Return the FIRST entry found. 432 | if (empty($matchings)) { 433 | return false; 434 | } 435 | if (count($matchings) > 1) { 436 | return false; 437 | } 438 | 439 | // In Moodle usernames are always converted to lowercase 440 | // see auto creating or synching users in auth/ldap/auth.php. 441 | return core_text::strtolower($matchings[0]); 442 | } 443 | 444 | /** 445 | * Returns a Moodle username from an LDAP DN 446 | * @param string $dn LDAP user DN 447 | * @return string or false 448 | */ 449 | private function get_username_bydn($dn) { 450 | $filter = '(&('.$this->config->user_attribute.'=*)'.$this->config->objectclass.')'; 451 | $ldapconnection = $this->ldap_connect(); 452 | $ldapresult = ldap_read($ldapconnection, $dn, $filter, [$this->config->user_attribute]); 453 | 454 | if (!$ldapresult) { 455 | return false; 456 | } 457 | 458 | $user = ldap_get_entries_moodle($ldapconnection, $ldapresult); 459 | 460 | if (empty($user)) { 461 | return false; 462 | } 463 | 464 | $matching = core_text::convert($user[0][$this->config->user_attribute][0], $this->config->ldapencoding, 'utf-8'); 465 | 466 | // In Moodle usernames are always converted to lowercase 467 | // see auto creating or synching users in auth/ldap/auth.php. 468 | return core_text::strtolower($matching); 469 | } 470 | 471 | 472 | /** 473 | * search the group cn in group names cache 474 | * this is definitively faster than searching AGAIN LDAP for this dn with class=group... 475 | * @param string $dn the group DN 476 | * @return string the group CN or false 477 | */ 478 | private function is_ldap_group($dn) { 479 | if (empty($this->config->process_nested_groups)) { 480 | return false; // Not supported by config. 481 | } 482 | return !empty($this->groupdnscache[$dn]) ? $this->groupdnscache[$dn] : false; 483 | } 484 | 485 | /** 486 | * rev 1012 traitement de l'execption avec active directory pour des groupes >1000 members 487 | * voir http://forums.sun.com/thread.jspa?threadID=578347 488 | * 489 | * @param string $groupe the group name 490 | * @return string[] an array of username indexed by Moodle's userid or boolean on failure 491 | */ 492 | public function ldap_get_group_members($groupe) { 493 | global $CFG, $DB; 494 | 495 | if ($this->config->user_type == "ad") { 496 | $members = $this->ldap_get_group_members_ad($groupe); 497 | } else { 498 | $members = $this->ldap_get_group_members_rfc($groupe); 499 | } 500 | $ret = []; 501 | // Remove all LDAP users unknown to Moodle; skip if $members is false. 502 | if (is_array($members)) { 503 | foreach ($members as $member) { 504 | $params = [ 505 | 'username' => $member, 506 | 'mnethostid' => $CFG->mnet_localhost_id, 507 | ]; 508 | if ($user = $DB->get_record('user', $params, 'id,username')) { 509 | $ret[$user->id] = $user->username; 510 | } 511 | } 512 | return $ret; 513 | } else { 514 | return false; 515 | } 516 | } 517 | 518 | 519 | /** 520 | * Returns the distinct values of the target LDAP attribute 521 | * these will be the idnumbers of the synced Moodle cohorts. 522 | * 523 | * @return array 524 | */ 525 | public function get_attribute_distinct_values() { 526 | // Only these cohorts will be synched. 527 | if (!empty($this->config->cohort_synching_ldap_attribute_idnumbers )) { 528 | return explode(',', $this->config->cohort_synching_ldap_attribute_idnumbers); 529 | } 530 | 531 | // Build a filter to fetch all users having something in the target LDAP attribute. 532 | $filter = '(&('.$this->config->user_attribute.'=*)'.$this->config->objectclass.')'; 533 | $filter = '(&'.$filter.'('.$this->config->cohort_synching_ldap_attribute_attribute.'=*))'; 534 | 535 | $ldapconnection = $this->ldap_connect(); 536 | 537 | $servercontrols = []; 538 | 539 | $contexts = explode(';', $this->config->contexts); 540 | if (!empty($this->config->create_context)) { 541 | array_push($contexts, $this->config->create_context); 542 | } 543 | $matchings = []; 544 | 545 | $ldappagedresults = ldap_paged_results_supported($this->config->ldap_version, $ldapconnection); 546 | $ldapcookie = ''; 547 | 548 | foreach ($contexts as $context) { 549 | $context = trim($context); 550 | if (empty($context)) { 551 | continue; 552 | } 553 | 554 | do { 555 | if ($ldappagedresults) { 556 | $servercontrols = [ 557 | [ 558 | 'oid' => LDAP_CONTROL_PAGEDRESULTS, 'value' => [ 559 | 'size' => $this->config->pagesize, 'cookie' => $ldapcookie, 560 | ], 561 | ], 562 | ]; 563 | } 564 | if ($this->config->search_sub) { 565 | // Use ldap_search to find first user from subtree. 566 | $ldapresult = ldap_search($ldapconnection, $context, $filter, 567 | [$this->config->cohort_synching_ldap_attribute_attribute], 568 | 0, -1, -1, LDAP_DEREF_NEVER, $servercontrols); 569 | } else { 570 | // Search only in this context. 571 | $ldapresult = ldap_list($ldapconnection, $context, $filter, 572 | [$this->config->cohort_synching_ldap_attribute_attribute], 573 | 0, -1, -1, LDAP_DEREF_NEVER, $servercontrols); 574 | } 575 | 576 | if (!$ldapresult) { 577 | continue; 578 | } 579 | 580 | if ($ldappagedresults) { 581 | // Get next server cookie to know if we'll need to continue searching. 582 | $ldapcookie = ''; 583 | // Get next cookie from controls. 584 | ldap_parse_result($ldapconnection, $ldapresult, $errcode, $matcheddn, 585 | $errmsg, $referrals, $controls); 586 | if (isset($controls[LDAP_CONTROL_PAGEDRESULTS]['value']['cookie'])) { 587 | $ldapcookie = $controls[LDAP_CONTROL_PAGEDRESULTS]['value']['cookie']; 588 | } 589 | } 590 | 591 | // This API function returns all attributes as an array 592 | // whether they are single or multiple. 593 | $users = ldap_get_entries_moodle($ldapconnection, $ldapresult); 594 | $attributekey = strtolower($this->config->cohort_synching_ldap_attribute_attribute); // MDL-57558. 595 | 596 | // Add found DISTINCT values to list. 597 | for ($i = 0; $i < count($users); $i++) { 598 | $count = $users[$i][$attributekey]['count']; 599 | for ($j = 0; $j < $count; $j++) { 600 | $value = core_text::convert($users[$i][$attributekey][$j], 601 | $this->config->ldapencoding, 'utf-8'); 602 | if (!in_array ($value, $matchings)) { 603 | array_push($matchings, $value); 604 | } 605 | } 606 | } 607 | } while ($ldappagedresults && $ldapcookie !== null && $ldapcookie != ''); 608 | 609 | } 610 | 611 | $this->ldap_close(true); 612 | return $matchings; 613 | } 614 | 615 | /** 616 | * Return users which have the given attribute value. 617 | * 618 | * @param string $attributevalue The attribute value. 619 | * @return array usernames 620 | */ 621 | public function get_users_having_attribute_value($attributevalue) { 622 | global $CFG, $DB; 623 | 624 | // Build a filter. 625 | $filter = '(&('.$this->config->user_attribute.'=*)'.$this->config->objectclass.')'; 626 | $filter = '(&'.$filter.'('.$this->config->cohort_synching_ldap_attribute_attribute. 627 | '='.ldap_filter_addslashes($attributevalue).'))'; 628 | 629 | // Call Moodle ldap_get_userlist that return it as an array with Moodle user attributes names. 630 | $matchings = $this->ldap_get_userlist($filter); 631 | 632 | // Return the FIRST entry found. 633 | if (empty($matchings)) { 634 | return []; 635 | } 636 | 637 | $ret = []; 638 | // Remove all matching LDAP users unkown to Moodle. 639 | foreach ($matchings as $member) { 640 | $params = [ 641 | 'username' => $member, 642 | 'mnethostid' => $CFG->mnet_localhost_id, 643 | ]; 644 | if ($user = $DB->get_record('user', $params, 'id,username')) { 645 | $ret[$user->id] = $user->username; 646 | } 647 | } 648 | 649 | return $ret; 650 | } 651 | 652 | /** 653 | * Get the members of a given cohort. 654 | * 655 | * @param int $cohortid the cohort 656 | * @return array array of user objects indexed by user id 657 | */ 658 | public function get_cohort_members($cohortid) { 659 | global $DB; 660 | $sql = " SELECT u.id,u.username 661 | FROM {user} u 662 | JOIN {cohort_members} cm ON (cm.userid = u.id AND cm.cohortid = :cohortid) 663 | WHERE u.deleted=0"; 664 | $params['cohortid'] = $cohortid; 665 | return $DB->get_records_sql($sql, $params); 666 | } 667 | 668 | /** 669 | * Check whether a given user is in a given cohort. 670 | * 671 | * @param int $cohortid the cohort 672 | * @param int $userid the user id 673 | * @return bool 674 | */ 675 | public function cohort_is_member($cohortid, $userid) { 676 | global $DB; 677 | $params = [ 678 | 'cohortid' => $cohortid, 679 | 'userid' => $userid, 680 | ]; 681 | return $DB->record_exists('cohort_members', $params); 682 | } 683 | 684 | /** 685 | * Synchronizes cohorts by LDAP attribute. 686 | * 687 | * @see \local_ldap\task\attribute_sync_task\execute() 688 | * @return bool always returns true. 689 | */ 690 | public function sync_cohorts_by_attribute() { 691 | global $DB; 692 | 693 | $cohortnames = $this->get_attribute_distinct_values(); 694 | foreach ($cohortnames as $cohortname) { 695 | // Not that we search for cohort IDNUMBER and not name for a match 696 | // thus it we do not autocreate cohorts, admin MUST create cohorts beforehand 697 | // and set their IDNUMBER to the exact value of the corresponding attribute in LDAP. 698 | if (!$cohort = $DB->get_record('cohort', ['idnumber' => $cohortname], '*')) { 699 | if (empty($this->config->cohort_synching_ldap_attribute_autocreate_cohorts)) { 700 | // The cohort does not exist and auto-creation of cohorts is disabled. 701 | continue; 702 | } 703 | 704 | $ldapmembers = $this->get_users_having_attribute_value($cohortname); 705 | if (count($ldapmembers) == 0) { 706 | // Do not create an empty cohort. 707 | continue; 708 | } 709 | 710 | $cohort = new stdClass(); 711 | $cohort->name = $cohort->idnumber = $cohortname; 712 | $cohort->contextid = context_system::instance()->id; 713 | $cohort->description = get_string('cohort_synchronized_with_attribute', 'local_ldap', 714 | $this->config->cohort_synching_ldap_attribute_attribute); 715 | $cohortid = cohort_add_cohort($cohort); 716 | } else { 717 | $cohortid = $cohort->id; 718 | $ldapmembers = $this->get_users_having_attribute_value($cohortname); 719 | } 720 | 721 | $cohortmembers = $this->get_cohort_members($cohortid); 722 | foreach ($cohortmembers as $userid => $user) { 723 | if (!isset($ldapmembers[$userid])) { 724 | cohort_remove_member($cohortid, $userid); 725 | } 726 | } 727 | 728 | foreach ($ldapmembers as $userid => $username) { 729 | if (!cohort_is_member($cohortid, $userid)) { 730 | cohort_add_member($cohortid, $userid); 731 | } 732 | } 733 | } 734 | return true; 735 | } 736 | 737 | /** 738 | * Synchronizes cohorts by LDAP group. 739 | * 740 | * @see \local_ldap\task\group_sync_task\execute() 741 | * @return bool always returns true. 742 | */ 743 | public function sync_cohorts_by_group() { 744 | global $DB; 745 | 746 | $ldapgroups = $this->ldap_get_grouplist(); 747 | foreach ($ldapgroups as $groupname) { 748 | if (!$cohort = $DB->get_record('cohort', ['idnumber' => $groupname], '*')) { 749 | if (empty($this->config->cohort_synching_ldap_groups_autocreate_cohorts)) { 750 | // The cohort does not exist and auto-creation of cohorts is disabled. 751 | continue; 752 | } 753 | $ldapmembers = $this->ldap_get_group_members($groupname); 754 | if (!is_countable($ldapmembers) || count($ldapmembers) == 0) { 755 | // Do not create an empty cohort. 756 | continue; 757 | } 758 | $cohort = new stdClass(); 759 | $cohort->name = $cohort->idnumber = $groupname; 760 | $cohort->contextid = context_system::instance()->id; 761 | $cohort->description = get_string('cohort_synchronized_with_group', 'local_ldap', $groupname); 762 | $cohortid = cohort_add_cohort($cohort); 763 | } else { 764 | $cohortid = $cohort->id; 765 | $ldapmembers = $this->ldap_get_group_members($groupname); 766 | } 767 | 768 | // Update existing membership. 769 | $cohortmembers = $this->get_cohort_members($cohortid); 770 | 771 | // Remove local Moodle users not present in LDAP. 772 | if (is_array($ldapmembers)) { 773 | foreach ($cohortmembers as $userid => $user) { 774 | if (!isset($ldapmembers[$userid])) { 775 | cohort_remove_member($cohortid, $userid); 776 | } 777 | } 778 | } 779 | 780 | // Add LDAP users not present in the local cohort. 781 | if (is_array($ldapmembers)) { 782 | foreach ($ldapmembers as $userid => $username) { 783 | if (!cohort_is_member($cohortid, $userid)) { 784 | cohort_add_member($cohortid, $userid); 785 | } 786 | } 787 | } 788 | } 789 | return true; 790 | } 791 | } 792 | -------------------------------------------------------------------------------- /pix/icon.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LafColITS/moodle-local_ldap/719ebb28ea4d176ab50afa8cbf0fa6b0f5bc6c2c/pix/icon.gif -------------------------------------------------------------------------------- /settings.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Settings for local_ldap. 19 | * 20 | * @package local_ldap 21 | * @copyright 2013 onwards Patrick Pollet {@link mailto:pp@patrickpollet.net} 22 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 | */ 24 | 25 | defined('MOODLE_INTERNAL') || die; 26 | 27 | if ($hassiteconfig) { 28 | $settings = new admin_settingpage('local_ldap', get_string('pluginname', 'local_ldap')); 29 | 30 | $settings->add(new admin_setting_heading('synccohortgroup', 31 | get_string('synccohortgroup', 'local_ldap'), 32 | get_string('synccohortgroup_info', 'local_ldap'))); 33 | 34 | $name = 'group_attribute'; 35 | $title = get_string($name, 'local_ldap'); 36 | $description = get_string($name.'_desc', 'local_ldap'); 37 | $setting = new admin_setting_configtext('local_ldap/'.$name, $title, $description, 'cn'); 38 | $settings->add($setting); 39 | 40 | $name = 'group_class'; 41 | $title = get_string($name, 'local_ldap'); 42 | $description = get_string($name.'_desc', 'local_ldap'); 43 | $setting = new admin_setting_configtext('local_ldap/'.$name, $title, $description, 'groupOfNames'); 44 | $settings->add($setting); 45 | 46 | $name = 'real_user_attribute'; 47 | $title = get_string($name, 'local_ldap'); 48 | $description = get_string($name.'_desc', 'local_ldap'); 49 | $setting = new admin_setting_configtext('local_ldap/'.$name, $title, $description, ''); 50 | $settings->add($setting); 51 | 52 | $name = 'process_nested_groups'; 53 | $title = get_string($name, 'local_ldap'); 54 | $description = get_string($name.'_desc', 'local_ldap'); 55 | $setting = new admin_setting_configcheckbox('local_ldap/'.$name, $title, $description, false); 56 | $settings->add($setting); 57 | 58 | $name = 'cohort_synching_ldap_groups_autocreate_cohorts'; 59 | $title = get_string($name, 'local_ldap'); 60 | $description = get_string($name.'_desc', 'local_ldap'); 61 | $setting = new admin_setting_configcheckbox('local_ldap/'.$name, $title, $description, false); 62 | $settings->add($setting); 63 | 64 | $settings->add(new admin_setting_heading('synccohortattribute', 65 | get_string('synccohortattribute', 'local_ldap'), 66 | get_string('synccohortattribute_info', 'local_ldap'))); 67 | 68 | $name = 'cohort_synching_ldap_attribute_attribute'; 69 | $title = get_string($name, 'local_ldap'); 70 | $description = get_string($name.'_desc', 'local_ldap'); 71 | $setting = new admin_setting_configtext('local_ldap/'.$name, $title, $description, 'eduPersonAffiliation'); 72 | $settings->add($setting); 73 | 74 | $name = 'cohort_synching_ldap_attribute_idnumbers'; 75 | $title = get_string($name, 'local_ldap'); 76 | $description = get_string($name.'_desc', 'local_ldap'); 77 | $setting = new admin_setting_configtext('local_ldap/'.$name, $title, $description, ''); 78 | $settings->add($setting); 79 | 80 | $name = 'cohort_synching_ldap_attribute_objectclass'; 81 | $title = get_string($name, 'local_ldap'); 82 | $description = get_string($name.'_desc', 'local_ldap'); 83 | $setting = new admin_setting_configtext('local_ldap/'.$name, $title, $description, ''); 84 | $settings->add($setting); 85 | 86 | $name = 'cohort_synching_ldap_attribute_autocreate_cohorts'; 87 | $title = get_string($name, 'local_ldap'); 88 | $description = get_string($name.'_desc', 'local_ldap'); 89 | $setting = new admin_setting_configcheckbox('local_ldap/'.$name, $title, $description, false); 90 | $settings->add($setting); 91 | 92 | $ADMIN->add('localplugins', $settings); 93 | } 94 | -------------------------------------------------------------------------------- /tests/sync_ad_test.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * PHPUnit tests for local_ldap. 19 | * 20 | * @package local_ldap 21 | * @copyright 2024 Lafayette College ITS 22 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 | */ 24 | 25 | namespace local_ldap; 26 | 27 | // phpcs:disable moodle.PHPUnit.TestCaseNames.Missing 28 | 29 | defined('MOODLE_INTERNAL') || die(); 30 | 31 | global $CFG; 32 | 33 | require_once($CFG->dirroot.'/local/ldap/tests/sync_base_testcase.php'); 34 | 35 | /** 36 | * PHPUnit tests for local_ldap and Active Directory. 37 | * 38 | * @package local_ldap 39 | * @copyright 2024 Lafayette College ITS 40 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 41 | */ 42 | final class sync_ad_test extends sync_base_testcase { 43 | 44 | /** 45 | * Get the LDAP user type. 46 | * 47 | * @return string 48 | */ 49 | protected function get_ldap_user_type(): string { 50 | return 'ad'; 51 | } 52 | 53 | /** 54 | * Get the object classes for an LDAP user. 55 | * 56 | * @return array 57 | */ 58 | protected function get_ldap_user_object_classes(): array { 59 | return ['inetOrgPerson', 'eduPerson', 'organizationalPerson', 'person', 'posixAccount']; 60 | } 61 | 62 | /** 63 | * Get the attribute class used for synchronization. 64 | * 65 | * @return string 66 | */ 67 | protected function get_ldap_user_attribute_class(): string { 68 | return 'eduPersonAffiliation'; 69 | } 70 | 71 | /** 72 | * Return the approrpiate top-level OU depending on the environment. 73 | * 74 | * @return string The top-level OU. 75 | */ 76 | protected function get_ldap_test_container(): string { 77 | return 'ou=Moodletest'; 78 | } 79 | 80 | /** 81 | * Return the approrpiate test OU. 82 | * 83 | * @return array The test container OU. 84 | */ 85 | protected function get_ldap_test_ou(): array { 86 | $o = []; 87 | $o['objectClass'] = ['organizationalUnit']; 88 | $o['ou'] = 'Moodletest'; 89 | return $o; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /tests/sync_base_testcase.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * PHPUnit tests for local_ldap. 19 | * 20 | * @package local_ldap 21 | * @copyright 2016 Lafayette College ITS 22 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 | */ 24 | 25 | namespace local_ldap; 26 | 27 | defined('MOODLE_INTERNAL') || die(); 28 | 29 | global $CFG; 30 | 31 | require_once($CFG->dirroot.'/local/ldap/locallib.php'); 32 | require_once($CFG->dirroot.'/auth/ldap/tests/auth_ldap_test.php'); 33 | require_once($CFG->dirroot.'/auth/ldap/auth.php'); 34 | require_once($CFG->libdir.'/ldaplib.php'); 35 | 36 | // Detect server type; we assume rfc2307. 37 | if (!defined('TEST_AUTH_LDAP_USER_TYPE')) { 38 | define('TEST_AUTH_LDAP_USER_TYPE', 'rfc2307'); 39 | } 40 | 41 | /** 42 | * PHPUnit tests for local_ldap. 43 | * 44 | * @package local_ldap 45 | * @copyright 2016 Lafayette College ITS 46 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 47 | */ 48 | abstract class sync_base_testcase extends \advanced_testcase { 49 | 50 | public function test_cohort_group_sync() { 51 | global $CFG, $DB; 52 | 53 | $this->validate_environment(); 54 | 55 | // Make sure we can connect the server. 56 | $connection = $this->connect_to_ldap(); 57 | 58 | $this->enable_plugin(); 59 | 60 | // Create new empty test container. 61 | $testcontainer = $this->get_ldap_test_container(); 62 | $topdn = $testcontainer . ',' . TEST_AUTH_LDAP_DOMAIN; 63 | $this->recursive_delete(TEST_AUTH_LDAP_DOMAIN, $testcontainer); 64 | 65 | $o = $this->get_ldap_test_ou(); 66 | if (!ldap_add($connection, $topdn, $o)) { 67 | $this->markTestSkipped('Can not create test LDAP container.'); 68 | } 69 | 70 | // Create 2000 users. 71 | $o = array(); 72 | $o['objectClass'] = array('organizationalUnit'); 73 | $o['ou'] = 'users'; 74 | ldap_add($connection, 'ou='.$o['ou'].','.$topdn, $o); 75 | for ($i = 1; $i <= 2000; $i++) { 76 | $this->create_ldap_user($connection, $topdn, $i); 77 | } 78 | 79 | // Create department groups. 80 | $o = array(); 81 | $o['objectClass'] = array('organizationalUnit'); 82 | $o['ou'] = 'groups'; 83 | ldap_add($connection, 'ou='.$o['ou'].','.$topdn, $o); 84 | $departments = array('english', 'history', 'english(bis)'); 85 | foreach ($departments as $department) { 86 | $o = array(); 87 | $o['objectClass'] = array('groupOfNames'); 88 | $o['cn'] = $department; 89 | $o['member'] = array('cn=username1,ou=users,'.$topdn, 'cn=username2,ou=users,'.$topdn, 90 | 'cn=username5,ou=users,'.$topdn); 91 | ldap_add($connection, 'cn='.$o['cn'].',ou=groups,'.$topdn, $o); 92 | } 93 | 94 | // Create a bunch of empty groups to simulate a large deployment. 95 | for ($i = 1; $i <= 2000; $i++) { 96 | $u = rand(1, 2000); 97 | $o = array(); 98 | $o['objectClass'] = array('groupOfNames'); 99 | $o['cn'] = "emptygroup{$i}"; 100 | $o['member'] = array("cn=username{$u},ou=users,".$topdn); 101 | ldap_add($connection, 'cn='.$o['cn'].',ou=groups,'.$topdn, $o); 102 | } 103 | 104 | // Create all employees group. 105 | $o = array(); 106 | $o['objectClass'] = array('groupOfNames'); 107 | $o['cn'] = 'allemployees'; 108 | $o['member'] = array(); 109 | for ($i = 1; $i <= 2000; $i++) { 110 | $o['member'][] = "cn=username{$i},ou=users,{$topdn}"; 111 | } 112 | ldap_add($connection, 'cn='.$o['cn'].',ou=groups,'.$topdn, $o); 113 | 114 | // Configure the authentication plugin a bit. 115 | set_config('host_url', TEST_AUTH_LDAP_HOST_URL, 'auth_ldap'); 116 | set_config('start_tls', 0, 'auth_ldap'); 117 | set_config('ldap_version', 3, 'auth_ldap'); 118 | set_config('ldapencoding', 'utf-8', 'auth_ldap'); 119 | set_config('pagesize', '2', 'auth_ldap'); 120 | set_config('bind_dn', TEST_AUTH_LDAP_BIND_DN, 'auth_ldap'); 121 | set_config('bind_pw', TEST_AUTH_LDAP_BIND_PW, 'auth_ldap'); 122 | set_config('user_type', TEST_AUTH_LDAP_USER_TYPE, 'auth_ldap'); 123 | set_config('contexts', 'ou=users,'.$topdn.';ou=groups,'.$topdn, 'auth_ldap'); 124 | set_config('search_sub', 0, 'auth_ldap'); 125 | set_config('opt_deref', LDAP_DEREF_NEVER, 'auth_ldap'); 126 | set_config('user_attribute', 'cn', 'auth_ldap'); 127 | set_config('memberattribute', 'member', 'auth_ldap'); 128 | set_config('memberattribute_isdn', 0, 'auth_ldap'); 129 | set_config('creators', '', 'auth_ldap'); 130 | set_config('removeuser', AUTH_REMOVEUSER_KEEP, 'auth_ldap'); 131 | set_config('field_map_email', 'mail', 'auth_ldap'); 132 | set_config('field_updatelocal_email', 'oncreate', 'auth_ldap'); 133 | set_config('field_updateremote_email', '0', 'auth_ldap'); 134 | set_config('field_lock_email', 'unlocked', 'auth_ldap'); 135 | set_config('field_map_firstname', 'givenName', 'auth_ldap'); 136 | set_config('field_updatelocal_firstname', 'oncreate', 'auth_ldap'); 137 | set_config('field_updateremote_firstname', '0', 'auth_ldap'); 138 | set_config('field_lock_firstname', 'unlocked', 'auth_ldap'); 139 | set_config('field_map_lastname', 'sn', 'auth_ldap'); 140 | set_config('field_updatelocal_lastname', 'oncreate', 'auth_ldap'); 141 | set_config('field_updateremote_lastname', '0', 'auth_ldap'); 142 | set_config('field_lock_lastname', 'unlocked', 'auth_ldap'); 143 | $this->assertEquals(2, $DB->count_records('user')); 144 | 145 | // Sync the users. 146 | $auth = get_auth_plugin('ldap'); 147 | 148 | ob_start(); 149 | $sink = $this->redirectEvents(); 150 | $auth->sync_users(true); 151 | $events = $sink->get_events(); 152 | $sink->close(); 153 | ob_end_clean(); 154 | 155 | // Check events, 2000 users created. 156 | $this->assertCount(2000, $events); 157 | 158 | // Add the cohorts. 159 | $cohort = new \stdClass(); 160 | $cohort->contextid = \context_system::instance()->id; 161 | $cohort->name = "History Department"; 162 | $cohort->idnumber = 'history'; 163 | $historyid = cohort_add_cohort($cohort); 164 | $cohort = new \stdClass(); 165 | $cohort->contextid = \context_system::instance()->id; 166 | $cohort->name = "English Department"; 167 | $cohort->idnumber = 'english'; 168 | $englishid = cohort_add_cohort($cohort); 169 | $cohort = new \stdClass(); 170 | $cohort->contextid = \context_system::instance()->id; 171 | $cohort->name = "English Department (bis)"; 172 | $cohort->idnumber = 'english(bis)'; 173 | $englishbisid = cohort_add_cohort($cohort); 174 | 175 | // We should find 2004 groups: the 2000 random groups, the three departments, 176 | // and the all employees group. 177 | $plugin = new \local_ldap(); 178 | $groups = $plugin->ldap_get_grouplist(); 179 | $this->assertEquals(2004, count($groups)); 180 | 181 | // All three cohorts should have three members. 182 | $plugin->sync_cohorts_by_group(); 183 | $members = $DB->count_records('cohort_members', array('cohortid' => $historyid)); 184 | $this->assertEquals(3, $members); 185 | $members = $DB->count_records('cohort_members', array('cohortid' => $englishid)); 186 | $this->assertEquals(3, $members); 187 | $members = $DB->count_records('cohort_members', array('cohortid' => $englishbisid)); 188 | $this->assertEquals(3, $members); 189 | 190 | // Remove a user and then ensure he's re-added. 191 | $members = $plugin->get_cohort_members($englishid); 192 | cohort_remove_member($englishid, current($members)->id); 193 | $members = $DB->count_records('cohort_members', array('cohortid' => $englishid)); 194 | $this->assertEquals(2, $members); 195 | $plugin->sync_cohorts_by_group(); 196 | $members = $DB->count_records('cohort_members', array('cohortid' => $englishid)); 197 | $this->assertEquals(3, $members); 198 | 199 | // Add the big cohort. 200 | $cohort = new \stdClass(); 201 | $cohort->contextid = \context_system::instance()->id; 202 | $cohort->name = "All employees"; 203 | $cohort->idnumber = 'allemployees'; 204 | $allemployeesid = cohort_add_cohort($cohort); 205 | 206 | // The big cohort should have 2000 members. 207 | $plugin->sync_cohorts_by_group(); 208 | $members = $DB->count_records('cohort_members', array('cohortid' => $allemployeesid)); 209 | $this->assertEquals(2000, $members); 210 | 211 | // Add a user to a group in LDAP and ensure he'd added. 212 | ldap_mod_add($connection, "cn=history,ou=groups,$topdn", 213 | array($auth->config->memberattribute => "cn=username3,ou=users,$topdn")); 214 | $members = $DB->count_records('cohort_members', array('cohortid' => $historyid)); 215 | $this->assertEquals(3, $members); 216 | $plugin->sync_cohorts_by_group(); 217 | $members = $DB->count_records('cohort_members', array('cohortid' => $historyid)); 218 | $this->assertEquals(4, $members); 219 | 220 | // Remove a user from a group in LDAP and ensure he's deleted. 221 | ldap_mod_del($connection, "cn=english,ou=groups,$topdn", 222 | array($auth->config->memberattribute => "cn=username2,ou=users,$topdn")); 223 | $members = $DB->count_records('cohort_members', array('cohortid' => $englishid)); 224 | $this->assertEquals(3, $members); 225 | $plugin->sync_cohorts_by_group(); 226 | $members = $DB->count_records('cohort_members', array('cohortid' => $englishid)); 227 | $this->assertEquals(2, $members); 228 | 229 | // Cleanup. 230 | $this->recursive_delete(TEST_AUTH_LDAP_DOMAIN, $testcontainer); 231 | } 232 | 233 | public function test_cohort_autocreation() { 234 | global $CFG, $DB; 235 | 236 | $this->validate_environment(); 237 | 238 | // Make sure we can connect the server. 239 | $connection = $this->connect_to_ldap(); 240 | 241 | $this->enable_plugin(); 242 | 243 | // Create new empty test container. 244 | $testcontainer = $this->get_ldap_test_container(); 245 | $topdn = $testcontainer . ',' . TEST_AUTH_LDAP_DOMAIN; 246 | $this->recursive_delete(TEST_AUTH_LDAP_DOMAIN, $testcontainer); 247 | 248 | $o = $this->get_ldap_test_ou(); 249 | if (!ldap_add($connection, $topdn, $o)) { 250 | $this->markTestSkipped('Can not create test LDAP container.'); 251 | } 252 | 253 | // Create 5 users. 254 | $o = array(); 255 | $o['objectClass'] = array('organizationalUnit'); 256 | $o['ou'] = 'users'; 257 | ldap_add($connection, 'ou='.$o['ou'].','.$topdn, $o); 258 | for ($i = 1; $i <= 5; $i++) { 259 | $this->create_ldap_user($connection, $topdn, $i); 260 | } 261 | 262 | // Create department groups. 263 | $o = array(); 264 | $o['objectClass'] = array('organizationalUnit'); 265 | $o['ou'] = 'groups'; 266 | ldap_add($connection, 'ou='.$o['ou'].','.$topdn, $o); 267 | $departments = array('english', 'history', 'english(bis)'); 268 | foreach ($departments as $department) { 269 | $o = array(); 270 | $o['objectClass'] = array('groupOfNames'); 271 | $o['cn'] = $department; 272 | $o['member'] = array('cn=username1,ou=users,'.$topdn, 'cn=username2,ou=users,'.$topdn, 273 | 'cn=username5,ou=users,'.$topdn); 274 | ldap_add($connection, 'cn='.$o['cn'].',ou=groups,'.$topdn, $o); 275 | } 276 | 277 | // Configure the authentication plugin a bit. 278 | set_config('host_url', TEST_AUTH_LDAP_HOST_URL, 'auth_ldap'); 279 | set_config('start_tls', 0, 'auth_ldap'); 280 | set_config('ldap_version', 3, 'auth_ldap'); 281 | set_config('ldapencoding', 'utf-8', 'auth_ldap'); 282 | set_config('pagesize', '2', 'auth_ldap'); 283 | set_config('bind_dn', TEST_AUTH_LDAP_BIND_DN, 'auth_ldap'); 284 | set_config('bind_pw', TEST_AUTH_LDAP_BIND_PW, 'auth_ldap'); 285 | set_config('user_type', TEST_AUTH_LDAP_USER_TYPE, 'auth_ldap'); 286 | set_config('contexts', 'ou=users,'.$topdn.';ou=groups,'.$topdn, 'auth_ldap'); 287 | set_config('search_sub', 0, 'auth_ldap'); 288 | set_config('opt_deref', LDAP_DEREF_NEVER, 'auth_ldap'); 289 | set_config('user_attribute', 'cn', 'auth_ldap'); 290 | set_config('memberattribute', 'member', 'auth_ldap'); 291 | set_config('memberattribute_isdn', 0, 'auth_ldap'); 292 | set_config('creators', '', 'auth_ldap'); 293 | set_config('removeuser', AUTH_REMOVEUSER_KEEP, 'auth_ldap'); 294 | set_config('field_map_email', 'mail', 'auth_ldap'); 295 | set_config('field_updatelocal_email', 'oncreate', 'auth_ldap'); 296 | set_config('field_updateremote_email', '0', 'auth_ldap'); 297 | set_config('field_lock_email', 'unlocked', 'auth_ldap'); 298 | set_config('field_map_firstname', 'givenName', 'auth_ldap'); 299 | set_config('field_updatelocal_firstname', 'oncreate', 'auth_ldap'); 300 | set_config('field_updateremote_firstname', '0', 'auth_ldap'); 301 | set_config('field_lock_firstname', 'unlocked', 'auth_ldap'); 302 | set_config('field_map_lastname', 'sn', 'auth_ldap'); 303 | set_config('field_updatelocal_lastname', 'oncreate', 'auth_ldap'); 304 | set_config('field_updateremote_lastname', '0', 'auth_ldap'); 305 | set_config('field_lock_lastname', 'unlocked', 'auth_ldap'); 306 | $this->assertEquals(2, $DB->count_records('user')); 307 | 308 | // Configure the local plugin. 309 | set_config('cohort_synching_ldap_groups_autocreate_cohorts', true, 'local_ldap'); 310 | 311 | // Sync the users. 312 | $auth = get_auth_plugin('ldap'); 313 | 314 | ob_start(); 315 | $sink = $this->redirectEvents(); 316 | $auth->sync_users(true); 317 | $events = $sink->get_events(); 318 | $sink->close(); 319 | ob_end_clean(); 320 | 321 | // Check events, 5 users created. 322 | $this->assertCount(5, $events); 323 | 324 | // Sync the cohorts. 325 | $plugin = new \local_ldap(); 326 | $plugin->sync_cohorts_by_group(); 327 | 328 | // All three cohorts should be created and have 3 members. 329 | $plugin->sync_cohorts_by_group(); 330 | $historyid = $DB->get_field('cohort', 'id', array('name' => 'history')); 331 | $members = $DB->count_records('cohort_members', array('cohortid' => $historyid)); 332 | $this->assertEquals(3, $members); 333 | $englishid = $DB->get_field('cohort', 'id', array('name' => 'english')); 334 | $members = $DB->count_records('cohort_members', array('cohortid' => $englishid)); 335 | $this->assertEquals(3, $members); 336 | $englishbisid = $DB->get_field('cohort', 'id', array('name' => 'english(bis)')); 337 | $members = $DB->count_records('cohort_members', array('cohortid' => $englishbisid)); 338 | $this->assertEquals(3, $members); 339 | 340 | // Direct test of member function. 341 | $members = $plugin->ldap_get_group_members('history'); 342 | $this->assertEquals(3, count($members)); 343 | 344 | // Cleanup. 345 | $this->recursive_delete(TEST_AUTH_LDAP_DOMAIN, $testcontainer); 346 | } 347 | 348 | public function test_cohort_attribute_sync() { 349 | global $CFG, $DB; 350 | 351 | $this->validate_environment(); 352 | 353 | $connection = $this->connect_to_ldap(); 354 | 355 | $this->enable_plugin(); 356 | 357 | // Create new empty test container. 358 | $testcontainer = $this->get_ldap_test_container(); 359 | $topdn = $testcontainer . ',' . TEST_AUTH_LDAP_DOMAIN; 360 | $this->recursive_delete(TEST_AUTH_LDAP_DOMAIN, $testcontainer); 361 | 362 | $o = $this->get_ldap_test_ou(); 363 | if (!ldap_add($connection, $topdn, $o)) { 364 | $this->markTestSkipped('Can not create test LDAP container.'); 365 | } 366 | 367 | // Create 2000 users. 368 | $o = array(); 369 | $o['objectClass'] = array('organizationalUnit'); 370 | $o['ou'] = 'users'; 371 | ldap_add($connection, 'ou='.$o['ou'].','.$topdn, $o); 372 | for ($i = 1; $i <= 2000; $i++) { 373 | $this->create_ldap_user($connection, $topdn, $i); 374 | } 375 | 376 | // All users will be employees. Odd users will be faculty. Even will be staff. 377 | // Some will be staff(pt). 378 | for ($i = 1; $i <= 2000; $i++) { 379 | ldap_mod_add($connection, "cn=username{$i},ou=users,$topdn", 380 | array($this->get_ldap_user_attribute_class() => 'employee')); 381 | if ($i % 2 == 1) { 382 | ldap_mod_add($connection, "cn=username{$i},ou=users,$topdn", 383 | array($this->get_ldap_user_attribute_class() => 'faculty')); 384 | } else { 385 | ldap_mod_add($connection, "cn=username{$i},ou=users,$topdn", 386 | array($this->get_ldap_user_attribute_class() => 'staff')); 387 | } 388 | if ($i % 50 == 0) { 389 | ldap_mod_add($connection, "cn=username{$i},ou=users,$topdn", 390 | array($this->get_ldap_user_attribute_class() => 'staff(pt)')); 391 | } 392 | } 393 | 394 | // Configure the authentication plugin a bit. 395 | set_config('host_url', TEST_AUTH_LDAP_HOST_URL, 'auth_ldap'); 396 | set_config('start_tls', 0, 'auth_ldap'); 397 | set_config('ldap_version', 3, 'auth_ldap'); 398 | set_config('ldapencoding', 'utf-8', 'auth_ldap'); 399 | set_config('pagesize', '2', 'auth_ldap'); 400 | set_config('bind_dn', TEST_AUTH_LDAP_BIND_DN, 'auth_ldap'); 401 | set_config('bind_pw', TEST_AUTH_LDAP_BIND_PW, 'auth_ldap'); 402 | set_config('user_type', TEST_AUTH_LDAP_USER_TYPE, 'auth_ldap'); 403 | set_config('contexts', 'ou=users,'.$topdn, 'auth_ldap'); 404 | set_config('search_sub', 0, 'auth_ldap'); 405 | set_config('opt_deref', LDAP_DEREF_NEVER, 'auth_ldap'); 406 | set_config('user_attribute', 'cn', 'auth_ldap'); 407 | set_config('memberattribute', 'member', 'auth_ldap'); 408 | set_config('memberattribute_isdn', 0, 'auth_ldap'); 409 | set_config('creators', '', 'auth_ldap'); 410 | set_config('removeuser', AUTH_REMOVEUSER_KEEP, 'auth_ldap'); 411 | set_config('field_map_email', 'mail', 'auth_ldap'); 412 | set_config('field_updatelocal_email', 'oncreate', 'auth_ldap'); 413 | set_config('field_updateremote_email', '0', 'auth_ldap'); 414 | set_config('field_lock_email', 'unlocked', 'auth_ldap'); 415 | set_config('field_map_firstname', 'givenName', 'auth_ldap'); 416 | set_config('field_updatelocal_firstname', 'oncreate', 'auth_ldap'); 417 | set_config('field_updateremote_firstname', '0', 'auth_ldap'); 418 | set_config('field_lock_firstname', 'unlocked', 'auth_ldap'); 419 | set_config('field_map_lastname', 'sn', 'auth_ldap'); 420 | set_config('field_updatelocal_lastname', 'oncreate', 'auth_ldap'); 421 | set_config('field_updateremote_lastname', '0', 'auth_ldap'); 422 | set_config('field_lock_lastname', 'unlocked', 'auth_ldap'); 423 | $this->assertEquals(2, $DB->count_records('user')); 424 | 425 | // Configure local plugin. 426 | set_config('cohort_synching_ldap_attribute_attribute', $this->get_ldap_user_attribute_class(), 'local_ldap'); 427 | 428 | // Sync the users. 429 | $auth = get_auth_plugin('ldap'); 430 | 431 | ob_start(); 432 | $sink = $this->redirectEvents(); 433 | $auth->sync_users(true); 434 | $events = $sink->get_events(); 435 | $sink->close(); 436 | ob_end_clean(); 437 | 438 | // Check events, 2000 users created. 439 | $this->assertCount(2000, $events); 440 | 441 | // Add the cohorts. 442 | $cohort = new \stdClass(); 443 | $cohort->contextid = \context_system::instance()->id; 444 | $cohort->name = "All employees"; 445 | $cohort->idnumber = 'employee'; 446 | $employeeid = cohort_add_cohort($cohort); 447 | $cohort = new \stdClass(); 448 | $cohort->contextid = \context_system::instance()->id; 449 | $cohort->name = "All faculty"; 450 | $cohort->idnumber = 'faculty'; 451 | $facultyid = cohort_add_cohort($cohort); 452 | $cohort = new \stdClass(); 453 | $cohort->contextid = \context_system::instance()->id; 454 | $cohort->name = "All staff"; 455 | $cohort->idnumber = 'staff'; 456 | $staffid = cohort_add_cohort($cohort); 457 | $cohort = new \stdClass(); 458 | $cohort->contextid = \context_system::instance()->id; 459 | $cohort->name = "All staff (pt)"; 460 | $cohort->idnumber = 'staff(pt)'; 461 | $staffptid = cohort_add_cohort($cohort); 462 | 463 | // Count the distinct attribute values. 464 | $plugin = new \local_ldap(); 465 | $attributes = $plugin->get_attribute_distinct_values(); 466 | $this->assertEquals(4, count($attributes)); 467 | 468 | // Faculty and staff should have two members and staff(pt) should have one. 469 | $plugin->sync_cohorts_by_attribute(); 470 | $members = $DB->count_records('cohort_members', array('cohortid' => $employeeid)); 471 | $this->assertEquals(2000, $members); 472 | $members = $DB->count_records('cohort_members', array('cohortid' => $facultyid)); 473 | $this->assertEquals(1000, $members); 474 | $members = $DB->count_records('cohort_members', array('cohortid' => $staffid)); 475 | $this->assertEquals(1000, $members); 476 | $members = $DB->count_records('cohort_members', array('cohortid' => $staffptid)); 477 | $this->assertEquals(40, $members); 478 | 479 | // Remove a user and then ensure he's re-added. 480 | $members = $plugin->get_cohort_members($staffid); 481 | cohort_remove_member($staffid, current($members)->id); 482 | $members = $DB->count_records('cohort_members', array('cohortid' => $staffid)); 483 | $this->assertEquals(999, $members); 484 | $plugin->sync_cohorts_by_attribute(); 485 | $members = $DB->count_records('cohort_members', array('cohortid' => $staffid)); 486 | $this->assertEquals(1000, $members); 487 | 488 | // Add an affiliation in LDAP and ensure he'd added. 489 | ldap_mod_add($connection, "cn=username500,ou=users,$topdn", 490 | array($this->get_ldap_user_attribute_class() => 'faculty')); 491 | $members = $DB->count_records('cohort_members', array('cohortid' => $facultyid)); 492 | $this->assertEquals(1000, $members); 493 | $plugin->sync_cohorts_by_attribute(); 494 | $members = $DB->count_records('cohort_members', array('cohortid' => $facultyid)); 495 | $this->assertEquals(1001, $members); 496 | 497 | // Remove a user from a group in LDAP and ensure he's deleted. 498 | ldap_mod_del($connection, "cn=username400,ou=users,$topdn", 499 | array($this->get_ldap_user_attribute_class() => 'staff')); 500 | $members = $DB->count_records('cohort_members', array('cohortid' => $staffid)); 501 | $this->assertEquals(1000, $members); 502 | $plugin->sync_cohorts_by_attribute(); 503 | $members = $DB->count_records('cohort_members', array('cohortid' => $staffid)); 504 | $this->assertEquals(999, $members); 505 | 506 | // Cleanup. 507 | $this->recursive_delete(TEST_AUTH_LDAP_DOMAIN, $testcontainer); 508 | } 509 | 510 | /** 511 | * Verify that we can run at least one test. 512 | */ 513 | protected function validate_environment(): void { 514 | if (!extension_loaded('ldap')) { 515 | $this->markTestSkipped('LDAP extension is not loaded.'); 516 | } 517 | 518 | $this->resetAfterTest(); 519 | 520 | if (!defined('TEST_AUTH_LDAP_HOST_URL') || !defined('TEST_AUTH_LDAP_BIND_DN') || !defined('TEST_AUTH_LDAP_BIND_PW') 521 | || !defined('TEST_AUTH_LDAP_DOMAIN')) { 522 | $this->markTestSkipped('External LDAP test server not configured.'); 523 | } 524 | 525 | if ($this->get_ldap_user_type() != TEST_AUTH_LDAP_USER_TYPE) { 526 | $this->markTestSkipped("Incompatible LDAP server type"); 527 | } 528 | } 529 | 530 | /** 531 | * Return the approrpiate top-level OU depending on the environment. 532 | * 533 | * @return string The top-level OU. 534 | */ 535 | abstract protected function get_ldap_test_container(): string; 536 | 537 | /** 538 | * Return the approrpiate test OU depending on the environment. 539 | * 540 | * @return array The test container OU. 541 | */ 542 | abstract protected function get_ldap_test_ou(): array; 543 | 544 | /** 545 | * Create an LDAP user in the test environment. 546 | * 547 | * Copied from auth_ldap_plugin_testcase\create_ldap_user. Extending that test 548 | * environment caused all manner of problems; forking was more straightforward. 549 | * 550 | * @param resource $connection the LDAP connection 551 | * @param string $topdn the top-level container 552 | * @param integer $i incremented number for user uniqueness constraint 553 | */ 554 | protected function create_ldap_user($connection, $topdn, $i) { 555 | $o = array(); 556 | $o['objectClass'] = $this->get_ldap_user_object_classes(); 557 | $o['cn'] = 'username'.$i; 558 | $o['sn'] = 'Lastname'.$i; 559 | $o['givenName'] = 'Firstname'.$i; 560 | $o['uid'] = $o['cn']; 561 | $o['uidnumber'] = 2000 + $i; 562 | $o['gidNumber'] = 1000 + $i; 563 | $o['homeDirectory'] = '/'; 564 | $o['mail'] = 'user'.$i.'@example.com'; 565 | $o['userPassword'] = 'pass'.$i; 566 | ldap_add($connection, 'cn='.$o['cn'].',ou=users,'.$topdn, $o); 567 | } 568 | 569 | /** 570 | * Get the object classes for an LDAP user. 571 | * 572 | * @return array 573 | */ 574 | abstract protected function get_ldap_user_object_classes(): array; 575 | 576 | /** 577 | * Get the attribute class used for synchronization. 578 | * 579 | * @return string 580 | */ 581 | abstract protected function get_ldap_user_attribute_class(): string; 582 | 583 | /** 584 | * Get the LDAP user type. 585 | * 586 | * @return string 587 | */ 588 | abstract protected function get_ldap_user_type(): string; 589 | 590 | /** 591 | * Delete an LDAP user in the test environment. 592 | * 593 | * Copied from auth_ldap_plugin_testcase\delete_ldap_user. Extending that test 594 | * environment caused all manner of problems; forking was more straightforward. 595 | * 596 | * @param resource $connection the LDAP connection 597 | * @param string $topdn the top-level container 598 | * @param integer $i incremented number for user uniqueness constraint 599 | */ 600 | protected function delete_ldap_user($connection, $topdn, $i) { 601 | ldap_delete($connection, 'cn=username'.$i.',ou=users,'.$topdn); 602 | } 603 | 604 | /** 605 | * Activate the LDAP authentication plugin. 606 | * 607 | * Copied from auth_ldap_plugin_testcase\enable_plugin. Extending that test 608 | * environment caused all manner of problems; forking was more straightforward. 609 | */ 610 | protected function enable_plugin() { 611 | $auths = get_enabled_auth_plugins(true); 612 | if (!in_array('ldap', $auths)) { 613 | $auths[] = 'ldap'; 614 | 615 | } 616 | set_config('auth', implode(',', $auths)); 617 | } 618 | 619 | /** 620 | * Connect to the LDAP server. 621 | * 622 | * @return resource 623 | */ 624 | protected function connect_to_ldap() { 625 | $debuginfo = ''; 626 | if (!$connection = ldap_connect_moodle(TEST_AUTH_LDAP_HOST_URL, 3, TEST_AUTH_LDAP_USER_TYPE, TEST_AUTH_LDAP_BIND_DN, 627 | TEST_AUTH_LDAP_BIND_PW, LDAP_DEREF_NEVER, $debuginfo, false)) { 628 | $this->markTestSkipped('Can not connect to LDAP test server: '.$debuginfo); 629 | return false; 630 | } 631 | return $connection; 632 | } 633 | 634 | /** 635 | * Clear out the test environment. We create a separate connection in case 636 | * pagination is required. 637 | * 638 | * @param string $dn The top level distinguished name 639 | * @param string $filter LDAP filter. 640 | */ 641 | protected function recursive_delete($dn, $filter) { 642 | $ldapconnection = $this->connect_to_ldap(); 643 | 644 | if ($res = ldap_list($ldapconnection, $dn, $filter, array('dn'))) { 645 | $info = ldap_get_entries($ldapconnection, $res); 646 | 647 | if ($info['count'] > 0) { 648 | $ldappagedresults = ldap_paged_results_supported(3, $ldapconnection); 649 | $ldapcookie = ''; 650 | $todelete = array(); 651 | $servercontrols = array(); 652 | do { 653 | if ($ldappagedresults) { 654 | $servercontrols = array( 655 | array( 656 | 'oid' => LDAP_CONTROL_PAGEDRESULTS, 'value' => array( 657 | 'size' => 250, 'cookie' => $ldapcookie 658 | ) 659 | ) 660 | ); 661 | } 662 | $res = ldap_search($ldapconnection, "$filter,$dn", 'cn=*', array('dn'), 663 | 0, -1, -1, LDAP_DEREF_NEVER, $servercontrols); 664 | if (!$res) { 665 | continue; 666 | } 667 | $info = ldap_get_entries($ldapconnection, $res); 668 | foreach ($info as $i) { 669 | if (isset($i['dn'])) { 670 | $todelete[] = $i['dn']; 671 | } 672 | } 673 | if ($ldappagedresults) { 674 | $ldapcookie = ''; 675 | ldap_parse_result($ldapconnection, $res, $errcode, $matcheddn, 676 | $errmsg, $referrals, $controls); 677 | if (isset($controls[LDAP_CONTROL_PAGEDRESULTS]['value']['cookie'])) { 678 | $ldapcookie = $controls[LDAP_CONTROL_PAGEDRESULTS]['value']['cookie']; 679 | } 680 | } 681 | ldap_free_result($res); 682 | } while ($ldappagedresults && $ldapcookie !== null && $ldapcookie != ''); 683 | 684 | if ($ldappagedresults) { 685 | ldap_close($ldapconnection); 686 | unset($ldapconnection); 687 | $ldapconnection = $this->connect_to_ldap(); 688 | } 689 | if (is_array($todelete)) { 690 | foreach ($todelete as $delete) { 691 | ldap_delete($ldapconnection, $delete); 692 | } 693 | } 694 | $todelete = array(); 695 | 696 | do { 697 | if ($ldappagedresults) { 698 | $servercontrols = array( 699 | array( 700 | 'oid' => LDAP_CONTROL_PAGEDRESULTS, 'value' => array( 701 | 'size' => 250, 'cookie' => $ldapcookie 702 | ) 703 | ) 704 | ); 705 | } 706 | $res = ldap_search($ldapconnection, "$filter,$dn", 'ou=*', array('dn'), 707 | 0, -1, -1, LDAP_DEREF_NEVER, $servercontrols); 708 | if (!$res) { 709 | continue; 710 | } 711 | $info = ldap_get_entries($ldapconnection, $res); 712 | foreach ($info as $i) { 713 | if (isset($i['dn']) && $info[0]['dn'] != $i['dn']) { 714 | $todelete[] = $i['dn']; 715 | } 716 | } 717 | if ($ldappagedresults) { 718 | $ldapcookie = ''; 719 | ldap_parse_result($ldapconnection, $res, $errcode, $matcheddn, 720 | $errmsg, $referrals, $controls); 721 | if (isset($controls[LDAP_CONTROL_PAGEDRESULTS]['value']['cookie'])) { 722 | $ldapcookie = $controls[LDAP_CONTROL_PAGEDRESULTS]['value']['cookie']; 723 | } 724 | } 725 | ldap_free_result($res); 726 | } while ($ldappagedresults && $ldapcookie !== null && $ldapcookie != ''); 727 | 728 | if ($ldappagedresults) { 729 | ldap_close($ldapconnection); 730 | unset($ldapconnection); 731 | $ldapconnection = $this->connect_to_ldap(); 732 | } 733 | 734 | if (is_array($todelete)) { 735 | foreach ($todelete as $delete) { 736 | ldap_delete($ldapconnection, $delete); 737 | } 738 | } 739 | 740 | ldap_delete($ldapconnection, "$filter,$dn"); 741 | } 742 | } 743 | ldap_close($ldapconnection); 744 | unset($ldapconnection); 745 | } 746 | } 747 | -------------------------------------------------------------------------------- /tests/sync_rfc2307_test.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * PHPUnit tests for local_ldap. 19 | * 20 | * @package local_ldap 21 | * @copyright 2024 Lafayette College ITS 22 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 | */ 24 | 25 | namespace local_ldap; 26 | 27 | // phpcs:disable moodle.PHPUnit.TestCaseNames.Missing 28 | 29 | defined('MOODLE_INTERNAL') || die(); 30 | 31 | global $CFG; 32 | 33 | require_once($CFG->dirroot.'/local/ldap/tests/sync_base_testcase.php'); 34 | 35 | /** 36 | * PHPUnit tests for local_ldap and OpenLDAP. 37 | * 38 | * @package local_ldap 39 | * @copyright 2024 Lafayette College ITS 40 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 41 | */ 42 | final class sync_rfc2307_test extends sync_base_testcase { 43 | 44 | /** 45 | * Get the LDAP user type. 46 | * 47 | * @return string 48 | */ 49 | protected function get_ldap_user_type(): string { 50 | return 'rfc2307'; 51 | } 52 | 53 | /** 54 | * Get the object classes for an LDAP user. 55 | * 56 | * @return array 57 | */ 58 | protected function get_ldap_user_object_classes(): array { 59 | return ['inetOrgPerson', 'organizationalPerson', 'person', 'posixAccount']; 60 | } 61 | 62 | /** 63 | * Get the attribute class used for synchronization. 64 | * 65 | * @return string 66 | */ 67 | protected function get_ldap_user_attribute_class(): string { 68 | return 'employeeType'; 69 | } 70 | 71 | /** 72 | * Return the approrpiate top-level OU depending on the environment. 73 | * 74 | * @return string The top-level OU. 75 | */ 76 | protected function get_ldap_test_container(): string { 77 | return 'dc=moodletest'; 78 | } 79 | 80 | /** 81 | * Return the approrpiate test OU depending on the environment. 82 | * 83 | * @return array The test container OU. 84 | */ 85 | protected function get_ldap_test_ou(): array { 86 | $o = []; 87 | $o['objectClass'] = ['dcObject', 'organizationalUnit']; 88 | $o['dc'] = 'moodletest'; 89 | $o['ou'] = 'MOODLETEST'; 90 | return $o; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /version.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * local_ldap version information. 19 | * 20 | * @package local_ldap 21 | * @copyright 2013 Patrick Pollet 22 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 | */ 24 | 25 | defined('MOODLE_INTERNAL') || die(); 26 | 27 | $plugin->component = 'local_ldap'; 28 | $plugin->version = 2024042400; 29 | $plugin->requires = 2024042200; 30 | $plugin->maturity = MATURITY_STABLE; 31 | $plugin->release = 'v4.4.0'; 32 | --------------------------------------------------------------------------------