-
Notifications
You must be signed in to change notification settings - Fork 19
Expand file tree
/
Copy pathSyncCapability.php
More file actions
272 lines (246 loc) · 9.64 KB
/
Copy pathSyncCapability.php
File metadata and controls
272 lines (246 loc) · 9.64 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
<?php
/**
* Class file for WordPress capabilities synced onto roles from an option.
*
* @package Accessibility_Checker
*/
namespace EqualizeDigital\AccessibilityChecker\Capabilities;
/**
* Registers one or more real WordPress capabilities, kept in sync with the
* role list stored in a single plugin option, with a manage_options bypass
* and a version-gated migration for sites that already had the option set
* before the capability existed.
*
* The generic primitive is sync_role_capability(): grant or revoke one
* capability on one role. Everything else (sync() looping roles x
* capabilities, the admin bypass, the option-watching/migration machinery)
* is built on top of that primitive, so a single option can drive a bundle
* of capabilities that always travel together (e.g. ignore-issues,
* ignore-issues-globally, and issues-explorer-access, all granted to
* whichever roles are configured for "can ignore issues").
*
* This class only ever syncs; it does not answer "can the current user do
* X" for the rest of the plugin family - see CapabilityChecker for that.
*/
class SyncCapability {
/**
* The capability strings this instance keeps in sync, e.g.
* [ 'edac_ignore_issues' ] or a multi-capability bundle.
*
* @var string[]
*/
private array $capabilities;
/**
* Name of the option holding the array of role slugs that should have
* these capabilities.
*
* @var string
*/
private string $option_name;
/**
* Role slugs to sync the capabilities onto if the option has never been
* set (used only by the migration, not as a fallback for a set-but-empty
* option).
*
* @var string[]
*/
private array $default_roles;
/**
* Bumped when the default_roles (or the capability list) for this bundle
* change; sites whose stored migration version is lower get re-synced
* once on their next init, even if they already ran an earlier
* version's migration.
*
* @var int
*/
private int $version;
/**
* Constructor.
*
* @param string|string[] $capabilities A single capability string, or an array of capability
* strings that should all be synced together from the
* same option (accepting a single string keeps existing
* single-capability callers working unchanged).
* @param string $option_name Option holding the array of role slugs allowed these capabilities.
* @param array $default_roles Roles to grant on first-ever sync (site had the option unset).
* @param int $version Bump to re-run the migration when default_roles/capabilities change.
*
* @throws \InvalidArgumentException If $capabilities is an empty array - there is nothing for this
* instance to sync/check, and user_can()'s no-argument default
* would otherwise silently check an undefined (null) capability.
*/
public function __construct( $capabilities, string $option_name, array $default_roles = [], int $version = 1 ) {
$this->capabilities = is_array( $capabilities ) ? array_values( $capabilities ) : [ $capabilities ];
if ( [] === $this->capabilities ) {
throw new \InvalidArgumentException( 'SyncCapability requires at least one capability.' );
}
$this->option_name = $option_name;
$this->default_roles = $default_roles;
$this->version = $version;
}
/**
* Wire up the bypass filter, live sync on option save, and the
* version-gated migration. Call once, typically from plugin bootstrap.
*
* @return void
*/
public function register(): void {
add_filter( 'map_meta_cap', [ $this, 'bypass_for_admins' ], 10, 3 );
add_action(
"add_option_{$this->option_name}",
function ( $option, $value ) {
$this->sync( $value );
},
10,
2
);
add_action(
"update_option_{$this->option_name}",
function ( $old_value, $value ) {
$this->sync( $value );
},
10,
2
);
// Whatever deleted the option (typically an uninstall routine, gated
// behind the "delete data" preference) intends for the roles it
// granted to lose these capabilities too - without this, sync()
// would only ever run again on the next add_option/update_option,
// leaving the capabilities stuck on whichever roles had them at
// deletion time indefinitely.
add_action(
"delete_option_{$this->option_name}",
function () {
$this->sync( [] );
}
);
// init, not admin_init: admin_menu (where menu capability checks happen)
// and rest_api_init (where REST permission_callbacks are registered) both
// fire before admin_init on their respective request types, so migrating
// on admin_init would leave the very first request after a version bump
// building a menu, or serving a REST request, against pre-migration
// capabilities. init fires early enough on every request type - admin,
// front-end, REST, and cron alike - to have already run by the time any
// of those capability checks happen.
add_action( 'init', [ $this, 'maybe_migrate' ] );
}
/**
* Whether the current user has one of this instance's capabilities.
* Defaults to the first (or only) capability in the bundle so existing
* single-capability callers can keep calling user_can() with no argument.
*
* @param string|null $capability Which capability to check; defaults to the first in the bundle.
* @return bool
*/
public function user_can( ?string $capability = null ): bool {
// phpcs:ignore WordPress.WP.Capabilities.Unknown -- Custom capability, synced by this class.
return current_user_can( $capability ?? $this->capabilities[0] );
}
/**
* A REST route permission_callback closure for one of this instance's
* capabilities, so routes can pass this directly instead of wrapping
* current_user_can() in their own inline closure.
*
* @param string|null $capability Which capability to check; defaults to the first in the bundle.
* @return callable
*/
public function permission_callback( ?string $capability = null ): callable {
return function () use ( $capability ) {
return $this->user_can( $capability );
};
}
/**
* Map_meta_cap callback: manage_options users always pass a check
* against any capability in this bundle, regardless of role sync.
*
* @param array $caps Required primitive capabilities.
* @param string $cap Capability being checked.
* @param int $user_id User ID.
* @return array
*/
public function bypass_for_admins( $caps, $cap, $user_id ) {
if ( in_array( $cap, $this->capabilities, true ) && user_can( $user_id, 'manage_options' ) ) {
return [];
}
return $caps;
}
/**
* Add or remove one capability on one role. The generic primitive
* sync() is built on. Deliberately private: calling it directly for a
* single capability out of a multi-capability bundle would grant/revoke
* that one capability while leaving the rest of the bundle untouched
* for that role, breaking the "these capabilities always travel
* together" guarantee this class exists to provide. Always go through
* sync() (or the option it's wired to) so every capability in the
* bundle stays in lockstep.
*
* @param string $role_slug Role slug, e.g. 'editor'.
* @param string $capability Capability string.
* @param bool $should_have Whether the role should have this capability.
* @return void
*/
private function sync_role_capability( string $role_slug, string $capability, bool $should_have ): void {
$role = wp_roles()->get_role( $role_slug );
if ( ! $role ) {
return;
}
if ( $should_have ) {
$role->add_cap( $capability );
} else {
$role->remove_cap( $capability );
}
}
/**
* Add or remove every capability in this bundle on every role so each
* capability matches exactly the role list passed in.
*
* @param mixed $roles Role slugs that should have the capabilities.
* @return void
*/
public function sync( $roles ): void {
$roles = is_array( $roles ) ? $roles : [];
foreach ( array_keys( wp_roles()->role_objects ) as $role_slug ) {
$should_have = in_array( $role_slug, $roles, true );
foreach ( $this->capabilities as $capability ) {
$this->sync_role_capability( $role_slug, $capability, $should_have );
}
}
}
/**
* Name of the option this bundle's migration-version marker is stored
* under. Includes a hash of the capability list, not just option_name,
* so two different SyncCapability instances that happen to point at the
* same option (e.g. a future feature layered onto an existing option)
* can never collide on one shared version counter and silently skip
* each other's migration.
*
* @return string
*/
private function version_option_name(): string {
$capabilities = $this->capabilities;
sort( $capabilities );
return 'edac_capability_version_' . $this->option_name . '_' . md5( implode( '|', $capabilities ) );
}
/**
* Run the sync once per migration version. Covers two cases: a site
* that already had option_name set before this bundle existed (needs an
* initial sync), and a site whose stored version predates a
* default_roles/capabilities change (needs a re-sync even though it
* already ran an earlier version's migration).
*
* Versioned per (option, capability set) pair, not per capability,
* since every capability in the bundle is always granted together and
* shares one migration.
*
* @return void
*/
public function maybe_migrate(): void {
$version_option = $this->version_option_name();
$stored_version = (int) get_option( $version_option, 0 );
if ( $stored_version >= $this->version ) {
return;
}
$this->sync( get_option( $this->option_name, $this->default_roles ) );
update_option( $version_option, $this->version );
}
}