Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions local/o365/classes/adminsetting/usersyncgroupfilter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.

/**
* Admin setting for group-based user sync filtering.
*
* @package local_o365
* @author Lai Wei <lai.wei@enovation.ie>
* @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);
}
}
187 changes: 159 additions & 28 deletions local/o365/classes/feature/usersync/main.php
Original file line number Diff line number Diff line change
Expand Up @@ -242,14 +242,16 @@ 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(
int $muserid,
$photodata,
bool $printtrace = false,
?int $appassignid = null,
?int $currentpicture = null
?int $currentpicture = null,
?string $photohash = null
) {
global $DB, $CFG;

Expand All @@ -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]);
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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);
}

Expand All @@ -580,13 +675,27 @@ 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);
}

/**
* 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down Expand Up @@ -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;
}
}
1 change: 1 addition & 0 deletions local/o365/classes/privacy/provider.php
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ public static function get_metadata(collection $collection): collection {
'muserid',
'assigned',
'photoid',
'photohash',
'photoupdated',
],
'local_o365_matchqueue' => [
Expand Down
Loading