core_reports_rejections = $core_reports_rejections; } /** * Hook whichever rejection seam this WordPress offers. * * WordPress 7.1 reports both rejections itself, which covers every ability * whatever the registration order and includes input validation - the case * no earlier version exposes at all. Below that, the only seam is wrapping * `permission_callback` at registration. */ public function register(): void { if ( $this->core_reports_rejections() ) { // Late, so the verdict observed is the one core acts on after // every other plugin has had its say. add_filter( 'wp_ability_permission_result', array( $this, 'observe_permission' ), 999, 3 ); add_filter( 'wp_ability_validate_input', array( $this, 'observe_input' ), 999, 3 ); return; } add_filter( 'wp_register_ability_args', array( $this, 'wrap_permission_callback' ), 10, 2 ); } /** * Record a failed permission check, then hand the verdict back untouched. * * @param bool|WP_Error|mixed $permission Verdict from the permission callback. * @param string $ability_name Namespaced ability name. * @param mixed $input Input the check ran against. * @return bool|WP_Error|mixed The verdict, unchanged. */ public function observe_permission( $permission, string $ability_name, $input = null ) { if ( true !== $permission ) { $this->record( $ability_name, $input, 'permission', $permission ); } return $permission; } /** * Record a failed input validation, then hand the result back untouched. * * Core passes this filter the input before the ability name, unlike the * permission filter. * * @param true|WP_Error|mixed $is_valid Validation result. * @param mixed $input Input being validated. * @param string $ability_name Namespaced ability name. * @return true|WP_Error|mixed The result, unchanged. */ public function observe_input( $is_valid, $input = null, string $ability_name = '' ) { if ( true !== $is_valid ) { $this->record( $ability_name, $input, 'input', $is_valid ); } return $is_valid; } /** * Wrap an ability's permission callback so its verdict can be observed. * * @param array $args Ability registration args. * @param string $ability_name Namespaced ability name. * @return array */ public function wrap_permission_callback( array $args, string $ability_name ): array { if ( ! isset( $args['permission_callback'] ) || ! is_callable( $args['permission_callback'] ) ) { // Core reports a missing permission callback itself, and inventing // a callback here would change what the ability does. return $args; } $original = $args['permission_callback']; // Core passes input to the callback only when a schema exists; the // wrapper has to invoke it exactly the same way. $has_schema = ! empty( $args['input_schema'] ); $args['permission_callback'] = function ( $input = null ) use ( $original, $has_schema, $ability_name ) { $verdict = $has_schema ? $original( $input ) : $original(); return $this->observe_permission( $verdict, $ability_name, $input ); }; return $args; } /** * Whether this WordPress reports rejections through its own filters. * * `WP_Filter_Sentinel` arrived with the execution lifecycle filters in 7.1 * and is the cheapest marker for them. */ private function core_reports_rejections(): bool { return $this->core_reports_rejections ?? class_exists( '\WP_Filter_Sentinel' ); } /** * Append one refused attempt to the ledger. * * @param string $ability_name Namespaced ability name. * @param mixed $input Attempted input. * @param string $stage Which check refused it: permission or input. * @param mixed $verdict The refusing verdict, for its error code. */ private function record( string $ability_name, $input, string $stage, $verdict ): void { $context = AgentContext::current(); if ( null === $context ) { // Humans and unauthenticated callers failing a permission check are // ordinary WordPress traffic; only agent attempts belong here. return; } // Shared with the REST door: a flood through either is capped the // same way, and together (ADR-13). $now = new DateTimeImmutable( 'now' ); $action = ActionDescriptor::from_ability( $ability_name, is_array( $input ) ? $input : array() ); $note = null === $this->throttle ? array() : $this->throttle->admit( $context->agent->id, $action->entity_type, $now ); if ( null === $note ) { return; } $result = array( 'refused_at' => $stage ) + $note; if ( $verdict instanceof WP_Error ) { $result['code'] = $verdict->get_error_code(); } try { $this->writer->append( array( 'ts' => $now, 'agent_id' => $context->agent->id, 'wp_user_id' => $context->agent->wp_user_id, 'source' => $action->source, 'action_cat' => $action->action_cat, 'entity_type' => $action->entity_type, 'entity_id' => $action->entity_id, 'ability_name' => $ability_name, 'status' => self::STATUS, 'input' => $action->input, 'result' => $result, // No rule was consulted, so none may be credited. 'policy_id' => null, 'rule_index' => null, ) ); } catch ( LedgerUnavailable $busy ) { // Core refused the call either way; under contention its record // is skipped, as the cap would skip it. return; } } }