Skip to content

Commit e8dbd46

Browse files
committed
Abilities API: Add execution lifecycle filters to WP_Ability methods
Introduce four filters that give plugins hook points across the ability execution lifecycle, complementing the existing observation-only actions (`wp_before_execute_ability`, `wp_after_execute_ability`): - `wp_pre_execute_ability`: short-circuits `execute()` when it returns a value other than the supplied default. - `wp_ability_normalize_input`: transforms input inside `normalize_input()`, and returning `WP_Error` halts execution. - `wp_ability_permission_result`: overrides the `permission_callback` result inside `check_permissions()`, consistently for `execute()` and direct callers. - `wp_ability_execute_result`: transforms the result inside `do_execute()` before output validation, and can recover from execute callback failures. The input and result filters fire before their respective schema validation steps, so `validate_input()` and `validate_output()` remain the final integrity gates. Only `wp_pre_execute_ability` can bypass validation, with the caller owning the returned value's shape. Add `WP_Filter_Sentinel`, a reusable marker class loaded alongside `WP_Hook`, whose per-instance identity lets a filter default be distinguished from any user value — including `null`, `false`, or arbitrary objects — via `===`. Update `WP_REST_Abilities_V1_Run_Controller::check_ability_permissions()` to propagate `WP_Error` results from `normalize_input()` directly, defaulting to status 400 while preserving filter-set statuses (e.g. 422, 429). Props gziolo, westonruter, migueluy. Fixes #64989. git-svn-id: https://develop.svn.wordpress.org/trunk@62397 602fd350-edb4-49c9-b593-d223f7449a82
1 parent bae08b2 commit e8dbd46

6 files changed

Lines changed: 788 additions & 15 deletions

File tree

src/wp-includes/abilities-api/class-wp-ability.php

Lines changed: 118 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -436,22 +436,41 @@ public function get_meta_item( string $key, $default_value = null ) {
436436
* the value of that key. If the input schema does not define a `default`, or if the input schema is empty,
437437
* this method returns null. If input is provided, it is returned as-is.
438438
*
439+
* The {@see 'wp_ability_normalize_input'} filter fires after the built-in default-value handling,
440+
* allowing plugins to transform the result.
441+
*
439442
* @since 6.9.0
443+
* @since 7.1.0 Added the `wp_ability_normalize_input` filter.
440444
*
441445
* @param mixed $input Optional. The raw input provided for the ability. Default `null`.
442-
* @return mixed The same input, or the default from schema, or `null` if default not set.
446+
* @return mixed The normalized input, or a `WP_Error` if a filter returned one.
443447
*/
444448
public function normalize_input( $input = null ) {
445-
if ( null !== $input ) {
446-
return $input;
447-
}
448-
449-
$input_schema = $this->get_input_schema();
450-
if ( ! empty( $input_schema ) && array_key_exists( 'default', $input_schema ) ) {
451-
return $input_schema['default'];
449+
if ( null === $input ) {
450+
$input_schema = $this->get_input_schema();
451+
if ( array_key_exists( 'default', $input_schema ) ) {
452+
$input = $input_schema['default'];
453+
}
452454
}
453455

454-
return null;
456+
/**
457+
* Filters the normalized input for an ability.
458+
*
459+
* Fires after `normalize_input()` has applied any default value declared in the input schema,
460+
* giving plugins a chance to adjust the input before it is consumed downstream. Common uses
461+
* include defaulting beyond what JSON Schema can express, prompt enrichment, and injecting
462+
* caller metadata.
463+
*
464+
* Returning a `WP_Error` causes callers that propagate it (such as `execute()`) to halt
465+
* before validation, permission checks, and the registered execute callback.
466+
*
467+
* @since 7.1.0
468+
*
469+
* @param mixed $input The normalized input data.
470+
* @param string $ability_name The name of the ability.
471+
* @param WP_Ability $ability The ability instance.
472+
*/
473+
return apply_filters( 'wp_ability_normalize_input', $input, $this->name, $this );
455474
}
456475

457476
/**
@@ -531,7 +550,11 @@ protected function invoke_callback( callable $callback, $input = null ) {
531550
* Please note that input is not automatically validated against the input schema.
532551
* Use `validate_input()` method to validate input before calling this method if needed.
533552
*
553+
* The {@see 'wp_ability_permission_result'} filter fires after the registered
554+
* `permission_callback` returns, allowing plugins to override the result.
555+
*
534556
* @since 6.9.0
557+
* @since 7.1.0 Added the `wp_ability_permission_result` filter.
535558
*
536559
* @see validate_input()
537560
*
@@ -547,27 +570,76 @@ public function check_permissions( $input = null ) {
547570
);
548571
}
549572

550-
return $this->invoke_callback( $this->permission_callback, $input );
573+
$permission = $this->invoke_callback( $this->permission_callback, $input );
574+
575+
/**
576+
* Filters the result of an ability's permission check.
577+
*
578+
* Fires after the registered `permission_callback` returns. Plugins can use this to layer
579+
* additional authorization rules on top of the ability's own permission logic — for example,
580+
* multi-factor authorization gates or temporary permission elevation for trusted contexts.
581+
*
582+
* Filters can return `true` to grant, `false` to deny, or a `WP_Error` to deny with a specific
583+
* error code and message. The filter receives whatever the `permission_callback` produced.
584+
* Any other return value is coerced to `false`.
585+
*
586+
* @since 7.1.0
587+
*
588+
* @param bool|WP_Error $permission The permission result returned by `permission_callback`.
589+
* @param string $ability_name The name of the ability.
590+
* @param mixed $input The input data for the permission check.
591+
* @param WP_Ability $ability The ability instance.
592+
*/
593+
$result = apply_filters( 'wp_ability_permission_result', $permission, $this->name, $input, $this );
594+
if ( ! is_bool( $result ) && ! is_wp_error( $result ) ) {
595+
$result = false;
596+
}
597+
return $result;
551598
}
552599

553600
/**
554601
* Executes the ability callback.
555602
*
603+
* The {@see 'wp_ability_execute_result'} filter fires before this method returns, allowing
604+
* plugins to transform the result produced by the registered `execute_callback`.
605+
*
556606
* @since 6.9.0
607+
* @since 7.1.0 Added the `wp_ability_execute_result` filter.
557608
*
558609
* @param mixed $input Optional. The input data for the ability. Default `null`.
559610
* @return mixed|WP_Error The result of the ability execution, or WP_Error on failure.
560611
*/
561612
protected function do_execute( $input = null ) {
562613
if ( ! is_callable( $this->execute_callback ) ) {
563-
return new WP_Error(
614+
$result = new WP_Error(
564615
'ability_invalid_execute_callback',
565616
/* translators: %s ability name. */
566617
sprintf( __( 'Ability "%s" does not have a valid execute callback.' ), esc_html( $this->name ) )
567618
);
619+
} else {
620+
$result = $this->invoke_callback( $this->execute_callback, $input );
568621
}
569622

570-
return $this->invoke_callback( $this->execute_callback, $input );
623+
/**
624+
* Filters the result returned by an ability's execute callback.
625+
*
626+
* Fires after the registered execute callback runs. Plugins can use this to transform the
627+
* result — response formatting, stripping internal metadata, content safety filtering,
628+
* response enrichment, or recovering from a failure by returning a successful value.
629+
*
630+
* The filter receives whatever the registered callback produced, including a `WP_Error`
631+
* if execution failed. Filters may pass the `WP_Error` through unchanged, override it with
632+
* a recovered result, or convert a successful result into a `WP_Error`.
633+
*
634+
* @since 7.1.0
635+
*
636+
* @param mixed $result The result returned by the registered `execute_callback`,
637+
* or a `WP_Error` if execution failed.
638+
* @param string $ability_name The name of the ability.
639+
* @param mixed $input The normalized input data.
640+
* @param WP_Ability $ability The ability instance.
641+
*/
642+
return apply_filters( 'wp_ability_execute_result', $result, $this->name, $input, $this );
571643
}
572644

573645
/**
@@ -605,12 +677,45 @@ protected function validate_output( $output ) {
605677
* Before returning the return value, it also validates the output.
606678
*
607679
* @since 6.9.0
680+
* @since 7.1.0 Added the `wp_pre_execute_ability` filter.
608681
*
609682
* @param mixed $input Optional. The input data for the ability. Default `null`.
610683
* @return mixed|WP_Error The result of the ability execution, or WP_Error on failure.
611684
*/
612685
public function execute( $input = null ) {
613-
$input = $this->normalize_input( $input );
686+
/**
687+
* Filters whether to short-circuit ability execution.
688+
*
689+
* Returning a value other than the received default bypasses the rest of `execute()` —
690+
* input normalization, input validation, permission checks, the registered execute callback,
691+
* output validation, and the surrounding actions — and the value is returned to the caller
692+
* as-is. Useful for cached responses, rate limiting, maintenance mode, and test mocking.
693+
*
694+
* To continue with normal execution, return `$pre` unchanged. This preserves any value
695+
* (including `null`, `false`, or arbitrary objects) as a valid short-circuit result.
696+
*
697+
* Because validation is bypassed, callers that short-circuit are responsible for the
698+
* integrity of any value they consume from `$input`.
699+
*
700+
* @since 7.1.0
701+
*
702+
* @param mixed $pre The pre-computed result. Return this value unchanged to continue execution.
703+
* Default `WP_Filter_Sentinel` instance unique to this invocation.
704+
* @param string $ability_name The name of the ability.
705+
* @param mixed $input The raw input passed to `execute()`.
706+
* @param WP_Ability $ability The ability instance.
707+
*/
708+
$pre_execute_sentinel = new WP_Filter_Sentinel();
709+
$pre = apply_filters( 'wp_pre_execute_ability', $pre_execute_sentinel, $this->name, $input, $this );
710+
if ( $pre !== $pre_execute_sentinel ) {
711+
return $pre;
712+
}
713+
714+
$input = $this->normalize_input( $input );
715+
if ( is_wp_error( $input ) ) {
716+
return $input;
717+
}
718+
614719
$is_valid = $this->validate_input( $input );
615720
if ( is_wp_error( $is_valid ) ) {
616721
return $is_valid;
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
/**
3+
* Filter Sentinel API.
4+
*
5+
* @package WordPress
6+
* @since 7.1.0
7+
*/
8+
9+
declare( strict_types = 1 );
10+
11+
/**
12+
* Marker object used as a filter's default value when any user value — including
13+
* `null`, `false`, or arbitrary objects — must remain distinguishable from the
14+
* "no filter modified this" case.
15+
*
16+
* Each instance is unique by identity. Compare returned values with `===` against
17+
* the original sentinel to detect that no filter callback replaced it.
18+
*
19+
* Filter callbacks that want to pass through without modifying the value should
20+
* return the received value unchanged. Returning a freshly constructed
21+
* `WP_Filter_Sentinel` is treated as a replacement, not as pass-through.
22+
*
23+
* @since 7.1.0
24+
*/
25+
final class WP_Filter_Sentinel {}

src/wp-includes/plugin.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323

2424
// Initialize the filter globals.
2525
require __DIR__ . '/class-wp-hook.php';
26+
require __DIR__ . '/class-wp-filter-sentinel.php';
2627

2728
/** @var WP_Hook[] $wp_filter */
2829
global $wp_filter;

src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,8 +158,17 @@ public function check_ability_permissions( $request ) {
158158
return $is_valid;
159159
}
160160

161-
$input = $this->get_input_from_request( $request );
162-
$input = $ability->normalize_input( $input );
161+
$input = $this->get_input_from_request( $request );
162+
$input = $ability->normalize_input( $input );
163+
if ( is_wp_error( $input ) ) {
164+
$error_data = $input->get_error_data();
165+
if ( ! is_array( $error_data ) || ! isset( $error_data['status'] ) ) {
166+
$input->add_data( array( 'status' => 400 ) );
167+
}
168+
169+
return $input;
170+
}
171+
163172
$is_valid = $ability->validate_input( $input );
164173
if ( is_wp_error( $is_valid ) ) {
165174
$is_valid->add_data( array( 'status' => 400 ) );

0 commit comments

Comments
 (0)