in_array( $hook, array( 'update_user_meta', 'updated_user_meta' ), true ) ) ); return (string) $meta_id . ':' . $depth; } /** * Capture the native actor for current-site capability metadata before other callbacks. * * @param mixed $meta_id Native metadata-row identifier supplied by the hook. * @param mixed $user_id Native affected-user identifier supplied by the metadata hook. * @param mixed $key Native metadata key compared with the current site capability key. * @param mixed $value Native capability metadata value observed by the hook. * * @return void */ public static function actor( mixed $meta_id, mixed $user_id, mixed $key, mixed $value ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Preserve the registered native hook/callback positional signature; this adapter intentionally does not consume or retain the unused supplied field. global $wpdb; if ( $key !== $wpdb->get_blog_prefix( get_current_blog_id() ) . 'capabilities' ) { return; } if ( count( self::$actors ) >= 128 ) { self::$actors = array(); Store::degraded( 'role_context_incomplete' ); } self::$actors[ self::frame( $meta_id ) ] = array( 'id' => get_current_user_id(), 'channel' => Store::channel(), ); } /** * Extract and sort enabled registered roles from bounded native capability metadata. * * @param mixed $caps Native capability map from which enabled registered roles are selected. * * @return array|null Registered enabled role names, or null for unsupported metadata. */ private static function roles( mixed $caps ): ?array { if ( ! is_array( $caps ) || count( $caps ) > 512 ) { return null; } $known = wp_roles()->roles; $roles = array(); foreach ( $caps as $key => $enabled ) { if ( true === $enabled && isset( $known[ $key ] ) ) { $roles[] = $key; } } sort( $roles, SORT_STRING ); return $roles; } /** * Read the bounded native role snapshot and bind the expected write to its site and user. * * @param mixed $meta_id Native metadata-row identifier supplied by the hook. * @param mixed $user_id Native affected-user identifier supplied by the metadata hook. * @param mixed $key Native metadata key compared with the current site capability key. * @param mixed $value Native capability metadata value observed by the hook. * * @return void */ public static function before( mixed $meta_id, mixed $user_id, mixed $key, mixed $value ): void { global $wpdb; if ( $key !== $wpdb->get_blog_prefix( get_current_blog_id() ) . 'capabilities' ) { return; } // Bounded request-local acquisition; final facts persist synchronously after success. unset( self::$before[ self::frame( $meta_id ) ] ); if ( count( self::$before ) >= 128 ) { self::$before = array(); Store::degraded( 'role_context_incomplete' ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Read the actual pre-write role metadata row; cached capabilities may already reflect the proposed change. $old = $wpdb->get_var( $wpdb->prepare( "SELECT meta_value FROM %i WHERE umeta_id=%d AND user_id=%d AND meta_key=%s", $wpdb->usermeta, $meta_id, $user_id, $key ) ); if ( ! is_string( $old ) || strlen( $old ) > 16384 ) { Store::degraded( 'role_context_incomplete' ); return; } // WordPress capabilities are native serialized metadata, never caller-supplied log JSON. // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- Decode actual bounded WordPress role/capability metadata with classes disabled; the following native role map or exact capability comparison rejects malformed values. Parser warnings must not disclose stored metadata. $old = @unserialize( $old, array( 'allowed_classes' => false ) ); $roles = self::roles( $old ); if ( null !== $roles ) { self::$before[ self::frame( $meta_id ) ] = array( 'roles' => $roles, 'expected' => self::roles( $value ), 'user' => (int) $user_id, 'site' => get_current_blog_id(), ); } } /** * Consume the matching successful metadata-write frame and record a verified role difference. * * @param mixed $meta_id Native metadata-row identifier supplied by the hook. * @param mixed $user_id Native affected-user identifier supplied by the metadata hook. * @param mixed $key Native metadata key compared with the current site capability key. * @param mixed $value Native capability metadata value observed by the hook. * * @return void */ public static function after( mixed $meta_id, mixed $user_id, mixed $key, mixed $value ): void { global $wpdb; if ( $key !== $wpdb->get_blog_prefix( get_current_blog_id() ) . 'capabilities' ) { return; } $frame = self::frame( $meta_id ); $before = self::$before[ $frame ] ?? null; $actor = self::$actors[ $frame ] ?? null; unset( self::$before[ $frame ], self::$actors[ $frame ] ); if ( ! $before || $before['user'] !== (int) $user_id || get_current_blog_id() !== $before['site'] ) { Store::degraded( 'role_context_incomplete' ); return; } // This is the value WordPress's successful write used. A later SELECT could // instead observe another worker's change and falsely attribute it here. $after = self::roles( $value ); if ( null !== $after && $before['expected'] === $after && is_array( $actor ) ) { Store::role_change( (int) $user_id, $before['roles'], $after, $actor ); } else { Store::degraded( 'role_context_incomplete' ); } } }