Capabilities.php
335 lines
| 1 | <?php |
| 2 | declare( strict_types = 1 ); |
| 3 | |
| 4 | namespace Automattic\WooCommerce\Internal\POS; |
| 5 | |
| 6 | defined( 'ABSPATH' ) || exit; |
| 7 | |
| 8 | use Automattic\WooCommerce\Internal\Utilities\Users; |
| 9 | use WP_User; |
| 10 | |
| 11 | /** |
| 12 | * POS capability model. |
| 13 | * |
| 14 | * POS access is defined entirely by `woocommerce_pos_*` capabilities granted per-user — the |
| 15 | * same primitive WordPress uses for every other authorization decision. A user |
| 16 | * has POS access if and only if they hold at least one of the known `woocommerce_pos_*` |
| 17 | * capabilities (those in all_pos_capabilities(); see has_pos_access()). |
| 18 | * |
| 19 | * Capabilities are granted per-user via add_cap(), never bundled onto a WP role. |
| 20 | * POS access can therefore be added to any existing user (shop_manager, |
| 21 | * administrator, …) without altering their role, and revoked without leaving |
| 22 | * them roleless. |
| 23 | * |
| 24 | * The preset layer maps each preset (see POSPreset) to a bundle of |
| 25 | * `woocommerce_pos_*` caps and assigns or clears them per user via |
| 26 | * set_pos_preset(). The assigned preset is recorded in `woocommerce_pos_preset` |
| 27 | * user meta for the UI; the caps remain the authorization signal, so a stray |
| 28 | * meta value alone grants nothing. |
| 29 | * |
| 30 | * @since 11.0.0 |
| 31 | * @internal |
| 32 | */ |
| 33 | class Capabilities { |
| 34 | |
| 35 | /** |
| 36 | * Default WP role for brand-new POS-only accounts. |
| 37 | * |
| 38 | * POS access is keyed on `woocommerce_pos_*` capabilities, not this role (see |
| 39 | * has_pos_access()), so new POS-only accounts use the stock `subscriber` role. |
| 40 | * A dedicated `pos_staff` role is planned for a later iteration. |
| 41 | */ |
| 42 | public const DEFAULT_STAFF_ROLE = 'subscriber'; |
| 43 | |
| 44 | /** |
| 45 | * POS capability identifiers. |
| 46 | * |
| 47 | * Real WP capabilities, granted per-user via add_cap() when POS access is |
| 48 | * assigned. They surface in current_user_can() and the standard /wp/v2/users |
| 49 | * response — no shadow permission store. All share the `woocommerce_pos_` |
| 50 | * prefix to stay isolated from core and third-party caps; what each one grants |
| 51 | * is described inline below. |
| 52 | */ |
| 53 | // Ring up and complete a sale at checkout. |
| 54 | public const CAP_PROCESS_SALES = 'woocommerce_pos_process_sales'; |
| 55 | // Look up and view existing orders. |
| 56 | public const CAP_VIEW_ORDERS = 'woocommerce_pos_view_orders'; |
| 57 | // Apply an existing coupon to a cart. |
| 58 | public const CAP_APPLY_COUPONS = 'woocommerce_pos_apply_coupons'; |
| 59 | // Create a new coupon during a sale. |
| 60 | public const CAP_CREATE_COUPONS = 'woocommerce_pos_create_coupons'; |
| 61 | // Refund a paid order. |
| 62 | public const CAP_ISSUE_REFUNDS = 'woocommerce_pos_issue_refunds'; |
| 63 | // View POS settings (read-only). |
| 64 | public const CAP_VIEW_SETTINGS = 'woocommerce_pos_view_settings'; |
| 65 | // Change POS settings. |
| 66 | public const CAP_EDIT_SETTINGS = 'woocommerce_pos_edit_settings'; |
| 67 | // Manage POS staff and their access. |
| 68 | public const CAP_MANAGE_STAFF = 'woocommerce_pos_manage_staff'; |
| 69 | // Leave POS mode for the full admin. |
| 70 | public const CAP_EXIT_POS = 'woocommerce_pos_exit'; |
| 71 | |
| 72 | /** |
| 73 | * User meta key recording which preset was assigned to a user. |
| 74 | * |
| 75 | * The value is one of the POSPreset constants. It drives the admin UI, but it |
| 76 | * is not the authorization signal: has_pos_access() reads the `woocommerce_pos_*` caps, not |
| 77 | * this meta. Stored per-site via Users::*_site_user_meta() (which suffixes the blog prefix) |
| 78 | * so it stays aligned with the blog-scoped capabilities on multisite. |
| 79 | */ |
| 80 | public const POS_PRESET_META_KEY = 'woocommerce_pos_preset'; |
| 81 | |
| 82 | /** |
| 83 | * All known POS capability identifiers. |
| 84 | * |
| 85 | * The canonical list of `woocommerce_pos_*` caps — used to test for POS access and, by the |
| 86 | * preset layer, to apply or clear a user's caps as a set. |
| 87 | * |
| 88 | * @return string[] |
| 89 | */ |
| 90 | public static function all_pos_capabilities(): array { |
| 91 | return array( |
| 92 | self::CAP_PROCESS_SALES, |
| 93 | self::CAP_VIEW_ORDERS, |
| 94 | self::CAP_APPLY_COUPONS, |
| 95 | self::CAP_CREATE_COUPONS, |
| 96 | self::CAP_ISSUE_REFUNDS, |
| 97 | self::CAP_VIEW_SETTINGS, |
| 98 | self::CAP_EDIT_SETTINGS, |
| 99 | self::CAP_MANAGE_STAFF, |
| 100 | self::CAP_EXIT_POS, |
| 101 | ); |
| 102 | } |
| 103 | |
| 104 | /** |
| 105 | * Whether a user has any POS access at all. |
| 106 | * |
| 107 | * True if the user holds at least one of the known `woocommerce_pos_*` capabilities (those |
| 108 | * in all_pos_capabilities()). This is the single authorization signal for POS |
| 109 | * access: neither a WP role nor any meta value grants it on its own. The |
| 110 | * any-cap definition fits both fixed presets |
| 111 | * (each preset's caps granted as a bundle) and a future granular model |
| 112 | * (individual `woocommerce_pos_*` caps assigned without a baseline cap). |
| 113 | * |
| 114 | * Reads the resolved capability map (WP_User::$allcaps) directly rather than |
| 115 | * looping over user_can(). user_can() re-runs map_meta_cap() and fires the |
| 116 | * user_has_cap filter on every call, so the loop would dispatch that machinery |
| 117 | * once per POS cap; an $allcaps lookup is a plain array check per cap. |
| 118 | * |
| 119 | * Reading $allcaps also scopes access to caps the user actually holds: unlike |
| 120 | * user_can(), it does not honor the multisite super-admin grant, which |
| 121 | * has_cap() applies as a runtime gate rather than storing in $allcaps. A super |
| 122 | * admin therefore does not implicitly count as POS staff — they need an |
| 123 | * explicit `woocommerce_pos_*` cap like anyone else. |
| 124 | * |
| 125 | * @param int $user_id Target user. |
| 126 | * @return bool |
| 127 | * |
| 128 | * @since 11.0.0 |
| 129 | */ |
| 130 | public static function has_pos_access( int $user_id ): bool { |
| 131 | if ( $user_id <= 0 ) { |
| 132 | return false; |
| 133 | } |
| 134 | |
| 135 | $user = get_userdata( $user_id ); |
| 136 | if ( ! $user ) { |
| 137 | return false; |
| 138 | } |
| 139 | |
| 140 | foreach ( self::all_pos_capabilities() as $cap ) { |
| 141 | if ( ! empty( $user->allcaps[ $cap ] ) ) { |
| 142 | return true; |
| 143 | } |
| 144 | } |
| 145 | return false; |
| 146 | } |
| 147 | |
| 148 | /** |
| 149 | * Resolve the assigned POS preset for a user, or null if none is set. |
| 150 | * |
| 151 | * Returns the `woocommerce_pos_preset` meta value only if it matches an |
| 152 | * assignable preset, so a stale or hand-edited value reads as "no preset". |
| 153 | * |
| 154 | * The meta is stored per-site (see set_pos_preset()) so it stays aligned with the |
| 155 | * blog-scoped POS capabilities on multisite. |
| 156 | * |
| 157 | * @param int $user_id Target user. |
| 158 | * @return string|null One of the POSPreset constants, or null. |
| 159 | * |
| 160 | * @since 11.0.0 |
| 161 | */ |
| 162 | public static function get_pos_preset( int $user_id ): ?string { |
| 163 | $meta = Users::get_site_user_meta( $user_id, self::POS_PRESET_META_KEY, true ); |
| 164 | if ( in_array( $meta, POSPreset::get_all(), true ) ) { |
| 165 | return (string) $meta; |
| 166 | } |
| 167 | return null; |
| 168 | } |
| 169 | |
| 170 | /** |
| 171 | * WP_User_Query args selecting candidate POS staff — every user holding any |
| 172 | * `woocommerce_pos_*` capability, via WP_User_Query's capability__in. |
| 173 | * |
| 174 | * Use it to enumerate POS staff — e.g. the GET /wc/pos/v1/staff endpoint and the |
| 175 | * wp-admin Staff list — which refine the candidates for their own needs (the staff |
| 176 | * endpoint also requires a PIN). Keying on caps rather than the preset meta keeps |
| 177 | * this aligned with the authorization signal: a user whose caps were stripped is |
| 178 | * excluded, and a cap granted outside a preset is still included. |
| 179 | * |
| 180 | * This is a candidate query, not exact parity with has_pos_access(): capability__in |
| 181 | * matches the capability *name* wherever it appears in the serialized capabilities |
| 182 | * row, so a user with an explicit denial (add_cap( $cap, false )) is still selected |
| 183 | * even though has_pos_access() — which reads the resolved capabilities — treats them |
| 184 | * as no access. Capabilities only ever grants or strips caps, never denies, so this |
| 185 | * diverges only when external code sets an explicit denial; callers needing exact |
| 186 | * parity should refine results with has_pos_access(). |
| 187 | * |
| 188 | * @return array<string, mixed> |
| 189 | * |
| 190 | * @since 11.0.0 |
| 191 | */ |
| 192 | public static function pos_staff_user_query_args(): array { |
| 193 | return array( |
| 194 | 'capability__in' => self::all_pos_capabilities(), |
| 195 | ); |
| 196 | } |
| 197 | |
| 198 | /** |
| 199 | * Preset metadata: the `woocommerce_pos_*` cap bundle and display label for each preset. |
| 200 | * |
| 201 | * Single source of truth for capabilities_for_preset() and preset_label(), so |
| 202 | * adding or renaming a preset is one edit here (plus the POSPreset constant) |
| 203 | * rather than several parallel switches. |
| 204 | * |
| 205 | * Capability Cashier Manager Admin |
| 206 | * woocommerce_pos_process_sales yes yes yes |
| 207 | * woocommerce_pos_view_orders yes yes yes |
| 208 | * woocommerce_pos_apply_coupons yes yes yes |
| 209 | * woocommerce_pos_create_coupons no yes yes |
| 210 | * woocommerce_pos_issue_refunds no yes yes |
| 211 | * woocommerce_pos_view_settings no yes yes |
| 212 | * woocommerce_pos_edit_settings no no yes |
| 213 | * woocommerce_pos_manage_staff no no yes |
| 214 | * woocommerce_pos_exit no no yes |
| 215 | * |
| 216 | * @return array<string, array{caps: array<string, true>, label: string}> |
| 217 | */ |
| 218 | private static function preset_definitions(): array { |
| 219 | $cashier_caps = array( |
| 220 | self::CAP_PROCESS_SALES => true, |
| 221 | self::CAP_VIEW_ORDERS => true, |
| 222 | self::CAP_APPLY_COUPONS => true, |
| 223 | ); |
| 224 | |
| 225 | $manager_caps = $cashier_caps + array( |
| 226 | self::CAP_CREATE_COUPONS => true, |
| 227 | self::CAP_ISSUE_REFUNDS => true, |
| 228 | self::CAP_VIEW_SETTINGS => true, |
| 229 | ); |
| 230 | |
| 231 | $admin_caps = $manager_caps + array( |
| 232 | self::CAP_EDIT_SETTINGS => true, |
| 233 | self::CAP_MANAGE_STAFF => true, |
| 234 | self::CAP_EXIT_POS => true, |
| 235 | ); |
| 236 | |
| 237 | return array( |
| 238 | POSPreset::CASHIER => array( |
| 239 | 'caps' => $cashier_caps, |
| 240 | 'label' => __( 'POS cashier', 'woocommerce' ), |
| 241 | ), |
| 242 | POSPreset::MANAGER => array( |
| 243 | 'caps' => $manager_caps, |
| 244 | 'label' => __( 'POS manager', 'woocommerce' ), |
| 245 | ), |
| 246 | POSPreset::ADMIN => array( |
| 247 | 'caps' => $admin_caps, |
| 248 | 'label' => __( 'POS admin', 'woocommerce' ), |
| 249 | ), |
| 250 | ); |
| 251 | } |
| 252 | |
| 253 | /** |
| 254 | * The `woocommerce_pos_*` capability bundle for a given preset (see preset_definitions()). |
| 255 | * |
| 256 | * @param string $preset One of the POSPreset constants. |
| 257 | * @return array<string, true> Map of granted cap => true. Empty for unknown presets. |
| 258 | * |
| 259 | * @since 11.0.0 |
| 260 | */ |
| 261 | public static function capabilities_for_preset( string $preset ): array { |
| 262 | $definitions = self::preset_definitions(); |
| 263 | |
| 264 | return isset( $definitions[ $preset ] ) ? $definitions[ $preset ]['caps'] : array(); |
| 265 | } |
| 266 | |
| 267 | /** |
| 268 | * Assign or clear the POS preset for a user. |
| 269 | * |
| 270 | * Touches only caps + meta, never WP roles: granting access to an existing user |
| 271 | * leaves their role intact, and clearing it never leaves them roleless. Every |
| 272 | * `woocommerce_pos_*` cap the user holds directly (via add_cap — the only way this class grants |
| 273 | * them) is stripped first, so a preset change (Manager to Cashier) drops the caps |
| 274 | * the new preset omits, and a clear (null) removes them along with the preset meta. |
| 275 | * Role-granted `woocommerce_pos_*` caps, if any, are out of scope: this class never adds caps |
| 276 | * to roles. |
| 277 | * |
| 278 | * @param int $user_id Target user. |
| 279 | * @param string|null $preset One of POSPreset::get_all(), or null to clear. |
| 280 | * @return bool True on success (including clears); false if the user does not |
| 281 | * exist or the preset value is not assignable. |
| 282 | * |
| 283 | * @since 11.0.0 |
| 284 | */ |
| 285 | public static function set_pos_preset( int $user_id, ?string $preset ): bool { |
| 286 | $user = get_user_by( 'id', $user_id ); |
| 287 | if ( ! $user instanceof WP_User ) { |
| 288 | return false; |
| 289 | } |
| 290 | |
| 291 | // Validate before mutating any state. |
| 292 | if ( null !== $preset && ! in_array( $preset, POSPreset::get_all(), true ) ) { |
| 293 | return false; |
| 294 | } |
| 295 | |
| 296 | // Strip the user's directly-held woocommerce_pos_* caps so a preset change (or clear) |
| 297 | // starts clean. remove_cap() is a no-op for caps the user does not hold and strips a cap |
| 298 | // whether it was granted (true) or explicitly denied (false). This class only grants caps |
| 299 | // per-user via add_cap(), so role-granted caps are out of scope. |
| 300 | foreach ( self::all_pos_capabilities() as $cap ) { |
| 301 | $user->remove_cap( $cap ); |
| 302 | } |
| 303 | |
| 304 | if ( null === $preset ) { |
| 305 | Users::delete_site_user_meta( $user_id, self::POS_PRESET_META_KEY ); |
| 306 | return true; |
| 307 | } |
| 308 | |
| 309 | // Store the preset per-site so the bookkeeping stays aligned with the blog-scoped |
| 310 | // POS capabilities on multisite (Users::update_site_user_meta suffixes the blog prefix, |
| 311 | // so the key still matches the woocommerce_% uninstall sweep). |
| 312 | Users::update_site_user_meta( $user_id, self::POS_PRESET_META_KEY, $preset ); |
| 313 | |
| 314 | foreach ( array_keys( self::capabilities_for_preset( $preset ) ) as $cap ) { |
| 315 | $user->add_cap( $cap ); |
| 316 | } |
| 317 | |
| 318 | return true; |
| 319 | } |
| 320 | |
| 321 | /** |
| 322 | * Translated label for a POS preset. |
| 323 | * |
| 324 | * @param string $preset One of the POSPreset constants. |
| 325 | * @return string Empty string for an unknown preset. |
| 326 | * |
| 327 | * @since 11.0.0 |
| 328 | */ |
| 329 | public static function preset_label( string $preset ): string { |
| 330 | $definitions = self::preset_definitions(); |
| 331 | |
| 332 | return isset( $definitions[ $preset ] ) ? $definitions[ $preset ]['label'] : ''; |
| 333 | } |
| 334 | } |
| 335 |