Skip to content

Commit 3844a0a

Browse files
pattonwebzclaude
andcommitted
Revoke synced capabilities when their backing option is deleted
register() only hooked add_option/update_option, so deleting the option (e.g. Pro's uninstall routine, gated behind the "delete data" preference) left every role that had been granted the bundle stuck with it indefinitely - the old array_intersect()-against-get_option() check re-read the option live on every request, so this is a real behavior change from before: deleting the option used to revoke access instantly for everyone but admins, and now it wouldn't at all. Hooking delete_option_{$option_name} to sync([]) closes that gap. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent c96102f commit 3844a0a

2 files changed

Lines changed: 36 additions & 0 deletions

File tree

includes/classes/Capabilities/SyncCapability.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,18 @@ function ( $old_value, $value ) {
113113
10,
114114
2
115115
);
116+
// Whatever deleted the option (typically an uninstall routine, gated
117+
// behind the "delete data" preference) intends for the roles it
118+
// granted to lose these capabilities too - without this, sync()
119+
// would only ever run again on the next add_option/update_option,
120+
// leaving the capabilities stuck on whichever roles had them at
121+
// deletion time indefinitely.
122+
add_action(
123+
"delete_option_{$this->option_name}",
124+
function () {
125+
$this->sync( [] );
126+
}
127+
);
116128

117129
// init, not admin_init: admin_menu (where menu capability checks happen)
118130
// and rest_api_init (where REST permission_callbacks are registered) both

tests/phpunit/includes/classes/Capabilities/SyncCapabilityTest.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,30 @@ public function test_register_syncs_on_option_save() {
9999
$this->assertTrue( wp_roles()->get_role( 'editor' )->has_cap( self::TEST_CAP ) );
100100
}
101101

102+
/**
103+
* Deleting the option (e.g. an uninstall routine's opt-in data cleanup)
104+
* should revoke the capability from every role it was synced onto - the
105+
* option's own removal is the strongest possible signal that a stale
106+
* grant shouldn't be left behind, and there is no other hook left that
107+
* would ever catch this since add_option/update_option only fire again
108+
* on a future save.
109+
*
110+
* @return void
111+
*/
112+
public function test_deleting_option_revokes_capability_from_all_roles() {
113+
$capability = new SyncCapability( self::TEST_CAP, self::TEST_OPTION );
114+
$capability->register();
115+
116+
add_option( self::TEST_OPTION, [ 'editor', 'author' ] );
117+
$this->assertTrue( wp_roles()->get_role( 'editor' )->has_cap( self::TEST_CAP ) );
118+
$this->assertTrue( wp_roles()->get_role( 'author' )->has_cap( self::TEST_CAP ) );
119+
120+
delete_option( self::TEST_OPTION );
121+
122+
$this->assertFalse( wp_roles()->get_role( 'editor' )->has_cap( self::TEST_CAP ) );
123+
$this->assertFalse( wp_roles()->get_role( 'author' )->has_cap( self::TEST_CAP ) );
124+
}
125+
102126
/**
103127
* Manage_options users must always pass user_can(), regardless of
104128
* whether their role was synced.

0 commit comments

Comments
 (0)