Skip to content

Commit 2dd604a

Browse files
authored
perf(users): check roles on request or on a schedule, not on every admin load (#49)
* perf(users): check roles on request or on a schedule, not on every admin load The users screen, its Out of sync view and the WorkOS Users settings tab each called RoleMapper::get_out_of_sync_users(), which loaded every member of the organization one get_user_by() at a time. On a site with 25,000 members that is ~50,000 queries and 17 s per load with an empty object cache, and 2.8 s with a warm one. RoleSyncCheck reads members in batches (one JOIN over memberships, users and capabilities per 1,000 members) and stores the result. It runs when an admin clicks Check now on WorkOS -> Users, or on an hourly/daily schedule when Action Scheduler is loaded. Between runs the stored result follows role and membership changes one user at a time. The admin screens read the stored result and never compute it. The users list also loads the page's memberships in one query (Manager::prime_user_orgs) instead of one per row for the role mismatch badge. Also: use imports instead of fully qualified class names in the touched files. * chore(uninstall): remove the role sync check option and scheduled actions
1 parent 7c22c0f commit 2dd604a

10 files changed

Lines changed: 1410 additions & 105 deletions

File tree

‎AGENTS.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ Per-environment constants (take priority over generic):
165165
| `src/WorkOS/Sync/Controller.php` | Sync controller |
166166
| `src/WorkOS/Sync/UserSync.php` | User sync (inbound/outbound) |
167167
| `src/WorkOS/Sync/RoleMapper.php` | WorkOS role → WP role mapping |
168+
| `src/WorkOS/Sync/RoleSyncCheck.php` | Which members' WP role doesn't match the role map. Batched (one JOIN query per 1,000 members), run by "Check now" on WorkOS → Users or on an Action Scheduler schedule (`role_sync_schedule`: off/hourly/daily, only offered when AS is loaded). Stores the result in `workos_role_sync_check` (not autoloaded, stale when env/org/role map change) and rechecks single users on role and `workos_org_membership_changed`. Admin screens read `result()`; nothing is computed on page load. |
168169
| `src/WorkOS/Sync/DirectorySync.php` | Directory sync (SCIM) |
169170
| `src/WorkOS/Sync/AuditLog.php` | Forward WP events to WorkOS Audit Logs |
170171
| `src/WorkOS/Sync/AuditLogController.php` | Audit log controller |
@@ -352,6 +353,7 @@ tests/
352353
├── RedirectTest.php # Login redirect tests
353354
├── RendererKsesTest.php # Shared renderer KSES allowlist tests
354355
├── RoleMapperTest.php # Role mapper tests
356+
├── RoleSyncCheckTest.php # Batched role sync check: parity with the per-user check, stored result, rechecks, list-screen reads
355357
├── SchemaTest.php # Database schema tests
356358
├── TokenAuthRefreshTest.php # TokenAuth regression: rejects unsigned/expired Bearer JWTs
357359
├── UserHelperTest.php # WorkOS\User + `workos_is_sso_user`/`workos_has_active_session` helpers

‎phpstan/stubs.php‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,32 @@
1515
define( 'WORKOS_DIR', '' );
1616
define( 'WORKOS_URL', '' );
1717
define( 'WORKOS_BASENAME', '' );
18+
19+
// Action Scheduler (optional; WooCommerce and many plugins ship it). Guarded by
20+
// RoleSyncCheck::has_action_scheduler() before every call.
21+
22+
/**
23+
* @param string $hook Hook.
24+
* @param array $args Arguments.
25+
* @param string $group Group.
26+
* @return int
27+
*/
28+
function as_enqueue_async_action( $hook, $args = array(), $group = '' ) {}
29+
30+
/**
31+
* @param int $timestamp First run.
32+
* @param int $interval_in_seconds Interval.
33+
* @param string $hook Hook.
34+
* @param array $args Arguments.
35+
* @param string $group Group.
36+
* @return int
37+
*/
38+
function as_schedule_recurring_action( $timestamp, $interval_in_seconds, $hook, $args = array(), $group = '' ) {}
39+
40+
/**
41+
* @param string $hook Hook.
42+
* @param array $args Arguments.
43+
* @param string $group Group.
44+
* @return void
45+
*/
46+
function as_unschedule_all_actions( $hook, $args = array(), $group = '' ) {}

‎src/WorkOS/Admin/Settings.php‎

Lines changed: 165 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
use WorkOS\Options\Options;
1515
use WorkOS\Options\Production;
1616
use WorkOS\Options\Staging;
17+
use WorkOS\Sync\RoleMapper;
18+
use WorkOS\Sync\RoleSyncCheck;
1719
use WorkOS\Vendor\StellarWP\SuperGlobals\SuperGlobals;
1820

1921
defined( 'ABSPATH' ) || exit;
@@ -52,6 +54,13 @@ class Settings {
5254
*/
5355
private bool $role_sync_form_queued = false;
5456

57+
/**
58+
* Whether the role-check form has been queued for footer render.
59+
*
60+
* @var bool
61+
*/
62+
private bool $role_check_form_queued = false;
63+
5564
/**
5665
* Constructor.
5766
*/
@@ -61,6 +70,7 @@ public function __construct() {
6170
add_action( 'admin_init', [ $this, 'handle_activate_environment' ] );
6271
add_action( 'admin_post_workos_create_org', [ $this, 'handle_create_org' ] );
6372
add_action( 'admin_post_workos_sync_roles_to_workos', [ $this, 'handle_sync_roles_to_workos' ] );
73+
add_action( 'admin_post_workos_check_role_sync', [ $this, 'handle_check_role_sync' ] );
6474
add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_assets' ] );
6575
add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_admin_assets' ] );
6676
add_filter( 'plugin_action_links_' . WORKOS_BASENAME, [ $this, 'action_links' ] );
@@ -334,7 +344,7 @@ private function enqueue_role_mapping_assets(): void {
334344
$asset['version']
335345
);
336346

337-
$wp_roles = \WorkOS\Sync\RoleMapper::get_wp_roles();
347+
$wp_roles = RoleMapper::get_wp_roles();
338348
$workos_roles = $this->get_workos_org_roles();
339349
wp_add_inline_script(
340350
'workos-role-mapping',
@@ -377,7 +387,7 @@ private function enqueue_redirect_urls_assets(): void {
377387
$asset['version']
378388
);
379389

380-
$wp_roles = \WorkOS\Sync\RoleMapper::get_wp_roles();
390+
$wp_roles = RoleMapper::get_wp_roles();
381391
$all_roles = array_merge(
382392
[ '__default__' => __( 'Default (all roles)', 'integration-workos' ) ],
383393
$wp_roles
@@ -422,7 +432,7 @@ private function enqueue_logout_redirect_urls_assets(): void {
422432
$asset['version']
423433
);
424434

425-
$wp_roles = \WorkOS\Sync\RoleMapper::get_wp_roles();
435+
$wp_roles = RoleMapper::get_wp_roles();
426436
$all_roles = array_merge(
427437
[ '__default__' => __( 'Default (all roles)', 'integration-workos' ) ],
428438
$wp_roles
@@ -1256,7 +1266,7 @@ public function handle_sync_roles_to_workos(): void {
12561266
admin_url( 'admin.php' )
12571267
);
12581268

1259-
$counts = \WorkOS\Sync\RoleMapper::push_all_roles_to_workos();
1269+
$counts = RoleMapper::push_all_roles_to_workos();
12601270

12611271
if ( $counts['synced'] > 0 || ( 0 === $counts['failed'] ) ) {
12621272
add_settings_error(
@@ -1732,34 +1742,13 @@ private function get_workos_org_roles(): array {
17321742
* Render the role mapping table.
17331743
*/
17341744
public function render_role_map(): void {
1735-
$map = \WorkOS\Sync\RoleMapper::get_role_map();
1736-
$wp_roles = \WorkOS\Sync\RoleMapper::get_wp_roles();
1745+
$map = RoleMapper::get_role_map();
1746+
$wp_roles = RoleMapper::get_wp_roles();
17371747
$workos_roles = $this->get_workos_org_roles();
17381748
$env = $this->get_editing_environment();
17391749
$has_dropdown = ! empty( $workos_roles );
17401750

1741-
// Out-of-sync warning banner.
1742-
$out_of_sync = \WorkOS\Sync\RoleMapper::get_out_of_sync_users();
1743-
if ( ! empty( $out_of_sync ) ) {
1744-
$count = count( $out_of_sync );
1745-
printf(
1746-
'<div class="notice notice-warning inline" id="workos-role-sync-warning"><p>%s <a href="%s">%s</a></p></div>',
1747-
esc_html(
1748-
sprintf(
1749-
/* translators: %d: number of out-of-sync users */
1750-
_n(
1751-
'%d user has a WordPress role that doesn\'t match their WorkOS role mapping.',
1752-
'%d users have a WordPress role that doesn\'t match their WorkOS role mapping.',
1753-
$count,
1754-
'integration-workos'
1755-
),
1756-
$count
1757-
)
1758-
),
1759-
esc_url( admin_url( 'users.php?workos_sync_status=out_of_sync' ) ),
1760-
esc_html__( 'View affected users', 'integration-workos' )
1761-
);
1762-
}
1751+
$this->render_role_sync_status();
17631752

17641753
echo '<table id="workos-role-map-table" class="widefat"><thead><tr>';
17651754
echo '<th>' . esc_html__( 'WorkOS Role', 'integration-workos' ) . '</th>';
@@ -1859,14 +1848,159 @@ public function render_role_map(): void {
18591848
echo '<p class="description" style="margin-top:8px">' . esc_html__( 'The "member" role is used as the default fallback.', 'integration-workos' ) . '</p>';
18601849
}
18611850

1851+
/**
1852+
* Last role sync check: what it found and when, a "Check now" button, and the schedule
1853+
* when Action Scheduler is available. Shows the stored result; nothing is computed here.
1854+
*/
1855+
private function render_role_sync_status(): void {
1856+
if ( ! workos()->is_enabled() || empty( Config::get_organization_id() ) ) {
1857+
return;
1858+
}
1859+
1860+
$result = RoleSyncCheck::result();
1861+
$running = RoleSyncCheck::is_running();
1862+
1863+
if ( $running ) {
1864+
$message = __( 'Role sync check is running in the background. Reload to see the result.', 'integration-workos' );
1865+
$class = 'notice-info';
1866+
} elseif ( ! $result ) {
1867+
$message = __( 'Roles haven\'t been checked against this role map yet.', 'integration-workos' );
1868+
$class = 'notice-info';
1869+
} elseif ( $result['count'] > 0 ) {
1870+
$message = sprintf(
1871+
/* translators: 1: number of out-of-sync users, 2: number of members checked, 3: time since the check */
1872+
_n(
1873+
'%1$s of %2$s members has a WordPress role that doesn\'t match their WorkOS role mapping. Checked %3$s ago.',
1874+
'%1$s of %2$s members have a WordPress role that doesn\'t match their WorkOS role mapping. Checked %3$s ago.',
1875+
$result['count'],
1876+
'integration-workos'
1877+
),
1878+
number_format_i18n( $result['count'] ),
1879+
number_format_i18n( $result['members'] ),
1880+
human_time_diff( $result['checked_at'] )
1881+
);
1882+
$class = 'notice-warning';
1883+
} else {
1884+
$message = sprintf(
1885+
/* translators: 1: number of members checked, 2: time since the check */
1886+
__( 'All %1$s members match the role mapping. Checked %2$s ago.', 'integration-workos' ),
1887+
number_format_i18n( $result['members'] ),
1888+
human_time_diff( $result['checked_at'] )
1889+
);
1890+
$class = 'notice-success';
1891+
}
1892+
1893+
printf( '<div class="notice %s inline" id="workos-role-sync-status"><p>%s ', esc_attr( $class ), esc_html( $message ) );
1894+
if ( $result && $result['count'] > 0 ) {
1895+
printf(
1896+
'<a href="%s">%s</a> ',
1897+
esc_url( admin_url( 'users.php?workos_sync_status=out_of_sync' ) ),
1898+
esc_html__( 'View affected users', 'integration-workos' )
1899+
);
1900+
}
1901+
if ( ! $running ) {
1902+
printf(
1903+
'<button type="submit" form="workos-role-check-form" class="button button-small">%s</button>',
1904+
esc_html__( 'Check now', 'integration-workos' )
1905+
);
1906+
if ( ! $this->role_check_form_queued ) {
1907+
add_action( 'admin_footer', [ $this, 'render_role_check_form' ] );
1908+
$this->role_check_form_queued = true;
1909+
}
1910+
}
1911+
echo '</p></div>';
1912+
1913+
if ( RoleSyncCheck::has_action_scheduler() ) {
1914+
$env = $this->get_editing_environment();
1915+
$schedule = (string) ( $this->get_editing_env_options()->all()['role_sync_schedule'] ?? 'off' );
1916+
$labels = [
1917+
'off' => __( 'Only when I click "Check now"', 'integration-workos' ),
1918+
'hourly' => __( 'Every hour', 'integration-workos' ),
1919+
'daily' => __( 'Every day', 'integration-workos' ),
1920+
];
1921+
echo '<p><label>' . esc_html__( 'Check roles automatically:', 'integration-workos' ) . ' ';
1922+
printf( '<select name="workos_%s[role_sync_schedule]">', esc_attr( $env ) );
1923+
foreach ( $labels as $value => $label ) {
1924+
printf( '<option value="%s" %s>%s</option>', esc_attr( $value ), selected( $schedule, $value, false ), esc_html( $label ) );
1925+
}
1926+
echo '</select></label> <span class="description">' . esc_html__( 'Runs in the background with Action Scheduler.', 'integration-workos' ) . '</span></p>';
1927+
}
1928+
}
1929+
1930+
/**
1931+
* Hidden form behind "Check now" (deferred to admin_footer so it isn't nested
1932+
* inside the settings form).
1933+
*/
1934+
public function render_role_check_form(): void {
1935+
?>
1936+
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" id="workos-role-check-form" style="display:none">
1937+
<?php wp_nonce_field( 'workos_check_role_sync' ); ?>
1938+
<input type="hidden" name="action" value="workos_check_role_sync" />
1939+
<input type="hidden" name="editing_env" value="<?php echo esc_attr( $this->get_editing_environment() ); ?>" />
1940+
</form>
1941+
<?php
1942+
}
1943+
1944+
/**
1945+
* "Check now" (admin_post action): run the role sync check, in the background when
1946+
* Action Scheduler is available.
1947+
*/
1948+
public function handle_check_role_sync(): void {
1949+
if ( ! current_user_can( 'manage_options' ) ) {
1950+
wp_die( esc_html__( 'You do not have permission to do this.', 'integration-workos' ) );
1951+
}
1952+
1953+
check_admin_referer( 'workos_check_role_sync' );
1954+
1955+
$env = SuperGlobals::get_post_var( 'editing_env' ) ?? Config::get_active_environment();
1956+
if ( ! in_array( $env, [ 'production', 'staging' ], true ) ) {
1957+
$env = Config::get_active_environment();
1958+
}
1959+
1960+
$outcome = RoleSyncCheck::start();
1961+
$result = RoleSyncCheck::result();
1962+
1963+
if ( 'queued' === $outcome ) {
1964+
add_settings_error( 'workos_messages', 'workos_role_check', __( 'Role sync check started in the background.', 'integration-workos' ), 'info' );
1965+
} elseif ( 'done' === $outcome && $result ) {
1966+
add_settings_error(
1967+
'workos_messages',
1968+
'workos_role_check',
1969+
sprintf(
1970+
/* translators: 1: members checked, 2: out-of-sync users */
1971+
__( 'Role sync check finished: %1$s members checked, %2$s out of sync.', 'integration-workos' ),
1972+
number_format_i18n( $result['members'] ),
1973+
number_format_i18n( $result['count'] )
1974+
),
1975+
$result['count'] > 0 ? 'warning' : 'success'
1976+
);
1977+
} else {
1978+
add_settings_error( 'workos_messages', 'workos_role_check', __( 'No organization is configured, so there is nothing to check.', 'integration-workos' ), 'error' );
1979+
}
1980+
1981+
set_transient( 'settings_errors', get_settings_errors(), 30 );
1982+
wp_safe_redirect(
1983+
add_query_arg(
1984+
[
1985+
'page' => 'workos',
1986+
'tab' => 'users',
1987+
'env' => $env,
1988+
'settings-updated' => 'true',
1989+
],
1990+
admin_url( 'admin.php' )
1991+
)
1992+
);
1993+
exit;
1994+
}
1995+
18621996
/**
18631997
* Render the redirect URLs table for the Users tab.
18641998
*/
18651999
public function render_redirect_urls(): void {
18662000
$env = $this->get_editing_environment();
18672001
$options = $this->get_editing_env_options()->all();
18682002
$raw_map = is_array( $options['redirect_urls'] ?? null ) ? $options['redirect_urls'] : [];
1869-
$wp_roles = \WorkOS\Sync\RoleMapper::get_wp_roles();
2003+
$wp_roles = RoleMapper::get_wp_roles();
18702004

18712005
// Prepend the default fallback option.
18722006
$all_roles = array_merge(
@@ -1986,7 +2120,7 @@ public function render_logout_redirect_urls(): void {
19862120
$env = $this->get_editing_environment();
19872121
$options = $this->get_editing_env_options()->all();
19882122
$raw_map = is_array( $options['logout_redirect_urls'] ?? null ) ? $options['logout_redirect_urls'] : [];
1989-
$wp_roles = \WorkOS\Sync\RoleMapper::get_wp_roles();
2123+
$wp_roles = RoleMapper::get_wp_roles();
19902124

19912125
// Prepend the default fallback option.
19922126
$all_roles = array_merge(
@@ -2193,6 +2327,7 @@ public function sanitize_environment_options( $input ): array {
21932327
$choices = [
21942328
'login_mode' => [ 'custom', 'redirect', 'headless' ],
21952329
'deprovision_action' => [ 'deactivate', 'demote', 'delete' ],
2330+
'role_sync_schedule' => array_keys( RoleSyncCheck::SCHEDULES ),
21962331
];
21972332
foreach ( $choices as $key => $allowed ) {
21982333
if ( isset( $input[ $key ] ) && in_array( $input[ $key ], $allowed, true ) ) {

0 commit comments

Comments
 (0)