Add Microsoft 365 user sync with roles, profile fields and deprovisioning
Some checks are pending
CI / PHP lint (7.4) (pull_request) Waiting to run
CI / PHP lint (8.0) (pull_request) Waiting to run
CI / PHP lint (8.1) (pull_request) Waiting to run
CI / PHP lint (8.2) (pull_request) Waiting to run
CI / PHP lint (8.3) (pull_request) Waiting to run
CI / PHP lint (8.4) (pull_request) Waiting to run
CI / WordPress Coding Standards (pull_request) Waiting to run
CI / WordPress.org Plugin Check (pull_request) Waiting to run

New "User sync" tab that imports Microsoft 365 / Entra ID users as
WordPress accounts and keeps them up to date:

- Scope: whole tenant or the (nested) members of selected groups,
  guests optional, e-mail domain allow-list respected. Existing accounts
  are linked by e-mail address.
- Roles: selectable default role plus a group -> role mapping (in
  addition to or instead of the default role, first match wins).
  Roles of pre-existing accounts are only managed on request.
- Profile: selectable Graph attributes (names, job title, department,
  phones, address, language, ...) and the profile photo as avatar.
- Deprovisioning: accounts disabled or deleted in Microsoft 365 (or
  removed from the sync groups) are deactivated or deleted; accounts
  deactivated by the sync are reactivated automatically. Deactivated
  accounts lose every sign-in path and all sessions.
- Safeguards: dry run, safety stop above 20 % (min. 5) deprovisioning,
  abort on any Graph error, "deleted" only on a 404 for the object ID,
  protected pre-existing administrators and own account, content
  reassignment required for deletion, run lock.
- Runs manually, via WP-Cron or `wp m365-login sync [--dry-run]`.
- Users screen column with deactivate/reactivate row actions and a
  read-only Microsoft 365 section on the profile screen.

The Graph client gains paging, retry on throttling and user, group
member and photo endpoints. The group picker is now reusable.
Version 1.1.0, German translations (du/Sie), docs and audit addendum.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Friederich Loheide 2026-09-23 16:34:30 +00:00
parent 1708bae91a
commit 4edf20bc45
20 changed files with 5532 additions and 982 deletions

View file

@ -8,7 +8,7 @@
defined( 'ABSPATH' ) || exit;
/**
* Obtains app-only tokens via client credentials and queries groups.
* Obtains app-only tokens via client credentials and queries users and groups.
*/
class M365_Login_Graph {
@ -98,6 +98,63 @@ class M365_Login_Graph {
return (string) $body['access_token'];
}
/**
* Performs an authenticated Graph request and returns the raw HTTP response.
*
* Retries a few times when Microsoft throttles (HTTP 429) or is briefly unavailable (503/504).
*
* @param string $method HTTP method.
* @param string $path Path relative to the v1.0 base (with query string) or an absolute Graph URL (paging links).
* @param array|null $json JSON body for POST requests.
* @param array $headers Extra headers.
* @return array|WP_Error Response array from wp_remote_request().
*/
private function raw_request( $method, $path, $json = null, $headers = array() ) {
$url = 0 === strpos( $path, self::GRAPH_BASE . '/' ) ? $path : self::GRAPH_BASE . $path;
if ( 0 !== strpos( $url, self::GRAPH_BASE . '/' ) ) {
return new WP_Error( 'graph_bad_url', 'Refusing to call a non-Graph URL.' );
}
for ( $attempt = 1; ; $attempt++ ) {
$token = $this->app_token();
if ( is_wp_error( $token ) ) {
return $token;
}
$args = array(
'method' => $method,
'timeout' => self::HTTP_TIMEOUT,
'headers' => array_merge(
array(
'Authorization' => 'Bearer ' . $token,
'Accept' => 'application/json',
),
$headers
),
);
if ( null !== $json ) {
$args['headers']['Content-Type'] = 'application/json';
$args['body'] = wp_json_encode( $json );
}
$response = wp_remote_request( $url, $args );
if ( is_wp_error( $response ) ) {
return $response;
}
$code = (int) wp_remote_retrieve_response_code( $response );
if ( 401 === $code ) {
$this->flush_token();
}
if ( $attempt < 4 && in_array( $code, array( 429, 503, 504 ), true ) ) {
$wait = (int) wp_remote_retrieve_header( $response, 'retry-after' );
sleep( max( 1, min( 10, $wait > 0 ? $wait : $attempt * 2 ) ) );
continue;
}
return $response;
}
}
/**
* Performs an authenticated Graph request.
*
@ -105,31 +162,10 @@ class M365_Login_Graph {
* @param string $path Path relative to the v1.0 base (with query string).
* @param array|null $json JSON body for POST requests.
* @param array $headers Extra headers.
* @return array|WP_Error Decoded JSON.
* @return array|WP_Error Decoded JSON. Errors carry array( 'status' => HTTP code ) as data.
*/
private function request( $method, $path, $json = null, $headers = array() ) {
$token = $this->app_token();
if ( is_wp_error( $token ) ) {
return $token;
}
$args = array(
'method' => $method,
'timeout' => self::HTTP_TIMEOUT,
'headers' => array_merge(
array(
'Authorization' => 'Bearer ' . $token,
'Accept' => 'application/json',
),
$headers
),
);
if ( null !== $json ) {
$args['headers']['Content-Type'] = 'application/json';
$args['body'] = wp_json_encode( $json );
}
$response = wp_remote_request( self::GRAPH_BASE . $path, $args );
$response = $this->raw_request( $method, $path, $json, $headers );
if ( is_wp_error( $response ) ) {
return $response;
}
@ -137,18 +173,156 @@ class M365_Login_Graph {
$code = (int) wp_remote_retrieve_response_code( $response );
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( 401 === $code ) {
$this->flush_token();
}
if ( $code < 200 || $code >= 300 || ! is_array( $body ) ) {
$graph_code = isset( $body['error']['code'] ) ? (string) $body['error']['code'] : 'HTTP ' . $code;
$message = isset( $body['error']['message'] ) ? (string) $body['error']['message'] : '';
return new WP_Error( 'graph_' . sanitize_key( $graph_code ), $graph_code . ( $message ? ': ' . $message : '' ) );
return $this->error_from( $code, $body );
}
return $body;
}
/**
* Builds a WP_Error from a failed Graph response.
*
* @param int $code HTTP status.
* @param array|null $body Decoded body.
* @return WP_Error
*/
private function error_from( $code, $body ) {
$graph_code = isset( $body['error']['code'] ) ? (string) $body['error']['code'] : 'HTTP ' . $code;
$message = isset( $body['error']['message'] ) ? (string) $body['error']['message'] : '';
return new WP_Error( 'graph_' . sanitize_key( $graph_code ), $graph_code . ( $message ? ': ' . $message : '' ), array( 'status' => (int) $code ) );
}
/**
* Whether a Graph error means "object does not exist".
*
* @param WP_Error $error Error.
* @return bool
*/
public static function is_not_found( $error ) {
$data = $error->get_error_data();
return is_array( $data ) && isset( $data['status'] ) && 404 === (int) $data['status'];
}
/**
* Follows @odata.nextLink until every page of a collection is read.
*
* @param string $path First page (relative path).
* @param array $headers Extra headers.
* @return array[]|WP_Error All items.
*/
private function collect( $path, $headers = array() ) {
$items = array();
$next = $path;
$pages = 0;
while ( '' !== $next ) {
if ( ++$pages > 1000 ) {
return new WP_Error( 'graph_paging', 'Too many result pages.' );
}
$result = $this->request( 'GET', $next, null, $headers );
if ( is_wp_error( $result ) ) {
return $result;
}
if ( isset( $result['value'] ) && is_array( $result['value'] ) ) {
foreach ( $result['value'] as $item ) {
if ( is_array( $item ) && ! empty( $item['id'] ) ) {
$items[] = $item;
}
}
}
$next = isset( $result['@odata.nextLink'] ) && is_string( $result['@odata.nextLink'] ) ? $result['@odata.nextLink'] : '';
}
return $items;
}
/**
* Lists every user of the tenant.
*
* @param string[] $select Properties to read.
* @return array[]|WP_Error
*/
public function list_users( $select ) {
return $this->collect( '/users?$select=' . rawurlencode( implode( ',', $select ) ) . '&$top=999' );
}
/**
* Lists the users that are (directly or through nested groups) members of a group.
*
* @param string $group_id Group object ID.
* @param string[] $select Properties to read.
* @return array[]|WP_Error
*/
public function list_group_users( $group_id, $select ) {
if ( ! M365_Login_Settings::is_guid( $group_id ) ) {
return new WP_Error( 'graph_bad_group', 'Invalid group object ID.' );
}
return $this->collect(
'/groups/' . rawurlencode( strtolower( $group_id ) ) . '/transitiveMembers/microsoft.graph.user?$select=' . rawurlencode( implode( ',', $select ) ) . '&$top=999&$count=true',
array( 'ConsistencyLevel' => 'eventual' )
);
}
/**
* Reads a single user.
*
* @param string $oid User object ID.
* @param string[] $select Properties to read.
* @return array|WP_Error WP_Error with status 404 when the user does not exist (anymore).
*/
public function get_user( $oid, $select ) {
if ( ! M365_Login_Settings::is_guid( $oid ) ) {
return new WP_Error( 'graph_bad_oid', 'Invalid user object ID.' );
}
return $this->request( 'GET', '/users/' . rawurlencode( strtolower( $oid ) ) . '?$select=' . rawurlencode( implode( ',', $select ) ) );
}
/**
* Metadata of a user's profile photo (prefers the 240×240 rendition).
*
* @param string $oid User object ID.
* @return array|null|WP_Error array( 'path' => photo path, 'etag' => string ), null when the user has no photo.
*/
public function photo_info( $oid ) {
if ( ! M365_Login_Settings::is_guid( $oid ) ) {
return new WP_Error( 'graph_bad_oid', 'Invalid user object ID.' );
}
$base = '/users/' . rawurlencode( strtolower( $oid ) );
foreach ( array( $base . '/photos/240x240', $base . '/photo' ) as $path ) {
$meta = $this->request( 'GET', $path );
if ( is_wp_error( $meta ) ) {
if ( self::is_not_found( $meta ) ) {
continue;
}
return $meta;
}
$etag = isset( $meta['@odata.mediaEtag'] ) ? (string) $meta['@odata.mediaEtag'] : '';
return array(
'path' => $path,
'etag' => '' !== $etag ? $etag : md5( (string) wp_json_encode( $meta ) ),
);
}
return null;
}
/**
* Downloads photo bytes.
*
* @param string $path Photo path returned by photo_info().
* @return string|WP_Error Binary image data.
*/
public function photo_bytes( $path ) {
$response = $this->raw_request( 'GET', $path . '/$value', null, array( 'Accept' => 'image/*' ) );
if ( is_wp_error( $response ) ) {
return $response;
}
$code = (int) wp_remote_retrieve_response_code( $response );
$body = wp_remote_retrieve_body( $response );
if ( 200 !== $code ) {
return $this->error_from( $code, json_decode( $body, true ) );
}
return $body;
}
/**
* Searches groups by display name.
*