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
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:
parent
1708bae91a
commit
4edf20bc45
20 changed files with 5532 additions and 982 deletions
|
|
@ -34,36 +34,50 @@ class M365_Login_Settings {
|
|||
public function defaults() {
|
||||
return array(
|
||||
// Connection.
|
||||
'tenant_id' => '',
|
||||
'client_id' => '',
|
||||
'client_secret' => '', // Stored encrypted.
|
||||
'auth_method' => 'secret', // 'secret' or 'certificate'.
|
||||
'cert_private_key' => '', // PEM, stored encrypted.
|
||||
'cert_certificate' => '', // PEM (public).
|
||||
'prompt' => 'select_account',
|
||||
'tenant_id' => '',
|
||||
'client_id' => '',
|
||||
'client_secret' => '', // Stored encrypted.
|
||||
'auth_method' => 'secret', // 'secret' or 'certificate'.
|
||||
'cert_private_key' => '', // PEM, stored encrypted.
|
||||
'cert_certificate' => '', // PEM (public).
|
||||
'prompt' => 'select_account',
|
||||
// Security / matching.
|
||||
'upn_fallback' => 1,
|
||||
'bind_oid' => 1,
|
||||
'allowed_domains' => '',
|
||||
'allowed_groups' => array(), // id => display name.
|
||||
'remember_me' => 0,
|
||||
'upn_fallback' => 1,
|
||||
'bind_oid' => 1,
|
||||
'allowed_domains' => '',
|
||||
'allowed_groups' => array(), // id => display name.
|
||||
'remember_me' => 0,
|
||||
// Button-only mode.
|
||||
'button_only' => 0,
|
||||
'fallback_key' => '',
|
||||
'button_only' => 0,
|
||||
'fallback_key' => '',
|
||||
// Button appearance.
|
||||
'button_text' => __( 'Sign in with Microsoft', 'm365-login' ),
|
||||
'button_icon' => '', // Empty = bundled Microsoft logo.
|
||||
'button_show_icon' => 1,
|
||||
'button_bg' => '#2f2f2f',
|
||||
'button_bg_hover' => '#1a1a1a',
|
||||
'button_color' => '#ffffff',
|
||||
'button_border' => '#2f2f2f',
|
||||
'button_radius' => 4,
|
||||
'button_position' => 'below',
|
||||
'divider_text' => __( 'or', 'm365-login' ),
|
||||
'button_text' => __( 'Sign in with Microsoft', 'm365-login' ),
|
||||
'button_icon' => '', // Empty = bundled Microsoft logo.
|
||||
'button_show_icon' => 1,
|
||||
'button_bg' => '#2f2f2f',
|
||||
'button_bg_hover' => '#1a1a1a',
|
||||
'button_color' => '#ffffff',
|
||||
'button_border' => '#2f2f2f',
|
||||
'button_radius' => 4,
|
||||
'button_position' => 'below',
|
||||
'divider_text' => __( 'or', 'm365-login' ),
|
||||
// Custom login pages.
|
||||
'custom_login_url' => '',
|
||||
'inject_form' => 1, // Add the button to wp_login_form() output.
|
||||
'custom_login_url' => '',
|
||||
'inject_form' => 1, // Add the button to wp_login_form() output.
|
||||
// User sync.
|
||||
'sync_enabled' => 0, // Scheduled sync via WP-Cron.
|
||||
'sync_interval' => 'daily',
|
||||
'sync_guests' => 0,
|
||||
'sync_scope_groups' => array(), // id => display name; empty = whole tenant.
|
||||
'sync_default_role' => 'subscriber',
|
||||
'sync_role_map' => array(), // id => array( 'name' => .., 'role' => .. ), in priority order.
|
||||
'sync_role_mode' => 'add', // 'add' (extra roles) or 'replace' (first match replaces the default role).
|
||||
'sync_manage_existing' => 0, // Also manage roles of accounts that existed before the sync.
|
||||
'sync_attributes' => array( 'displayName', 'givenName', 'surname' ),
|
||||
'sync_disabled_action' => 'disable', // Account disabled in Microsoft 365: none|disable|delete.
|
||||
'sync_deleted_action' => 'disable', // Account deleted in Microsoft 365: none|disable|delete.
|
||||
'sync_scope_action' => 'none', // Removed from the sync groups: none|disable|delete.
|
||||
'sync_reassign' => 0, // User ID that receives content of deleted users.
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -80,6 +94,13 @@ class M365_Login_Settings {
|
|||
return $this->cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the cached settings (after the option was written).
|
||||
*/
|
||||
public function flush() {
|
||||
$this->cache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a single setting.
|
||||
*
|
||||
|
|
@ -309,7 +330,47 @@ class M365_Login_Settings {
|
|||
* @return array
|
||||
*/
|
||||
public function allowed_groups() {
|
||||
$raw = $this->get( 'allowed_groups', array() );
|
||||
return self::guid_map( $this->get( 'allowed_groups', array() ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups that limit the user sync (lowercase GUID => name); empty = whole tenant.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function sync_scope_groups() {
|
||||
return self::guid_map( $this->get( 'sync_scope_groups', array() ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Group → role mapping in priority order.
|
||||
*
|
||||
* @return array lowercase GUID => array( 'name' => string, 'role' => string ).
|
||||
*/
|
||||
public function sync_role_map() {
|
||||
$raw = $this->get( 'sync_role_map', array() );
|
||||
$out = array();
|
||||
if ( is_array( $raw ) ) {
|
||||
foreach ( $raw as $id => $row ) {
|
||||
$id = strtolower( (string) $id );
|
||||
if ( self::is_guid( $id ) && is_array( $row ) && ! empty( $row['role'] ) ) {
|
||||
$out[ $id ] = array(
|
||||
'name' => isset( $row['name'] ) ? (string) $row['name'] : $id,
|
||||
'role' => (string) $row['role'],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps GUID keys (lowercased) of an id => name array.
|
||||
*
|
||||
* @param mixed $raw Stored value.
|
||||
* @return array
|
||||
*/
|
||||
private static function guid_map( $raw ) {
|
||||
$out = array();
|
||||
if ( is_array( $raw ) ) {
|
||||
foreach ( $raw as $id => $name ) {
|
||||
|
|
@ -449,8 +510,8 @@ class M365_Login_Settings {
|
|||
// Certificate: keep the stored pair unless a new one is pasted or removal is requested.
|
||||
$out['cert_private_key'] = $current['cert_private_key'];
|
||||
$out['cert_certificate'] = $current['cert_certificate'];
|
||||
$pasted_key = isset( $input['cert_key_pem'] ) ? trim( (string) wp_unslash( $input['cert_key_pem'] ) ) : '';
|
||||
$pasted_cert = isset( $input['cert_cert_pem'] ) ? trim( (string) wp_unslash( $input['cert_cert_pem'] ) ) : '';
|
||||
$pasted_key = isset( $input['cert_key_pem'] ) ? trim( (string) wp_unslash( $input['cert_key_pem'] ) ) : '';
|
||||
$pasted_cert = isset( $input['cert_cert_pem'] ) ? trim( (string) wp_unslash( $input['cert_cert_pem'] ) ) : '';
|
||||
if ( ! empty( $input['cert_remove'] ) ) {
|
||||
$out['cert_private_key'] = '';
|
||||
$out['cert_certificate'] = '';
|
||||
|
|
@ -485,26 +546,12 @@ class M365_Login_Settings {
|
|||
$out['bind_oid'] = empty( $input['bind_oid'] ) ? 0 : 1;
|
||||
$out['remember_me'] = empty( $input['remember_me'] ) ? 0 : 1;
|
||||
|
||||
$domains = isset( $input['allowed_domains'] ) ? sanitize_textarea_field( wp_unslash( $input['allowed_domains'] ) ) : '';
|
||||
$domains = preg_replace( '/[^a-z0-9.\-@,;\s]/i', '', $domains );
|
||||
$domains = isset( $input['allowed_domains'] ) ? sanitize_textarea_field( wp_unslash( $input['allowed_domains'] ) ) : '';
|
||||
$domains = preg_replace( '/[^a-z0-9.\-@,;\s]/i', '', $domains );
|
||||
$out['allowed_domains'] = trim( (string) $domains );
|
||||
|
||||
// Allowed groups: GUID => name.
|
||||
$groups = array();
|
||||
if ( ! empty( $input['allowed_groups'] ) && is_array( $input['allowed_groups'] ) ) {
|
||||
foreach ( $input['allowed_groups'] as $id => $name ) {
|
||||
$id = strtolower( trim( sanitize_text_field( wp_unslash( (string) $id ) ) ) );
|
||||
if ( ! self::is_guid( $id ) ) {
|
||||
continue;
|
||||
}
|
||||
$name = sanitize_text_field( wp_unslash( (string) $name ) );
|
||||
$groups[ $id ] = '' === $name ? $id : mb_substr( $name, 0, 120 );
|
||||
if ( count( $groups ) >= 100 ) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
$out['allowed_groups'] = $groups;
|
||||
$out['allowed_groups'] = self::sanitize_group_list( isset( $input['allowed_groups'] ) ? $input['allowed_groups'] : array() );
|
||||
|
||||
// Button-only mode + fallback key.
|
||||
$out['button_only'] = empty( $input['button_only'] ) ? 0 : 1;
|
||||
|
|
@ -551,11 +598,114 @@ class M365_Login_Settings {
|
|||
$divider = isset( $input['divider_text'] ) ? sanitize_text_field( wp_unslash( $input['divider_text'] ) ) : '';
|
||||
$out['divider_text'] = mb_substr( $divider, 0, 40 );
|
||||
|
||||
$out = $this->sanitize_sync( $input, $out );
|
||||
|
||||
$this->cache = null;
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitises the user sync settings.
|
||||
*
|
||||
* @param array $input Raw input.
|
||||
* @param array $out Settings sanitised so far.
|
||||
* @return array
|
||||
*/
|
||||
private function sanitize_sync( $input, $out ) {
|
||||
$defaults = $this->defaults();
|
||||
|
||||
$out['sync_enabled'] = empty( $input['sync_enabled'] ) ? 0 : 1;
|
||||
$out['sync_guests'] = empty( $input['sync_guests'] ) ? 0 : 1;
|
||||
$out['sync_manage_existing'] = empty( $input['sync_manage_existing'] ) ? 0 : 1;
|
||||
|
||||
$interval = isset( $input['sync_interval'] ) ? sanitize_key( $input['sync_interval'] ) : '';
|
||||
$out['sync_interval'] = in_array( $interval, array( 'hourly', 'twicedaily', 'daily' ), true ) ? $interval : $defaults['sync_interval'];
|
||||
|
||||
$mode = isset( $input['sync_role_mode'] ) ? sanitize_key( $input['sync_role_mode'] ) : '';
|
||||
$out['sync_role_mode'] = in_array( $mode, array( 'add', 'replace' ), true ) ? $mode : $defaults['sync_role_mode'];
|
||||
|
||||
$role = isset( $input['sync_default_role'] ) ? sanitize_key( $input['sync_default_role'] ) : '';
|
||||
$out['sync_default_role'] = '' !== $role && get_role( $role ) ? $role : $defaults['sync_default_role'];
|
||||
|
||||
$out['sync_scope_groups'] = self::sanitize_group_list( isset( $input['sync_scope_groups'] ) ? $input['sync_scope_groups'] : array() );
|
||||
|
||||
$map = array();
|
||||
if ( ! empty( $input['sync_role_map'] ) && is_array( $input['sync_role_map'] ) ) {
|
||||
foreach ( $input['sync_role_map'] as $id => $row ) {
|
||||
$id = strtolower( trim( sanitize_text_field( wp_unslash( (string) $id ) ) ) );
|
||||
if ( ! self::is_guid( $id ) || ! is_array( $row ) ) {
|
||||
continue;
|
||||
}
|
||||
$map_role = isset( $row['role'] ) ? sanitize_key( $row['role'] ) : '';
|
||||
if ( '' === $map_role || ! get_role( $map_role ) ) {
|
||||
continue;
|
||||
}
|
||||
$name = isset( $row['name'] ) ? sanitize_text_field( wp_unslash( (string) $row['name'] ) ) : '';
|
||||
$map[ $id ] = array(
|
||||
'name' => '' === $name ? $id : mb_substr( $name, 0, 120 ),
|
||||
'role' => $map_role,
|
||||
);
|
||||
if ( count( $map ) >= 100 ) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
$out['sync_role_map'] = $map;
|
||||
|
||||
$attributes = array();
|
||||
if ( ! empty( $input['sync_attributes'] ) && is_array( $input['sync_attributes'] ) ) {
|
||||
$known = array_keys( M365_Login_Sync::attributes() );
|
||||
foreach ( $input['sync_attributes'] as $attribute ) {
|
||||
$attribute = sanitize_text_field( wp_unslash( (string) $attribute ) );
|
||||
if ( in_array( $attribute, $known, true ) ) {
|
||||
$attributes[] = $attribute;
|
||||
}
|
||||
}
|
||||
}
|
||||
$out['sync_attributes'] = array_values( array_unique( $attributes ) );
|
||||
|
||||
foreach ( array( 'sync_disabled_action', 'sync_deleted_action', 'sync_scope_action' ) as $key ) {
|
||||
$action = isset( $input[ $key ] ) ? sanitize_key( $input[ $key ] ) : '';
|
||||
$out[ $key ] = in_array( $action, array( 'none', 'disable', 'delete' ), true ) ? $action : $defaults[ $key ];
|
||||
}
|
||||
|
||||
$reassign = isset( $input['sync_reassign'] ) ? absint( $input['sync_reassign'] ) : 0;
|
||||
$out['sync_reassign'] = $reassign && get_userdata( $reassign ) ? $reassign : 0;
|
||||
|
||||
$deletes = in_array( 'delete', array( $out['sync_disabled_action'], $out['sync_deleted_action'], $out['sync_scope_action'] ), true );
|
||||
if ( $deletes && ! $out['sync_reassign'] ) {
|
||||
add_settings_error( M365_LOGIN_OPTION, 'sync_reassign', __( 'User sync: "Delete" needs a user who receives the posts of deleted accounts. Until one is selected, accounts are deactivated instead.', 'm365-login' ), 'warning' );
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitises a GUID => name list posted by a group picker.
|
||||
*
|
||||
* @param mixed $raw Raw input.
|
||||
* @return array
|
||||
*/
|
||||
private static function sanitize_group_list( $raw ) {
|
||||
$groups = array();
|
||||
if ( empty( $raw ) || ! is_array( $raw ) ) {
|
||||
return $groups;
|
||||
}
|
||||
foreach ( $raw as $id => $name ) {
|
||||
$id = strtolower( trim( sanitize_text_field( wp_unslash( (string) $id ) ) ) );
|
||||
if ( ! self::is_guid( $id ) ) {
|
||||
continue;
|
||||
}
|
||||
$name = sanitize_text_field( wp_unslash( (string) $name ) );
|
||||
$groups[ $id ] = '' === $name ? $id : mb_substr( $name, 0, 120 );
|
||||
if ( count( $groups ) >= 100 ) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return $groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a GUID.
|
||||
*
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue