From 610df87919b85f25f3b2418125ffc6e1ba362cb7 Mon Sep 17 00:00:00 2001 From: Lai Wei Date: Wed, 27 May 2026 17:10:06 +0100 Subject: [PATCH] Allow only sync users from a group --- .../adminsetting/usersyncgroupfilter.php | 70 ++++++ local/o365/classes/feature/usersync/main.php | 187 ++++++++++++--- local/o365/classes/privacy/provider.php | 1 + local/o365/classes/rest/unified.php | 215 ++++++++++++++++++ .../classes/task/photoandtimezonesync.php | 154 +++++++++++-- local/o365/classes/task/usersync.php | 29 ++- local/o365/db/install.xml | 1 + local/o365/db/upgrade.php | 34 +++ local/o365/lang/en/local_o365.php | 13 ++ local/o365/settings.php | 6 + local/o365/version.php | 2 +- 11 files changed, 664 insertions(+), 48 deletions(-) create mode 100644 local/o365/classes/adminsetting/usersyncgroupfilter.php diff --git a/local/o365/classes/adminsetting/usersyncgroupfilter.php b/local/o365/classes/adminsetting/usersyncgroupfilter.php new file mode 100644 index 000000000..f60541dc8 --- /dev/null +++ b/local/o365/classes/adminsetting/usersyncgroupfilter.php @@ -0,0 +1,70 @@ +. + +/** + * Admin setting for group-based user sync filtering. + * + * @package local_o365 + * @author Lai Wei + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @copyright (C) 2026 onwards Microsoft, Inc. (http://microsoft.com/) + */ + +namespace local_o365\adminsetting; + +use admin_setting_configtext; + + +/** + * Admin setting for group-based user sync filtering. + * Stores the Microsoft 365 group ID whose members will be synced to Moodle. + */ +class usersyncgroupfilter extends admin_setting_configtext { + /** + * Constructor. + * + * @param string $name unique ascii name + * @param string $visiblename localised name + * @param string $description localised long description + * @param mixed $defaultsetting string or array depending on implementation + */ + public function __construct($name, $visiblename, $description, $defaultsetting = '') { + parent::__construct($name, $visiblename, $description, $defaultsetting, PARAM_TEXT); + } + + /** + * Write the setting to the database with GUID format validation. + * + * @param string $data The value to write + * @return string Empty string if successful, error message if validation fails + */ + public function write_setting($data) { + $data = trim((string) $data); + + // Empty value is allowed (optional setting). + if ($data === '') { + return parent::write_setting($data); + } + + // Check if the value matches GUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx. + // Pattern: 8 hex digits, dash, 4 hex digits, dash, 4 hex digits, dash, 4 hex digits, dash, 12 hex digits. + if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $data)) { + return get_string('settings_usersyncgroupfilter_validation_error', 'local_o365'); + } + + return parent::write_setting($data); + } +} diff --git a/local/o365/classes/feature/usersync/main.php b/local/o365/classes/feature/usersync/main.php index 2eee727f5..dc70f01e7 100644 --- a/local/o365/classes/feature/usersync/main.php +++ b/local/o365/classes/feature/usersync/main.php @@ -242,6 +242,7 @@ public function assign_user($muserid, $userobjectid) { * @param bool $printtrace Whether to output trace messages * @param int|null $appassignid Pre-fetched local_o365_appassign record ID, or null to look it up. * @param int|null $currentpicture Pre-fetched user.picture value, or null to look it up. + * @param string|null $photohash Pre-fetched photo hash, or null to look it up. * @return bool True if photo was updated, false otherwise */ protected function apply_photo( @@ -249,7 +250,8 @@ protected function apply_photo( $photodata, bool $printtrace = false, ?int $appassignid = null, - ?int $currentpicture = null + ?int $currentpicture = null, + ?string $photohash = null ) { global $DB, $CFG; @@ -268,53 +270,134 @@ protected function apply_photo( $context = user::instance($muserid); if (!$photodata || $photodata === false) { - // No profile photo found. + // No profile photo found in M365. if ($printtrace) { $this->mtrace('No photo received.'); } - if (!empty($muser->picture)) { - // User has no photo. Deleting previous profile photo. + // Return true (state changed) only when there was an existing picture to clear. + // Returning false when the picture was already absent avoids spurious "changed" counts. + $result = !empty($muser->picture); + + if ($result) { + // User had a photo; clear it now. $fs = get_file_storage(); $fs->delete_area_files($context->id, 'user', 'icon'); $DB->set_field('user', 'picture', 0, ['id' => $muser->id]); + + if ($printtrace) { + $this->mtrace('Profile photo cleared.'); + } + } + + // Update appassign regardless of whether a picture existed, so that photoupdated + // reflects when M365 was last checked (enabling the expiry logic to skip future + // re-fetches) and any stale photohash is cleared to avoid false "unchanged" matches. + if ($appassignid !== null) { + $record = new stdClass(); + $record->id = $appassignid; + $record->photoupdated = time(); + $record->photohash = null; + $DB->update_record('local_o365_appassign', $record); + } else { + $record = $DB->get_record('local_o365_appassign', ['muserid' => $muserid]); + if (empty($record)) { + $record = new stdClass(); + $record->muserid = $muserid; + $record->assigned = 0; + } + $record->photoupdated = time(); + $record->photohash = null; + if (empty($record->id)) { + $DB->insert_record('local_o365_appassign', $record); + } else { + $DB->update_record('local_o365_appassign', $record); + } + } + } else if ($photohash !== null) { + // Photo hash provided: check if photo actually changed by comparing hashes. + $appassign = null; + if ($appassignid !== null) { + $appassign = $DB->get_record('local_o365_appassign', ['id' => $appassignid]); + } else { + $appassign = $DB->get_record('local_o365_appassign', ['muserid' => $muserid]); } - $result = false; + // If the stored photo hash matches the new hash, photo hasn't changed. + if ($appassign && $appassign->photohash === $photohash) { + if ($printtrace) { + $this->mtrace('Photo unchanged (same hash).'); + } + $result = false; + } else { + // Hash is different or no previous hash stored: photo has changed. + // Process and store the new photo. + $result = $this->process_new_photo($muserid, $photodata, $printtrace, $muser, $context, $photohash, $appassignid); + } } else { // Both get_photo() and get_photos_batch() guarantee that $photodata is valid // binary image data at this point (validated via is_valid_photo_binary() in // unified.php), so no JSON-error guard is needed here. - $tempfile = tempnam($CFG->tempdir . '/', 'profileimage') . '.jpg'; - if (!$fp = fopen($tempfile, 'w+b')) { - @unlink($tempfile); - return false; - } + $result = $this->process_new_photo($muserid, $photodata, $printtrace, $muser, $context, $photohash, $appassignid); + } - fwrite($fp, $photodata); - fclose($fp); + return $result; + } - $newpicture = process_new_icon($context, 'user', 'icon', 0, $tempfile); - if ($newpicture != $muser->picture) { - $DB->set_field('user', 'picture', $newpicture, ['id' => $muser->id]); - $result = true; - } + /** + * Process new photo data by storing it and updating user record. + * + * @param int $muserid The Moodle user ID + * @param string $photodata Binary photo data + * @param bool $printtrace Whether to output trace messages + * @param stdClass $muser User object with id and picture fields + * @param context_user $context User context + * @param string|null $photohash SHA256 hash of the photo data + * @param int|null $appassignid ID of the appassign record + * @return bool True if photo was updated, false otherwise + */ + protected function process_new_photo( + int $muserid, + string $photodata, + bool $printtrace, + stdClass $muser, + $context, + ?string $photohash, + ?int $appassignid + ): bool { + global $DB, $CFG; + $tempfile = tempnam($CFG->tempdir . '/', 'profileimage') . '.jpg'; + if (!$fp = fopen($tempfile, 'w+b')) { @unlink($tempfile); + return false; + } - if ($printtrace) { - $this->mtrace('Photo applied.'); - } + fwrite($fp, $photodata); + fclose($fp); + + $newpicture = process_new_icon($context, 'user', 'icon', 0, $tempfile); + $result = false; + if ($newpicture != $muser->picture) { + $DB->set_field('user', 'picture', $newpicture, ['id' => $muser->id]); + $result = true; + } + + @unlink($tempfile); + + if ($printtrace) { + $this->mtrace('Photo applied.'); + } - // Update appassign record. - // When the batch query has already resolved the record ID, use it directly - // to skip the per-user SELECT. Fall back to a full lookup (insert or update) - // when the ID is unknown (e.g. new users with no existing appassign record, - // or callers outside the batch task such as assign_photo). + // Update appassign record only if photo changed. + if ($result) { if ($appassignid !== null) { $record = new stdClass(); $record->id = $appassignid; $record->photoupdated = time(); + if ($photohash !== null) { + $record->photohash = $photohash; + } $DB->update_record('local_o365_appassign', $record); } else { $record = $DB->get_record('local_o365_appassign', ['muserid' => $muserid]); @@ -324,6 +407,9 @@ protected function apply_photo( $record->assigned = 0; } $record->photoupdated = time(); + if ($photohash !== null) { + $record->photohash = $photohash; + } if (empty($record->id)) { $DB->insert_record('local_o365_appassign', $record); } else { @@ -457,14 +543,17 @@ protected function apply_timezone( * @param mixed $photodata The photo data (binary string or false for no photo) * @param int|null $appassignid Pre-fetched local_o365_appassign record ID, or null to look it up. * @param int|null $currentpicture Pre-fetched user.picture value, or null to look it up. + * @param string|null $photohash Pre-fetched photo hash, or null to look it up. + * @return bool True if photo was updated, false otherwise */ public function apply_photo_public( int $muserid, $photodata, ?int $appassignid = null, - ?int $currentpicture = null - ) { - $this->apply_photo($muserid, $photodata, false, $appassignid, $currentpicture); + ?int $currentpicture = null, + ?string $photohash = null + ): bool { + return $this->apply_photo($muserid, $photodata, false, $appassignid, $currentpicture, $photohash); } /** @@ -564,6 +653,12 @@ public function get_user(string $objectid, bool $guestuser = false) { */ public function process_users_batched(callable $callback, $params = 'default'): int { $apiclient = $this->construct_user_api(); + + $groupfilter = $this->get_usersync_group_filter(); + if (!empty($groupfilter)) { + return $apiclient->process_group_members_batched($groupfilter, $callback, $params); + } + return $apiclient->process_users_batched($callback, $params); } @@ -580,6 +675,12 @@ public function process_users_batched(callable $callback, $params = 'default'): */ public function process_users_delta_batched(callable $callback, $params = 'default', ?string $deltatoken = null): array { $apiclient = $this->construct_user_api(); + + $groupfilter = $this->get_usersync_group_filter(); + if (!empty($groupfilter)) { + return $apiclient->process_group_members_delta_batched($groupfilter, $callback, $params, $deltatoken); + } + return $apiclient->process_users_delta_batched($callback, $params, $deltatoken); } @@ -587,6 +688,14 @@ public function process_users_delta_batched(callable $callback, $params = 'defau * Process users in batches with minimal fields (id and accountEnabled only). * This method is optimized for suspend/reenable operations that only need user IDs. * + * NOTE: This method intentionally does NOT apply the usersyncgroupfilter setting. + * Enabled-status sync (suspend/reenable/delete) operates across all Entra users so + * that accounts disabled or deleted in Microsoft 365 are always suspended in Moodle, + * even when the user was never in the configured sync group. This is a deliberate + * safety-net behaviour and is documented in the settings_usersyncgroupfilter_details + * language string. If you need group-filtered status checks, call + * process_users_batched() with the appropriate callback instead. + * * @param callable $callback Function to call for each batch of users. Receives array of users as parameter. * @return int Total number of users processed * @throws moodle_exception @@ -1309,6 +1418,7 @@ public function sync_users(array $entraidusers = [], string $bindingusernameclai conn.id as existingconnectionid, assign.assigned assigned, assign.photoid photoid, + assign.photohash photohash, assign.photoupdated photoupdated, obj.id AS objectid FROM {user} u @@ -1388,6 +1498,7 @@ public function sync_users(array $entraidusers = [], string $bindingusernameclai conn.id as existingconnectionid, assign.assigned assigned, assign.photoid photoid, + assign.photohash photohash, assign.photoupdated photoupdated, obj.id AS objectid FROM {user} u @@ -2282,6 +2393,15 @@ public function drop_entra_users_temp_table(string $temptablename): void { * * Streams API results directly into database without accumulating in PHP memory. * + * This method deliberately fetches ALL Entra users regardless of any usersyncgroupfilter + * setting. The temporary table is consumed by the enabled-status sync task + * (userenabledstatussync), which must be able to suspend or delete Moodle accounts for + * users who have been disabled or removed from Entra ID — even if those users were never + * members of the configured sync group. Scoping this query to the group would create a + * blind spot where deleted non-member accounts are never suspended. + * + * See also: process_users_minimal_batched() and settings_usersyncgroupfilter_details. + * * @param string $temptablename The name of the temporary table to populate. */ public function populate_entra_users_temp_table(string $temptablename): void { @@ -2290,6 +2410,7 @@ public function populate_entra_users_temp_table(string $temptablename): void { $apiclient = $this->construct_user_api(); $insertcount = 0; + // Intentionally unfiltered — see method docblock for rationale. $apiclient->process_users_minimal_batched(function (array $entrabatch) use ($DB, $temptablename, &$insertcount) { $records = []; foreach ($entrabatch as $user) { @@ -2415,4 +2536,14 @@ public function process_user_status_from_temp_table( return [$reenabled, $suspended, $deleted]; } + + /** + * Get the configured Microsoft 365 group filter for user sync. + * + * @return string|null The group object ID if configured, null otherwise + */ + public function get_usersync_group_filter(): ?string { + $groupid = get_config('local_o365', 'usersyncgroupfilter'); + return !empty($groupid) ? trim($groupid) : null; + } } diff --git a/local/o365/classes/privacy/provider.php b/local/o365/classes/privacy/provider.php index 68762ad53..80b4999dd 100644 --- a/local/o365/classes/privacy/provider.php +++ b/local/o365/classes/privacy/provider.php @@ -97,6 +97,7 @@ public static function get_metadata(collection $collection): collection { 'muserid', 'assigned', 'photoid', + 'photohash', 'photoupdated', ], 'local_o365_matchqueue' => [ diff --git a/local/o365/classes/rest/unified.php b/local/o365/classes/rest/unified.php index 08ed3879d..2114fb65b 100644 --- a/local/o365/classes/rest/unified.php +++ b/local/o365/classes/rest/unified.php @@ -1264,6 +1264,220 @@ public function process_users_minimal_batched(callable $callback): int { return $totalprocessed; } + /** + * Process users from a specific group in batches with a callback function. + * This fetches members of the specified group using the /groups/{id}/members endpoint. + * + * @param string $groupid The Microsoft 365 group object ID + * @param callable $callback Function to call for each batch of users. Receives array of users as parameter. + * @param string|array $params User fields to select (use 'default' for default fields) + * @return int Total number of users processed + * @throws moodle_exception + */ + public function process_group_members_batched(string $groupid, callable $callback, $params = 'default'): int { + $odataqueries = []; + + if ($params === 'default') { + $params = $this->get_required_user_fields(); + } + + if (is_array($params)) { + $excludedfields = ['preferredName', 'teams', 'groups', 'roles']; + foreach ($excludedfields as $excludedfield) { + if (($key = array_search($excludedfield, $params)) !== false) { + unset($params[$key]); + } + } + $odataqueries['$select'] = implode(',', $params); + } + + $odataqueries['$top'] = (string)self::GRAPH_API_BATCH_SIZE; + + $ownerids = []; + + // Fetch group owners first. + $ownerqueries = $odataqueries; + $ownerqueries['$top'] = '999'; // Owners list is typically small. + $ownerpage = function (array $result) use (&$ownerids): int { + if (!empty($result['value']) && is_array($result['value'])) { + foreach ($result['value'] as $owner) { + if (!empty($owner['id'])) { + $ownerids[$owner['id']] = $owner; + } + } + return count($result['value']); + } + return 0; + }; + + $ownerendpoint = "/groups/{$groupid}/owners/microsoft.graph.user"; + $this->execute_odata_paginated($ownerendpoint, $ownerqueries, $ownerpage); + + // Fetch group members. As each page arrives, remove seen members from $ownerids + // so only genuine owner-only accounts remain after pagination completes. + $pagehandler = function (array $result) use ($callback, &$ownerids): int { + if (!empty($result['value']) && is_array($result['value'])) { + $users = $result['value']; + foreach ($users as $user) { + if (!empty($user['id'])) { + unset($ownerids[$user['id']]); + } + } + $callback($users); + return count($users); + } + return 0; + }; + + $endpoint = "/groups/{$groupid}/members/microsoft.graph.user"; + [$totalprocessed] = $this->execute_odata_paginated($endpoint, $odataqueries, $pagehandler); + + // Emit owners who were not present in any members page (owner-only accounts). + // This is done once after all member pages to avoid duplicate processing. + if (!empty($ownerids)) { + $owneronly = array_values($ownerids); + $callback($owneronly); + $totalprocessed += count($owneronly); + } + + return $totalprocessed; + } + + /** + * Process users from a specific group in batches using delta (incremental) sync. + * + * Microsoft Graph API does not support delta queries directly on /groups/{id}/members. + * This method works around that limitation by: + * 1. Fetching the current group member and owner IDs upfront (id-only, lightweight). + * 2. Running the standard tenant-wide /users/delta query using the supplied token. + * 3. Filtering each delta page client-side to only pass group members to the callback. + * + * This preserves the efficiency of delta sync (only changed user records are transferred) + * while ensuring the group filter is strictly honoured. The delta token is stored and + * reused across runs just like an unfiltered delta sync. + * + * Known limitation: a user who existed in the tenant for a long time but was only recently + * added to the group may not appear in the delta (their user object may not have changed). + * Periodic full syncs (nodelta mode) handle this edge case. + * + * @param string $groupid The Microsoft 365 group object ID + * @param callable $callback Function to call for each filtered batch of group-member users + * @param string|array $params User fields to select (use 'default' for default fields) + * @param string|null $deltatoken Delta token from a previous sync, or null for a full initial sync + * @return array [total group-member users processed, new delta token, bool fields mapping changed] + * @throws moodle_exception + */ + public function process_group_members_delta_batched( + string $groupid, + callable $callback, + $params = 'default', + ?string $deltatoken = null + ): array { + // Step 1: Fetch current group member and owner IDs (id field only — lightweight). + $memberids = []; + $idquery = ['$select' => 'id', '$top' => '999']; + $idhandler = function (array $result) use (&$memberids): int { + if (!empty($result['value']) && is_array($result['value'])) { + foreach ($result['value'] as $user) { + if (!empty($user['id'])) { + $memberids[$user['id']] = true; + } + } + return count($result['value']); + } + return 0; + }; + + $this->execute_odata_paginated("/groups/{$groupid}/members/microsoft.graph.user", $idquery, $idhandler); + $this->execute_odata_paginated("/groups/{$groupid}/owners/microsoft.graph.user", $idquery, $idhandler); + + if (empty($memberids)) { + // Group is empty or inaccessible — nothing to sync. Clear the delta token so the + // next run re-evaluates group membership from scratch. + utils::debug('Group ' . $groupid . ' has no members or owners; skipping delta sync.', __METHOD__); + return [0, null, false]; + } + + utils::debug( + 'Group filter active (' . count($memberids) . ' members/owners). Running filtered tenant delta.', + __METHOD__ + ); + + // Step 2: Build the OData query for the tenant-wide delta, replicating the fields-hash + // invalidation logic from process_users_delta_batched so token management is consistent. + $odataqueries = []; + $fieldsmappingchanged = false; + + if ($params === 'default') { + $params = $this->get_required_user_fields(); + } + + if (is_array($params)) { + $excludedfields = ['preferredName', 'teams', 'groups', 'roles']; + foreach ($excludedfields as $excludedfield) { + if (($key = array_search($excludedfield, $params)) !== false) { + unset($params[$key]); + } + } + + sort($params); + $selectfields = implode(',', $params); + $odataqueries['$select'] = $selectfields; + + $currentfieldshash = md5($selectfields); + if (!empty($deltatoken)) { + $storedfieldshash = get_config('local_o365', 'task_usersync_fieldshash'); + if ($storedfieldshash && $storedfieldshash !== $currentfieldshash) { + $deltatoken = null; + $fieldsmappingchanged = true; + } + } + + set_config('task_usersync_fieldshash', $currentfieldshash, 'local_o365'); + } + + if (!empty($deltatoken)) { + $odataqueries['$deltatoken'] = $deltatoken; + } + + $odataqueries['$top'] = (string)self::GRAPH_API_BATCH_SIZE; + + // Step 3: Stream the delta, filtering each page to group members/owners only. + // Deduplication (same user on multiple pages) mirrors process_users_delta_batched. + $knownids = []; + $pagehandler = function (array $result) use ($callback, &$knownids, $memberids): int { + if (empty($result['value']) || !is_array($result['value'])) { + return 0; + } + + $batch = $result['value']; + + // Remove duplicates and tombstone (deleted) records. + foreach ($batch as $key => $user) { + if (isset($knownids[$user['id']]) || isset($user['@removed'])) { + unset($batch[$key]); + } else { + $knownids[$user['id']] = true; + } + } + + // Filter to group members and owners only. + $batch = array_values(array_filter($batch, function (array $user) use ($memberids): bool { + return !empty($user['id']) && isset($memberids[$user['id']]); + })); + + if (!empty($batch)) { + $callback($batch); + } + + return count($batch); + }; + + [$totalprocessed, $deltatokenvalue] = $this->execute_odata_paginated('/users/delta', $odataqueries, $pagehandler); + + return [$totalprocessed, $deltatokenvalue, $fieldsmappingchanged]; + } + /** * Get user manager by passing user AD id. * @@ -2800,6 +3014,7 @@ public function get_photos_batch(array $upns): array { $results[$upn] = [ 'status' => 'success', 'data' => $binarydata, + 'hash' => hash('sha256', $binarydata), 'http_status' => $httpstatus, ]; } else { diff --git a/local/o365/classes/task/photoandtimezonesync.php b/local/o365/classes/task/photoandtimezonesync.php index b6586d20a..f17deef9f 100644 --- a/local/o365/classes/task/photoandtimezonesync.php +++ b/local/o365/classes/task/photoandtimezonesync.php @@ -74,6 +74,10 @@ public function execute() { } $this->mtrace('Starting photo and timezone sync.'); + + // Display enabled sync options. + $this->display_sync_options($photosyncenabled, $tzsynceenabled); + raise_memory_limit(MEMORY_HUGE); // Do not time out when syncing. @@ -86,8 +90,58 @@ public function execute() { } $this->mtrace('Batch size: ' . $batchsize . ' users.', 1); + // Get photo expiration setting (in hours). + $photoexpire = get_config('local_o365', 'photoexpire'); + if (empty($photoexpire) || !is_numeric($photoexpire)) { + $photoexpire = 24; + } + $photoexpiresec = $photoexpire * 3600; + $currenttime = time(); + + if (main::sync_option_enabled('photosync')) { + $this->mtrace('Photo expiry: ' . $photoexpire . ' hours.', 1); + } + $usersync = new main(); + // Check for group filter. + $groupfilter = $usersync->get_usersync_group_filter(); + $groupmembersupns = null; + + if (!empty($groupfilter)) { + $this->mtrace('Group filter enabled: ' . $groupfilter); + $this->mtrace('Fetching group members...'); + + try { + $apiclient = $usersync->construct_user_api(); + $groupmembersupns = []; + + // Fetch group members and owners. + $memberhandler = function ($userbatch) use (&$groupmembersupns) { + foreach ($userbatch as $user) { + if (!empty($user['userPrincipalName'])) { + $groupmembersupns[strtolower($user['userPrincipalName'])] = true; + } + } + }; + + $apiclient->process_group_members_batched($groupfilter, $memberhandler, ['id', 'userPrincipalName']); + $this->mtrace('Group has ' . count($groupmembersupns) . ' members (including owners).'); + } catch (moodle_exception $e) { + $this->mtrace('Error fetching group members: ' . $e->getMessage()); + $this->mtrace(''); + $this->mtrace('NOTE: Group filter is enabled (Group ID: ' . $groupfilter . ')'); + $this->mtrace('This error may indicate:'); + $this->mtrace(' - The group ID is invalid or does not exist'); + $this->mtrace(' - The application does not have permission to access the group'); + $this->mtrace(' - The group was deleted or the ID changed'); + $this->mtrace('Please verify the group ID is correct and the application has the necessary permissions.'); + utils::debug($e->getMessage(), __METHOD__, $e); + return true; + } + $this->mtrace(''); + } + // Count total users to process. $countsql = "SELECT COUNT(obj.moodleid) FROM {local_o365_objects} obj @@ -141,17 +195,18 @@ public function execute() { $this->mtrace('Batch ' . ($batchnum + 1) . '/' . $numbatches . ' (offset: ' . $offset . ')...'); - // Fetch batch of users. LEFT JOIN with appassign to check if assigned, but only - // get the first appassign id per user to avoid duplicate rows. + // Fetch batch of users. LEFT JOIN with appassign to get the photo sync + // metadata (id, photoupdated) needed for the expiry check and apply_photo call. $sql = "SELECT obj.moodleid AS muserid, obj.o365name AS upn, u.username, u.picture AS currentpicture, u.timezone AS currenttimezone, - (SELECT id FROM {local_o365_appassign} - WHERE muserid = obj.moodleid LIMIT 1) AS appassignid + assign.id AS appassignid, + assign.photoupdated AS photoupdated FROM {local_o365_objects} obj JOIN {user} u ON u.id = obj.moodleid + LEFT JOIN {local_o365_appassign} assign ON assign.muserid = obj.moodleid WHERE obj.type = 'user' AND u.deleted = 0 AND u.suspended = 0 @@ -171,21 +226,40 @@ public function execute() { // Separate users by what needs updating. $upnsforphotosync = []; $upnsfortzsync = []; + $usersforprocessing = 0; + $photosyncskipped = 0; foreach ($users as $user) { - // Sync photos for all users if photo sync is enabled. + // Skip user if group filter is active and user is not in the group. + if ($groupmembersupns !== null && !isset($groupmembersupns[strtolower($user->upn)])) { + continue; + } + + $usersforprocessing++; + + // Sync photos if enabled. if ($photosyncenabled) { - $upnsforphotosync[] = $user->upn; + // Skip re-fetching the photo when it was synced within the expiry window. + if (!empty($user->photoupdated) && ($user->photoupdated + $photoexpiresec) > $currenttime) { + $photosyncskipped++; + } else { + $upnsforphotosync[] = $user->upn; + } } - // Sync timezone if enabled. + // Always sync timezone if enabled (no expiration check for timezone). if ($tzsynceenabled) { $upnsfortzsync[] = $user->upn; } } + if (!empty($groupfilter)) { + $this->mtrace('Users in group: ' . $usersforprocessing, 1); + } + if ($photosyncenabled) { - $this->mtrace('Users for photo sync: ' . count($upnsforphotosync), 1); + $this->mtrace('Users for photo sync: ' . count($upnsforphotosync) . + ($photosyncskipped > 0 ? ' (' . $photosyncskipped . ' skipped — photo fresh within expiry window)' : ''), 1); $totalusersforphotosync += count($upnsforphotosync); } @@ -262,6 +336,7 @@ public function execute() { // Apply photos and timezones to users in this batch. $batchphotoschanged = 0; + $batchphotosnochange = 0; $batchtimezoneschanged = 0; if ($photosyncenabled && !empty($photosbyupn)) { @@ -274,36 +349,49 @@ public function execute() { // Handle new format (status array). $shouldapply = false; $photodata = false; - $logmessage = ''; + $photohash = null; + $isremoval = false; if (is_array($response) && isset($response['status'])) { $status = $response['status']; if ($status === 'success') { $photodata = $response['data']; + $photohash = $response['hash'] ?? null; $shouldapply = true; - $logmessage = 'Photo changed.'; } else if ($status === 'not_found') { $photodata = false; $shouldapply = true; - $logmessage = 'Photo cleared (not in M365).'; + $isremoval = true; } } else if ($response !== false) { // Fallback for old format compatibility. $photodata = $response; $shouldapply = true; - $logmessage = 'Photo changed.'; } if ($shouldapply) { try { - $usersync->apply_photo_public( + $photoactuallychanged = $usersync->apply_photo_public( $user->muserid, $photodata, $user->appassignid ?? null, - $user->currentpicture ?? null + $user->currentpicture ?? null, + $photohash ); - $this->mtrace('User "' . $user->username . '": ' . $logmessage, 2); - $batchphotoschanged++; + if ($photoactuallychanged) { + // Log per-user when state actually changed, with a message + // specific to the outcome so the reason is clear in the log. + $changemsg = $isremoval + ? 'Photo removed (not found in M365).' + : 'Photo updated from M365.'; + $this->mtrace('User "' . $user->username . '": ' . $changemsg, 2); + $batchphotoschanged++; + } else { + // Photo was checked but state is unchanged (hash matched or + // picture was already absent). Count for the batch summary + // so admins can diagnose runs where nothing changes. + $batchphotosnochange++; + } } catch (moodle_exception $e) { $this->mtrace('User "' . $user->username . '": Error applying photo - ' . $e->getMessage(), 2); @@ -350,7 +438,11 @@ public function execute() { // Show batch summary. if ($photosyncenabled) { - $this->mtrace('Batch photos changed: ' . $batchphotoschanged, 1); + $summary = 'Batch photos changed: ' . $batchphotoschanged; + if ($batchphotosnochange > 0) { + $summary .= ', no change: ' . $batchphotosnochange; + } + $this->mtrace($summary, 1); } if ($tzsynceenabled) { $this->mtrace('Batch timezones changed: ' . $batchtimezoneschanged, 1); @@ -395,4 +487,32 @@ public function execute() { return true; } + + /** + * Display the sync options enabled for this task. + * + * @param bool $photosyncenabled Whether photo sync is enabled + * @param bool $tzsynceenabled Whether timezone sync is enabled + */ + protected function display_sync_options(bool $photosyncenabled, bool $tzsynceenabled): void { + $syncoptions = main::get_sync_options(); + + $this->mtrace('Sync options:', 1); + + if ($photosyncenabled && isset($syncoptions['photosync'])) { + $this->mtrace('photosync - Sync Microsoft 365 profile photos to Moodle', 2); + } + + if ($photosyncenabled && isset($syncoptions['photosynconlogin'])) { + $this->mtrace('photosynconlogin - Sync Microsoft 365 profile photos to Moodle on login', 2); + } + + if ($tzsynceenabled && isset($syncoptions['tzsync'])) { + $this->mtrace('tzsync - Sync Outlook timezone to Moodle', 2); + } + + if ($tzsynceenabled && isset($syncoptions['tzsynconlogin'])) { + $this->mtrace('tzsynconlogin - Sync Outlook timezone to Moodle on login', 2); + } + } } diff --git a/local/o365/classes/task/usersync.php b/local/o365/classes/task/usersync.php index 116c10a83..79d7f03fe 100644 --- a/local/o365/classes/task/usersync.php +++ b/local/o365/classes/task/usersync.php @@ -134,6 +134,12 @@ public function execute() { // List users batch size. $this->mtrace('Graph API list users batch size: ' . unified::GRAPH_API_BATCH_SIZE . '.', 1); + // Check for group filter. + $groupfilter = $usersync->get_usersync_group_filter(); + if (!empty($groupfilter)) { + $this->mtrace('User sync group filter enabled: ' . $groupfilter, 1); + } + // Initialize sync cache ONCE before batch processing to avoid redundant queries. $this->mtrace(''); $this->mtrace('Initializing user sync cache...'); @@ -159,6 +165,15 @@ public function execute() { $this->mtrace(''); } catch (moodle_exception $e) { $this->mtrace('Error in full usersync: ' . $e->getMessage()); + if (!empty($groupfilter)) { + $this->mtrace(''); + $this->mtrace('NOTE: Group filter is enabled (Group ID: ' . $groupfilter . ')'); + $this->mtrace('This error may indicate:'); + $this->mtrace(' - The group ID is invalid or does not exist'); + $this->mtrace(' - The application does not have permission to access the group'); + $this->mtrace(' - The group was deleted or the ID changed'); + $this->mtrace('Please verify the group ID is correct and the application has the necessary permissions.'); + } utils::debug($e->getMessage(), __METHOD__, $e); } } else { @@ -175,6 +190,15 @@ public function execute() { $this->mtrace(''); } catch (moodle_exception $e) { $this->mtrace('Error in delta usersync: ' . $e->getMessage()); + if (!empty($groupfilter)) { + $this->mtrace(''); + $this->mtrace('NOTE: Group filter is enabled (Group ID: ' . $groupfilter . ')'); + $this->mtrace('This error may indicate:'); + $this->mtrace(' - The group ID is invalid or does not exist'); + $this->mtrace(' - The application does not have permission to access the group'); + $this->mtrace(' - The group was deleted or the ID changed'); + $this->mtrace('Please verify the group ID is correct and the application has the necessary permissions.'); + } utils::debug($e->getMessage(), __METHOD__, $e); $this->mtrace('Resetting delta tokens.'); $deltatoken = null; @@ -258,8 +282,9 @@ protected function display_sync_settings() { $this->mtrace('Sync options:', 1); foreach ($syncoptions as $option => $value) { - // Skip photo and timezone sync - they're handled by separate task. - if ($option === 'photosync' || $option === 'tzsync') { + // Skip all photo and timezone sync options (both scheduled and on-login variants) — + // they are handled by the dedicated photoandtimezonesync task, not this one. + if ($option === 'photosync' || $option === 'photosynconlogin' || $option === 'tzsync' || $option === 'tzsynconlogin') { continue; } diff --git a/local/o365/db/install.xml b/local/o365/db/install.xml index a8857c597..97d7245ec 100644 --- a/local/o365/db/install.xml +++ b/local/o365/db/install.xml @@ -111,6 +111,7 @@ + diff --git a/local/o365/db/upgrade.php b/local/o365/db/upgrade.php index 845a0d20e..394dc864c 100644 --- a/local/o365/db/upgrade.php +++ b/local/o365/db/upgrade.php @@ -1641,5 +1641,39 @@ function xmldb_local_o365_upgrade($oldversion) { upgrade_plugin_savepoint(true, 2025040820.03, 'local', 'o365'); } + if ($oldversion < 2025040830.01) { + // Add dedicated photohash field to local_o365_appassign. + // Previously the photoid column (originally the MS 365 photo etag ID) was repurposed + // to store a SHA-256 hash of the photo bytes, causing confusion with the field's stated + // purpose. This step introduces a separate, correctly-named column for the hash so that + // photoid retains its original meaning. + $table = new xmldb_table('local_o365_appassign'); + $field = new xmldb_field( + 'photohash', + XMLDB_TYPE_CHAR, + '64', + null, + null, + null, + null, + 'photoid' + ); + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Migrate any SHA-256 hashes already stored in photoid into the new photohash column. + // A SHA-256 hex string is exactly 64 characters; a genuine MS 365 photo etag ID is + // typically much shorter, so length == 64 is a reliable discriminator. + $DB->execute( + "UPDATE {local_o365_appassign} + SET photohash = photoid, photoid = '' + WHERE " . $DB->sql_length('photoid') . " = 64" + ); + + // O365 savepoint reached. + upgrade_plugin_savepoint(true, 2025040830.01, 'local', 'o365'); + } + return true; } diff --git a/local/o365/lang/en/local_o365.php b/local/o365/lang/en/local_o365.php index d28145d0a..deb42f461 100644 --- a/local/o365/lang/en/local_o365.php +++ b/local/o365/lang/en/local_o365.php @@ -442,6 +442,18 @@ $string['settings_teamconnections_details'] = 'Review and manage connections between Moodle course and Microsoft Teams.'; $string['settings_usermatch'] = 'User Matching'; $string['settings_usermatch_details'] = 'This tool allows you to match Moodle users with Microsoft 365 users based on a custom uploaded data file.'; +$string['settings_usersyncgroupfilter'] = 'User sync group filter'; +$string['settings_usersyncgroupfilter_details'] = 'When a group object ID (GUID) is entered here, full user syncs will be restricted to members and owners of that Microsoft 365 group. Leave blank to sync all licensed users in your tenant.

+CAUTION: This is an advanced setting. Misconfiguration can cause significant data synchronisation issues.

+When configured, the following apply:
+(1) Profile updates, timezone updates, and photo updates will only be applied to users who are members or owners of the group
+(2) New Moodle accounts will only be created for users who are members or owners of the group
+(3) Moodle accounts for non-member users will no longer receive updates and may become out-of-sync with Microsoft 365
+(4) Users disabled in Microsoft 365 will still be suspended in Moodle by the enabled-status sync, which runs independently of group filtering
+(5) Removing a user from the group stops future syncs for that account but does not delete or suspend their Moodle account

+Incremental (delta) sync limitation: Microsoft Graph does not support delta queries on group membership endpoints. When this setting is configured, delta syncs will perform a full group-member sync instead of an incremental update. This is correct behaviour but may be slower than a normal delta sync.

+Validate the group object ID before saving. Enter the group object ID (GUID).'; +$string['settings_usersyncgroupfilter_validation_error'] = 'Invalid group ID format. The group ID must be a valid GUID (e.g., 550e8400-e29b-41d4-a716-446655440000).'; $string['settings_usersynccreationrestriction'] = 'User creation restriction'; $string['settings_usersynccreationrestriction_details'] = 'If enabled, only Microsoft Entra ID users matching the condition will be created during user sync.'; $string['settings_usersynccreationrestriction_fieldval'] = 'Field value'; @@ -877,6 +889,7 @@ $string['privacy:metadata:local_o365_appassign:muserid'] = 'The ID of the Moodle user'; $string['privacy:metadata:local_o365_appassign:assigned'] = 'Whether the user has been assigned to the app'; $string['privacy:metadata:local_o365_appassign:photoid'] = 'The ID of the user\'s photo in Microsoft 365'; +$string['privacy:metadata:local_o365_appassign:photohash'] = 'A SHA-256 hash of the user\'s profile photo bytes, stored to detect photo changes without re-fetching the full image'; $string['privacy:metadata:local_o365_appassign:photoupdated'] = 'When the user\'s photo was last updated from Microsoft 365'; $string['privacy:metadata:local_o365_matchqueue'] = 'Information about Moodle user to Microsoft 365 user matching'; $string['privacy:metadata:local_o365_matchqueue:musername'] = 'The username of the Moodle user.'; diff --git a/local/o365/settings.php b/local/o365/settings.php index 08c90b188..f57e1b343 100644 --- a/local/o365/settings.php +++ b/local/o365/settings.php @@ -34,6 +34,7 @@ use local_o365\adminsetting\serviceresource; use local_o365\adminsetting\toollink; use local_o365\adminsetting\coursesync; +use local_o365\adminsetting\usersyncgroupfilter; use local_o365\adminsetting\usersynccreationrestriction; use local_o365\adminsetting\team_type_custom_id; use local_o365\feature\coursesync\main; @@ -208,6 +209,11 @@ $desc = new lang_string('settings_usersync_details', 'local_o365', $scheduledtasks->out()); $usersyncsettings->add(new usersyncoptions('local_o365/usersync', $label, $desc)); + // User sync group filter. + $label = new lang_string('settings_usersyncgroupfilter', 'local_o365'); + $desc = new lang_string('settings_usersyncgroupfilter_details', 'local_o365'); + $usersyncsettings->add(new usersyncgroupfilter('local_o365/usersyncgroupfilter', $label, $desc, '')); + // User creation restrictions. $label = new lang_string('settings_usersynccreationrestriction', 'local_o365'); $desc = new lang_string('settings_usersynccreationrestriction_details', 'local_o365'); diff --git a/local/o365/version.php b/local/o365/version.php index d916af04b..30cf8d0fe 100644 --- a/local/o365/version.php +++ b/local/o365/version.php @@ -26,7 +26,7 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2025040830; +$plugin->version = 2025040830.01; $plugin->requires = 2025040800; $plugin->release = '5.0.6'; $plugin->component = 'local_o365';