network_id ) || isset( $this->site_id ) ) { throw new \Error( 'An authorization scope cannot be initialized twice.' ); } $this->network_id = self::network(); $this->site_id = $network_view ? 0 : (int) get_current_blog_id(); $this->network_view = $network_view; $this->capability = $capability; $this->principal = get_current_user_id(); } /** * Preserve public identifier reads without exposing mutable authorization state. * * @param string $name Requested public identifier. * @return int Captured identifier, returned by value rather than reference. * @throws \Error When a caller asks for any other property. */ public function __get( string $name ): int { if ( 'network_id' === $name ) { return $this->network_id; } if ( 'site_id' === $name ) { return $this->site_id; } throw new \Error( 'Only captured authorization identifiers are readable.' ); } /** * Report the same initialized, non-null identifier availability as public reads. * * @param string $name Requested public identifier. * @return bool Whether the captured identifier has been initialized. */ public function __isset( string $name ): bool { return ( 'network_id' === $name && isset( $this->network_id ) ) || ( 'site_id' === $name && isset( $this->site_id ) ); } /** * Reject identifier replacement and dynamic shadow properties on every PHP version. * * @param string $name Property the caller attempted to write. * @param mixed $value Rejected replacement value. * @return void * @throws \Error Always: authorization state is captured only by the private factory. */ public function __set( string $name, mixed $value ): void { throw new \Error( 'Authorization scope properties cannot be changed.' ); } /** * Reject removal instead of permitting a later identifier replacement. * * @param string $name Property the caller attempted to remove. * @return void * @throws \Error Always: captured authorization state cannot be removed. */ public function __unset( string $name ): void { throw new \Error( 'Authorization scope properties cannot be removed.' ); } /** * Create a current-site scope after checking an allowlisted native capability. * * @phpstan-impure Reads current native authority or mutable context. * * @param string $capability Native capability selected for the authorization decision. * * @return self|\WP_Error Current authorization scope, or an error denying access. */ public static function current( string $capability = 'logged_ai_view_events' ): self|\WP_Error { if ( ! in_array( $capability, self::SITE_CAPS, true ) || ! self::can( $capability ) ) { return new \WP_Error( 'logged_ai_forbidden', __( 'You cannot access this resource.', 'logged-ai' ), array( 'status' => 403 ) ); } return new self( $capability ); } /** * Recheck captured identity, scope and native authority before using this scope. * * @phpstan-impure Reads current native authority or mutable context. * * @return bool Whether the captured principal and namespace remain authorized. */ public function valid(): bool { $allowed = get_current_user_id() === $this->principal && ( $this->network_view ? self::network_authorized() && self::can( 'manage_network_options' ) : (int) get_current_blog_id() === $this->site_id ) && self::network() === $this->network_id && self::can( $this->capability ); return $allowed && get_current_user_id() === $this->principal && self::network() === $this->network_id && ( $this->network_view || (int) get_current_blog_id() === $this->site_id ); } /** * Check one native capability through the bounded coherent grant decision. * * Primitive site capabilities must not inherit another network's super-admin bypass. * * @phpstan-impure * * @param string $capability Native capability selected for the authorization decision. * * @return bool Whether both native grant views allow the capability. */ public static function can( string $capability ): bool { return self::can_all( array( $capability ) ); } /** * Check bounded capabilities without recursive decisions or native context drift. * * One bounded permission decision; recursive native callbacks fail closed. * * @phpstan-impure * * @param array $capabilities Bounded native capability names required together. * @param int $grant_site Explicit grant site, or zero for the internally selected site. * @param array|null $site_requirements Optional fixed per-site grants for installation-deletion authorization. * * @return bool Whether the complete capability decision remains current and allowed. */ private static function can_all( array $capabilities, int $grant_site = 0, ?array $site_requirements = null ): bool { if ( self::$checking_capability || ! $capabilities || count( $capabilities ) > 16 ) { return false; } self::$checking_capability = true; try { $principal = get_current_user_id(); $origin = get_current_blog_id(); $network = self::network(); $network_caps = array_merge( self::NETWORK_CAPS, self::NATIVE_NETWORK_CAPS, is_multisite() ? array( 'update_plugins' ) : array() ); foreach ( $capabilities as $capability ) { if ( ! is_string( $capability ) || '' === $capability ) { return false; } if ( in_array( $capability, $network_caps, true ) && ! self::network_authorized() ) { return false; } } $site = $grant_site > 0 ? $grant_site : ( in_array( 'logged_ai_purge_network_data', $capabilities, true ) ? (int) get_main_site_id( $network ) : (int) $origin ); $allowed = self::caller_capabilities( $capabilities, $site, $site_requirements ); if ( $allowed && array_intersect( $capabilities, $network_caps ) ) { $allowed = self::network_authorized(); } if ( $allowed ) { $allowed = self::committed_capabilities( $capabilities, $site, $site_requirements ); } return $allowed && get_current_user_id() === $principal && get_current_blog_id() === $origin && self::network() === $network; } finally { self::$checking_capability = false; } } /** * Isolate native caller-snapshot hydration from the caller's global objects. * * @param array $capabilities Bounded native capability names required together. * @param int $site Selected native grant site. * @param array|null $site_requirements Fixed optional installation-deletion grants. * @return bool Whether the caller snapshot allows access without replacing its owned views. */ private static function caller_capabilities( array $capabilities, int $site, ?array $site_requirements ): bool { $origin = array( get_current_blog_id(), self::network(), get_current_user_id() ); $user = $GLOBALS['current_user'] ?? null; $roles = $GLOBALS['wp_roles'] ?? null; $user_view = $user instanceof \WP_User ? clone $user : $user; $roles_view = $roles instanceof \WP_Roles ? clone $roles : $roles; if ( $user_view instanceof \WP_User ) { /** * Native data is public and untyped; callbacks can replace its documented object value. * * @var mixed $user_data */ $user_data = $user_view->data; if ( $user_data instanceof \stdClass ) { $user_view->data = clone $user_data; } } $network = self::network(); if ( array( get_current_blog_id(), $network, get_current_user_id() ) !== $origin || ( $GLOBALS['current_user'] ?? null ) !== $user || ( $GLOBALS['wp_roles'] ?? null ) !== $roles ) { return false; } $owned = static function () use ( $origin, $user_view, $roles_view ): bool { $network = self::network(); return array( get_current_blog_id(), $network, get_current_user_id() ) === $origin && ( $GLOBALS['current_user'] ?? null ) === $user_view && ( $GLOBALS['wp_roles'] ?? null ) === $roles_view; }; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Isolate the preliminary caller grant read from native site-switch mutations; restore only these exact owned views at the unchanged origin. $GLOBALS['current_user'] = $user_view; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Native constructors may mutate global roles before returning; the caller precheck owns this clone only for its bounded read. $GLOBALS['wp_roles'] = $roles_view; $restored = false; try { $allowed = null === $site_requirements ? self::fresh_capabilities( $capabilities, $site ) : self::fresh_site_requirements( $site_requirements ); } finally { if ( $owned() ) { // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the captured caller user only from its exact owned view at the unchanged site, network and principal; foreign replacements stay visible. $GLOBALS['current_user'] = $user; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the captured caller roles under the same exact-reference and origin guard, including a genuine callback exception. $GLOBALS['wp_roles'] = $roles; $restored = true; } } return $allowed && $restored; } /** * Hydrate one caller-snapshot user without hiding callback context replacements. * * The committed lease retains its direct fresh_user constructor chain and recovery. * * @param int $principal Native principal whose grant snapshot is refreshed. * @param int $site Selected native grant site. * @return \WP_User|null Fresh selected user, or null after a callback replaces the owned context. */ private static function caller_user( int $principal, int $site ): ?\WP_User { global $wpdb; /** * Native adapter retains the caller's transaction snapshot. * * @var \wpdb $wpdb */ $origin = array( get_current_blog_id(), self::network(), get_current_user_id() ); $user = $GLOBALS['current_user'] ?? null; $roles = $GLOBALS['wp_roles'] ?? null; $selected_roles = null; $owned = static function () use ( $origin, $user, $roles, &$selected_roles ): bool { /** * The constructor owner updates this reference after native callbacks return. * * @var \WP_Roles|null $selected_roles */ $network = self::network(); return array( get_current_blog_id(), $network, get_current_user_id() ) === $origin && ( $GLOBALS['current_user'] ?? null ) === $user && ( $GLOBALS['wp_roles'] ?? null ) === ( $selected_roles ?? $roles ); }; try { if ( is_multisite() ) { wp_cache_switch_to_blog( $site ); } try { wp_cache_delete( $wpdb->get_blog_prefix( $site ) . 'user_roles', 'options' ); wp_cache_delete( 'alloptions', 'options' ); } finally { if ( is_multisite() ) { wp_cache_switch_to_blog( get_current_blog_id() ); } } if ( ! $owned() ) { return null; } $created_roles = new \WP_Roles( $site ); if ( ! $owned() ) { return null; } $selected_roles = $created_roles; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Select only the staged native roles after checking that construction preserved the caller context; finally restores only this exact owned object. $GLOBALS['wp_roles'] = $selected_roles; clean_user_cache( $principal ); wp_cache_delete( $principal, 'user_meta' ); if ( ! $owned() ) { return null; } $selected_user = new \WP_User( $principal, '', $site ); if ( ! $owned() ) { return null; } } finally { $restored = $owned(); if ( $restored ) { // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore captured caller roles only while the exact selected object, user and native origin remain owned; foreign callback replacements are preserved. $GLOBALS['wp_roles'] = $roles; } } return $restored ? $selected_user : null; } /** * Verify the grant on an independent current connection and restore native context. * * Both native views must allow access; no vendor-specific transaction-state probe. * * @phpstan-impure * * @param array $capabilities Bounded native capability names required together. * @param int $site Selected native site identifier. * @param array|null $site_requirements Optional fixed per-site grants for installation-deletion authorization. * * @return bool Whether authorization, context restoration and owned-connection closure all succeed. */ private static function committed_capabilities( array $capabilities, int $site, ?array $site_requirements = null ): bool { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $original = $wpdb; // First checking the caller's native grants also preserves an uncommitted // local revocation. The committed check also runs in autocommit mode: // WordPress callbacks may own a snapshot which cannot be inferred portably. require_once __DIR__ . '/class-workerconnection.php'; if ( ! in_array( get_class( $original ), array( 'wpdb', WorkerConnection::class ), true ) ) { return false; } try { $caller_handle = $original->__get( 'dbh' ); if ( ! $caller_handle instanceof \mysqli ) { return false; } $caller_thread = $caller_handle->thread_id; $context = static function ( \wpdb $db ): array { $value = Derived::native_context( $db ); $value[0] = null; return $value; }; $caller_context = $context( $original ); $caller_current = static function () use ( $original, $caller_handle, $caller_thread, $context, $caller_context ): bool { try { return ( $GLOBALS['wpdb'] ?? null ) === $original && $caller_context === $context( $original ) && $original->__get( 'dbh' ) === $caller_handle && $caller_handle->thread_id === $caller_thread; } catch ( \Throwable $error ) { return false; } }; $origin = array( get_current_blog_id(), self::network(), get_current_user_id() ); if ( ! $caller_current() ) { return false; } } catch ( \Throwable $error ) { return false; } $roles = $GLOBALS['wp_roles'] ?? null; $user = $GLOBALS['current_user'] ?? null; $connection = null; $allowed = false; $restored = true; $handle = null; $observer = null; $switch_observer = null; /** * Native globals are observed at runtime, including absent or malformed callback state. * * @var mixed $switch_stack */ $switch_stack = $GLOBALS['_wp_switched_stack'] ?? null; $switched = $GLOBALS['switched'] ?? null; $current_blog = $GLOBALS['current_blog'] ?? null; $current_network = $GLOBALS['current_site'] ?? null; $lease = null; /** * WordPress query callbacks can change this captured flag. * * @var bool $changed */ $changed = false; $assigned = false; $closed = false; $final_origin = false; try { $connection = new WorkerConnection( DB_USER, DB_PASSWORD, DB_NAME, DB_HOST ); $handle = $connection->__get( 'dbh' ); if ( ! $handle instanceof \mysqli || $handle === $caller_handle ) { $handle = null; return false; } $thread = $handle->thread_id; if ( ! $connection->ready ) { return false; } $connection->set_prefix( $original->base_prefix ); $connection->set_blog_id( $origin[0], $origin[1] ); $connection->suppress_errors( true ); $worker_context = $context( $connection ); $bookkeeping = array( 'blogid' => true, 'siteid' => true, ); if ( array_diff_key( $worker_context, $bookkeeping ) !== array_diff_key( $caller_context, $bookkeeping ) ) { return false; } $stable = static function () use ( $original, $connection, $caller_handle, $caller_thread, $handle, $thread, $context, $caller_context, $worker_context, $switch_stack, $switched, $current_blog, $current_network, &$assigned, &$lease ): bool { /** * Captured reference is read at invocation after setup/callback changes. * * @var bool $assigned */ /** * The hydration owner and native switch observer update this reference between invocations. * * @var array|null $lease */ try { $expected_caller = $caller_context; $expected_worker = $worker_context; $expected_stack = $switch_stack; $expected_switched = $switched; if ( null !== $lease && 'target' === $lease['phase'] ) { $expected_caller = $lease['caller']; $expected_worker = $lease['worker']; $expected_stack = array_merge( $switch_stack, array( $lease['origin'] ) ); $expected_switched = true; } if ( null !== $lease && ( $GLOBALS['switched'] ?? null ) !== $expected_switched ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace -- Bound the native switch's pre-return flag update to the exact leased constructor chain; no trace is emitted or persisted. $transition = self::hydration_transition( debug_backtrace( DEBUG_BACKTRACE_PROVIDE_OBJECT, 40 ), $lease['principal'], $lease['site'], $lease['recover'] ); if ( 'target' === $lease['phase'] && ( $lease['recover'] || str_starts_with( $transition, 'switch:' ) ) ) { $expected_switched = $switched; } elseif ( 'origin' === $lease['phase'] && ( $lease['recover'] || str_starts_with( $transition, 'restore:' ) ) ) { $expected_switched = true; } } return ( $GLOBALS['wpdb'] ?? null ) === ( $assigned ? $connection : $original ) && $expected_caller === $context( $original ) && $expected_worker === $context( $connection ) && $original->__get( 'dbh' ) === $caller_handle && $caller_handle->thread_id === $caller_thread && $connection->__get( 'dbh' ) === $handle && $handle->thread_id === $thread && ( $GLOBALS['_wp_switched_stack'] ?? null ) === $expected_stack && ( $GLOBALS['switched'] ?? null ) === $expected_switched && ( $GLOBALS['current_blog'] ?? null ) === $current_blog && ( $GLOBALS['current_site'] ?? null ) === $current_network; } catch ( \Throwable $error ) { return false; } }; if ( ! $stable() ) { return false; } $setup = null; $observer = static function ( string $sql ) use ( $connection, $stable, &$changed, &$setup ): string { /** * Captured reference is read at invocation after setup/callback changes. * * @var string|null $setup */ // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace -- Inspect at most 20 live frames to bind original SQL and the private permission connection's exact setup caller; no trace is emitted or persisted. $trace = debug_backtrace( DEBUG_BACKTRACE_PROVIDE_OBJECT, 20 ); foreach ( $trace as $index => $frame ) { if ( 'query' !== $frame['function'] || ! ( ( $frame['object'] ?? null ) instanceof \wpdb ) ) { continue; } $read = 1 === preg_match( '/\A(?:SELECT|SHOW|DESCRIBE|EXPLAIN)\s/i', trim( $sql ) ); if ( $frame['object'] === $connection ) { $next = $trace[ $index + 1 ] ?? array(); $own_setup = null !== $setup && $setup === $sql && self::class === ( $next['class'] ?? '' ) && 'committed_capabilities' === ( $next['function'] ); $input = $frame['args'][0] ?? null; if ( $changed || ! $stable() || ! is_string( $input ) || $connection->remove_placeholder_escape( $input ) !== $sql || ( ! $read && ! $own_setup ) ) { $changed = true; $connection->flush(); return ''; } } elseif ( ! $read ) { $changed = true; return ''; } break; } return $sql; }; add_filter( 'query', $observer, PHP_INT_MAX ); foreach ( array( 'SET SESSION autocommit=1', 'SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED' ) as $statement ) { $setup = $statement; if ( 0 !== $connection->query( $statement ) || '' !== $connection->last_error || $statement !== $connection->last_query || ! $stable() || $changed ) { return false; } } $setup = null; $verified = false; foreach ( array( 'transaction_isolation', 'tx_isolation' ) as $isolation ) { $sql = 'SELECT @@SESSION.autocommit,@@SESSION.' . $isolation . ',DATABASE()'; $count = $connection->query( $sql ); /** * Native callbacks since the preceding guard can change this flag. * * @var bool $changed */ if ( ! $stable() || $changed || $sql !== $connection->last_query ) { return false; } if ( 'transaction_isolation' === $isolation && false === $count && '' !== $connection->last_error ) { continue; } $verified = 1 === $count && '' === $connection->last_error && '1' === $connection->get_var( null, 0 ) && 'READ-COMMITTED' === $connection->get_var( null, 1 ) && DB_NAME === $connection->get_var( null, 2 ); break; } /** * Native callbacks since the preceding guard can change this flag. * * @var bool $changed */ if ( ! $verified || ! $stable() || $changed ) { return false; } $switch_observer = static function ( int $next, int $previous, string $kind ) use ( &$lease, &$changed, $origin, $stable ): void { /** * The hydration owner and native switch observer update this reference between invocations. * * @var array|null $lease */ if ( null === $lease ) { $changed = true; return; } // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace -- Inspect only 40 frames for the direct native constructor round trip; nested callback switches cannot borrow the hydration allowance. $transition = self::hydration_transition( debug_backtrace( DEBUG_BACKTRACE_PROVIDE_OBJECT, 40 ), $lease['principal'], $lease['site'], $lease['recover'] ); if ( 'switch' === $kind && 'origin' === $lease['phase'] && $next === $lease['site'] && $previous === $origin[0] && str_starts_with( $transition, 'switch:' ) ) { $lease['phase'] = 'target'; } elseif ( 'restore' === $kind && 'target' === $lease['phase'] && $next === $origin[0] && $previous === $lease['site'] && str_starts_with( $transition, 'restore:' ) ) { $lease['phase'] = 'origin'; } else { $changed = true; return; } if ( str_ends_with( $transition, ':roles' ) ) { $lease['roles_seen'] = true; } if ( ! $stable() ) { $changed = true; } }; add_action( 'switch_blog', $switch_observer, PHP_INT_MIN, 3 ); $targets = array_unique( array_merge( array( $site ), array_keys( $site_requirements ?? array() ) ) ); $hydrate = static function ( int $principal, int $selected ) use ( $connection, $context, $caller_context, $worker_context, $origin, $targets, $switch_stack, $switched, $stable, &$lease, &$changed ): \WP_User { /** * The hydration owner and native switch observer update this reference between invocations. * * @var array|null $lease */ if ( null !== $lease || $changed || ! $stable() || $principal !== $origin[2] || ! in_array( $selected, $targets, true ) ) { throw new \RuntimeException( 'Native grant hydration context changed.' ); } if ( $selected === $origin[0] ) { return self::fresh_user( $principal, $selected ); } /** * Native helper callbacks can change this shared refusal flag during the next guard. * * @var bool $changed */ if ( ! is_multisite() || ! is_array( $switch_stack ) || $switched !== (bool) $switch_stack || (int) get_main_site_id( $origin[1] ) !== $selected || ! $stable() || $changed ) { throw new \RuntimeException( 'Native grant hydration target changed.' ); } // Native table-map construction invokes no switch hooks and never moves the caller adapter. $connection->set_blog_id( $selected, $origin[1] ); $target_worker = $context( $connection ); $connection->set_blog_id( $origin[0], $origin[1] ); if ( $worker_context !== $context( $connection ) || ! $stable() ) { throw new \RuntimeException( 'Native grant table map changed.' ); } $target_caller = $caller_context; $target_caller[1] = $selected; $target_caller[2] = $target_worker['prefix']; $target_worker[1] = $selected; $target_worker[2] = $target_worker['prefix']; $registration = $GLOBALS['wp_filter']['switch_blog']->callbacks[1]['wp_switch_roles_and_user'] ?? null; if ( null !== $registration && ( ! is_array( $registration ) || 'wp_switch_roles_and_user' !== ( $registration['function'] ?? null ) || ! is_int( $registration['accepted_args'] ?? null ) ) ) { throw new \RuntimeException( 'Native role switch registration changed.' ); } $lease = array( 'principal' => $principal, 'site' => $selected, 'origin' => $origin[0], 'caller' => $target_caller, 'worker' => $target_worker, 'phase' => 'origin', 'roles_seen' => false, 'recover' => false, ); $role_interrupted = false; try { return self::fresh_user( $principal, $selected ); } catch ( \Throwable $error ) { /** * Native switch callbacks can change the active lease before a hydration throws. * * @var array $lease */ $exception_trace = array_slice( $error->getTrace(), 0, 40 ); $role_interrupted = self::hydration_roles_interrupted( $exception_trace, $principal, $selected ); $interrupted_transition = self::hydration_transition( $exception_trace, $principal, $selected, false, true ); $changed = true; $lease['recover'] = true; if ( 'origin' === $lease['phase'] && str_starts_with( $interrupted_transition, 'switch:' ) ) { $lease['phase'] = 'target'; if ( ! $stable() ) { $lease['phase'] = 'origin'; } } elseif ( 'target' === $lease['phase'] && str_starts_with( $interrupted_transition, 'restore:' ) ) { $lease['phase'] = 'origin'; if ( ! $stable() ) { $lease['phase'] = 'target'; } } $lease['recover'] = false; throw $error; } finally { /** * Native construction or recovery callbacks can change the active lease fields. * * @var array $lease */ try { $lease['recover'] = true; if ( 'target' === $lease['phase'] && $stable() ) { $changed = true; try { self::restore_hydration(); } catch ( \Throwable $recovery_error ) { $changed = true; } } if ( 'origin' === $lease['phase'] && $stable() && ( $GLOBALS['switched'] ?? null ) !== $switched ) { $changed = true; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited, WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- Finish only the interrupted owned native restore's flag update after exact origin, stack, principal, table-map and handle checks; this decision remains denied. $GLOBALS['switched'] = $switched; } $lease['recover'] = false; $current = $GLOBALS['wp_filter']['switch_blog']->callbacks[1]['wp_switch_roles_and_user'] ?? null; if ( ( $lease['roles_seen'] || $role_interrupted ) && $current !== $registration ) { $native = array( 'function' => 'wp_switch_roles_and_user', 'accepted_args' => 2, ); if ( $native === $current || ( null === $current && $role_interrupted ) ) { remove_action( 'switch_blog', 'wp_switch_roles_and_user', 1 ); if ( null !== $registration && 'wp_switch_roles_and_user' === ( $registration['function'] ?? null ) && is_int( $registration['accepted_args'] ?? null ) ) { add_action( 'switch_blog', 'wp_switch_roles_and_user', 1, $registration['accepted_args'] ); } elseif ( null !== $registration ) { $changed = true; } } else { $changed = true; } } if ( 'origin' !== $lease['phase'] || ! $stable() ) { $changed = true; throw new \RuntimeException( 'Native grant hydration did not restore its origin.' ); } } finally { $lease = null; } } }; // Only the bounded native permission decision sees this connection. // A capability callback cannot commit either the caller or a Pro worker. $assigned = true; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Temporarily route only the bounded committed-capability decision through its disposable connection; finally restores the caller connection. $wpdb = $connection; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Isolate native site-switch mutations from the captured caller user object; finally restores the caller only at its unchanged identity and site. $GLOBALS['current_user'] = $user instanceof \WP_User ? clone $user : $user; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Native switch hooks may mutate roles in place; the committed view owns this clone and finally restores the captured caller roles. $GLOBALS['wp_roles'] = $roles instanceof \WP_Roles ? clone $roles : $roles; $network_allowed = true; $network_caps = array_merge( self::NETWORK_CAPS, self::NATIVE_NETWORK_CAPS, is_multisite() ? array( 'update_plugins' ) : array() ); foreach ( $capabilities as $capability ) { if ( in_array( $capability, $network_caps, true ) && ! self::network_authorized() ) { $network_allowed = false; break; } } $allowed = $network_allowed && ( null === $site_requirements ? self::fresh_capabilities( $capabilities, $site, $hydrate ) : self::fresh_site_requirements( $site_requirements, $hydrate ) ); if ( $allowed && array_intersect( $capabilities, $network_caps ) ) { $allowed = self::network_authorized(); } /** * Native callbacks since the preceding guard can change this flag. * * @var bool $changed */ $allowed = $allowed && $stable() && ! $changed && $wpdb === $connection && $connection->base_prefix === $original->base_prefix && $connection->prefix === $original->prefix && array( get_current_blog_id(), self::network(), get_current_user_id() ) === $origin; } catch ( \Throwable $error ) { $allowed = false; } finally { if ( null !== $switch_observer ) { remove_action( 'switch_blog', $switch_observer, PHP_INT_MIN ); } $assigned = false; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the captured caller connection in finally before reconciling actual native callback context. $wpdb = $original; $actual = array( get_current_blog_id(), self::network(), get_current_user_id() ); try { if ( $actual[0] === $origin[0] && $actual[1] === $origin[1] ) { // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore captured roles only when native execution remains on the original site and network. $GLOBALS['wp_roles'] = $roles; } else { $allowed = false; $original->set_blog_id( $actual[0], $actual[1] ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Refresh roles for the actual callback-selected site after refusing the original capability result; do not restore a stale site identity. $GLOBALS['wp_roles'] = new \WP_Roles( $actual[0] ); } if ( $actual === $origin ) { // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the captured user object only when the complete actual native site, network and principal context remains unchanged. $GLOBALS['current_user'] = $user; } clean_user_cache( $origin[2] ); wp_cache_delete( $origin[2], 'user_meta' ); foreach ( array_unique( array_merge( array( $origin[0], $actual[0], $site ), array_keys( $site_requirements ?? array() ) ) ) as $grant_site ) { if ( is_multisite() ) { wp_cache_switch_to_blog( $grant_site ); } try { wp_cache_delete( $original->get_blog_prefix( $grant_site ) . 'user_roles', 'options' ); wp_cache_delete( 'alloptions', 'options' ); } finally { if ( is_multisite() ) { wp_cache_switch_to_blog( get_current_blog_id() ); } } } } catch ( \Throwable $error ) { $restored = false; } try { $final_origin = array( get_current_blog_id(), self::network(), get_current_user_id() ) === $origin; } catch ( \Throwable $error ) { $final_origin = false; } if ( null !== $observer ) { remove_filter( 'query', $observer, PHP_INT_MAX ); } if ( $connection instanceof \wpdb && $handle instanceof \mysqli ) { try { $closed = true === ( $connection->__get( 'dbh' ) === $handle ? $connection->close() : $handle->close() ); } catch ( \Throwable $error ) { $closed = false; } } if ( $connection instanceof WorkerConnection && $connection !== $original ) { remove_filter( 'query', array( $connection, 'remove_placeholder_escape' ), 0 ); } } return $allowed && $restored && $closed && ! $changed && $final_origin && $caller_current(); } /** * Attest one contiguous native constructor chain for the selected hydration. * * @param array $trace Bounded live or exception frames, never persisted. * @param int $index First role or user construction frame. * @param string $kind Fixed native roles or user constructor kind. * @param int $principal Captured native principal. * @param int $site Internally selected grant site. * @param bool $exception Whether only an interrupted chain is being attested for refusal-only cleanup. * @return bool Whether the exact native object and arguments reach fresh_user directly. */ private static function hydration_constructor( array $trace, int $index, string $kind, int $principal, int $site, bool $exception = false ): bool { $functions = 'roles' === $kind ? array( 'get_roles_data', 'for_site', '__construct' ) : array( 'get_role_caps', 'for_site', 'init', '__construct' ); $class = 'roles' === $kind ? 'WP_Roles' : 'WP_User'; $object = $trace[ $index ]['object'] ?? null; if ( ! $exception && ( ! is_object( $object ) || get_class( $object ) !== $class || $site !== $object->get_site_id() ) ) { return false; } foreach ( $functions as $offset => $function ) { $frame = $trace[ $index + $offset ] ?? array(); if ( ( $frame['class'] ?? null ) !== $class || ( $frame['function'] ?? null ) !== $function || ( ! $exception && ( $frame['object'] ?? null ) !== $object ) ) { return false; } } $constructor = $trace[ $index + count( $functions ) - 1 ]['args'] ?? array(); $owner = $trace[ $index + count( $functions ) ] ?? array(); return ( $owner['class'] ?? null ) === self::class && ( $owner['function'] ?? null ) === 'fresh_user' && ( $exception || ( ( 'roles' === $kind ? array( $site ) === $constructor : array( $principal, '', $site ) === $constructor ) && ( $trace[ $index + 1 ]['args'][0] ?? null ) === $site && ( $owner['args'] ?? null ) === array( $principal, $site ) ) ); } /** * Identify only a direct native enter or restore for the current constructor. * * @param array $trace At most forty current frames, never persisted. * @param int $principal Captured native principal. * @param int $site Internally selected grant site. * @param bool $recover Whether the private owned-frame recovery is active. * @param bool $exception Whether only an interrupted chain is being attested for refusal-only cleanup. * @return string Fixed transition kind, or an empty refusal. */ private static function hydration_transition( array $trace, int $principal, int $site, bool $recover, bool $exception = false ): string { foreach ( $trace as $index => $frame ) { $function = $frame['function'] ?? ''; if ( ! in_array( $function, array( 'switch_to_blog', 'restore_current_blog' ), true ) || isset( $frame['class'] ) ) { continue; } $kind = 'switch_to_blog' === $function ? 'switch' : 'restore'; $next = $trace[ $index + 1 ] ?? array(); if ( $recover && 'restore' === $kind && ( $next['class'] ?? null ) === self::class && ( $next['function'] ?? null ) === 'restore_hydration' ) { return 'restore:cleanup'; } if ( 'get_blog_option' === ( $next['function'] ?? null ) && ! isset( $next['class'] ) && ( $exception || ( $next['args'][0] ?? null ) === $site ) && self::hydration_constructor( $trace, $index + 2, 'roles', $principal, $site, $exception ) ) { return $kind . ':roles'; } if ( self::hydration_constructor( $trace, $index + 1, 'user', $principal, $site, $exception ) ) { return $kind . ':user'; } return ''; } return ''; } /** * Determine whether a throw interrupted the native callback's remove/add pair. * * @param array $trace At most forty exception frames, never persisted. * @param int $principal Captured native principal. * @param int $site Internally selected grant site. * @return bool Whether the direct selected role constructor was interrupted. */ private static function hydration_roles_interrupted( array $trace, int $principal, int $site ): bool { foreach ( $trace as $index => $frame ) { if ( 'WP_Roles' === ( $frame['class'] ?? null ) && 'get_roles_data' === ( $frame['function'] ?? null ) ) { return self::hydration_constructor( $trace, $index, 'roles', $principal, $site, true ); } } return false; } /** * Restore the single native stack frame still owned by an interrupted hydration. * * @return void */ private static function restore_hydration(): void { restore_current_blog(); } /** * Refresh selected native grant caches and construct the account for one site. * * Refresh only bounded native role/user cache entries for the selected site. * * @phpstan-impure * * @param int $principal Native account identifier whose current grants are checked. * @param int $site Selected native site identifier. * * @return \WP_User New native user object for the selected principal and site. */ private static function fresh_user( int $principal, int $site ): \WP_User { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ if ( is_multisite() ) { wp_cache_switch_to_blog( $site ); } try { wp_cache_delete( $wpdb->get_blog_prefix( $site ) . 'user_roles', 'options' ); wp_cache_delete( 'alloptions', 'options' ); } finally { if ( is_multisite() ) { wp_cache_switch_to_blog( get_current_blog_id() ); } } // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Refresh the selected site's roles after cache invalidation for a fresh grant snapshot; both calling decision helpers restore their captured roles in finally. $GLOBALS['wp_roles'] = new \WP_Roles( $site ); clean_user_cache( $principal ); wp_cache_delete( $principal, 'user_meta' ); return new \WP_User( $principal, '', $site ); } /** * Check native capability filters and verify the persisted grant snapshot remains current. * * Native filters share one grant snapshot, then a bounded raw reread detects callback mutations. * * @phpstan-impure * * @param array $capabilities Bounded native capability names required together. * @param int $site Selected native site identifier. * @param \Closure|null $hydrate Optional private committed-view native hydration lease. * * @return bool Whether every requested capability survives the final grant and origin checks. */ private static function fresh_capabilities( array $capabilities, int $site, ?\Closure $hydrate = null ): bool { $principal = get_current_user_id(); if ( $principal < 1 || $site < 1 ) { return false; } $roles = $GLOBALS['wp_roles'] ?? null; $origin = get_current_blog_id(); $network = self::network(); $caller_user = $GLOBALS['current_user'] ?? null; $caller_current = static function () use ( $hydrate, $roles, $caller_user, $origin, $network, $principal ): bool { if ( null !== $hydrate ) { return true; } $current_network = self::network(); return $current_network === $network && get_current_user_id() === $principal && get_current_blog_id() === $origin && ( $GLOBALS['wp_roles'] ?? null ) === $roles && ( $GLOBALS['current_user'] ?? null ) === $caller_user; }; try { $user = null === $hydrate ? self::caller_user( $principal, $site ) : $hydrate( $principal, $site ); if ( ! $user instanceof \WP_User || ! $user->exists() || ! $caller_current() ) { return false; } $raw_caps = $user->caps; $raw_allcaps = $user->allcaps; foreach ( $capabilities as $capability ) { $native = $user->has_cap( $capability ); $required = map_meta_cap( $capability, $principal ); if ( ! $caller_current() || in_array( 'do_not_allow', $required, true ) || get_current_user_id() !== $principal || get_current_blog_id() !== $origin || self::network() !== $network ) { return false; } $network_admin = is_multisite() && self::network_authorized(); $explicit = in_array( $capability, array( 'logged_ai_purge_data', 'logged_ai_view_sensitive_context', 'logged_ai_purge_network_data', 'logged_ai_manage_investigations' ), true ); // Actual-network administrators still honor the selected native user's veto. if ( $network_admin && ! $native ) { return false; } if ( $explicit ) { if ( empty( $raw_allcaps[ $capability ] ) || ! $native ) { return false; } } elseif ( ! $network_admin ) { if ( is_multisite() && is_super_admin( $principal ) ) { // Core's process-network bypass is inapplicable: use its ordinary-user contract. // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Reapply the native user_has_cap filter so core capability vetoes remain effective. $caps = apply_filters( 'user_has_cap', $raw_allcaps, $required, array( $capability, $principal ), $user ); $caps['exist'] = true; unset( $caps['do_not_allow'] ); foreach ( $required as $required_cap ) { if ( empty( $caps[ $required_cap ] ) ) { return false; } } } elseif ( ! $native ) { return false; } } } // No retry loop: a changed persisted grant invalidates this decision. // These are native grant fields only, not a second user_has_cap dispatch. if ( ! $caller_current() ) { return false; } $after = null === $hydrate ? self::caller_user( $principal, $site ) : $hydrate( $principal, $site ); return $after instanceof \WP_User && $after->exists() && $caller_current() && $raw_caps === $after->caps && $raw_allcaps === $after->allcaps && get_current_user_id() === $principal && get_current_blog_id() === $origin && self::network() === $network; } finally { if ( null !== $hydrate ) { // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Preserve the committed lease's existing captured-role restoration; caller hydration restores only its exact owned references. $GLOBALS['wp_roles'] = $roles; } } } /** * Verify fixed installation grants across bounded site-specific snapshots. * * Fixed installation purpose only: native callbacks run before the final raw * grant comparison across both the current and main sites. No retry loop. * * @phpstan-impure * * @param array $requirements Bounded site-to-capability requirements for the fixed installation purpose. * @param \Closure|null $hydrate Optional private committed-view native hydration lease. * * @return bool Whether all required site grants remain consistent for the same principal. */ private static function fresh_site_requirements( array $requirements, ?\Closure $hydrate = null ): bool { if ( ! $requirements || count( $requirements ) > 2 ) { return false; } $principal = get_current_user_id(); $roles = $GLOBALS['wp_roles'] ?? null; $snapshots = array(); $caller_user = $GLOBALS['current_user'] ?? null; $origin = get_current_blog_id(); $network = self::network(); $caller_current = static function () use ( $hydrate, $roles, $caller_user, $origin, $network, $principal ): bool { if ( null !== $hydrate ) { return true; } $current_network = self::network(); return $current_network === $network && get_current_user_id() === $principal && get_current_blog_id() === $origin && ( $GLOBALS['wp_roles'] ?? null ) === $roles && ( $GLOBALS['current_user'] ?? null ) === $caller_user; }; try { foreach ( $requirements as $site => $capabilities ) { if ( ! is_int( $site ) || $site < 1 || ! is_array( $capabilities ) || ! $capabilities || count( $capabilities ) > 4 ) { return false; } $user = null === $hydrate ? self::caller_user( $principal, $site ) : $hydrate( $principal, $site ); if ( ! $user instanceof \WP_User || ! $user->exists() || ! $caller_current() ) { return false; } $snapshots[ $site ] = array( $user->caps, $user->allcaps ); } foreach ( $requirements as $site => $capabilities ) { if ( ! $caller_current() || ! self::fresh_capabilities( $capabilities, $site, $hydrate ) ) { return false; } } foreach ( $snapshots as $site => $snapshot ) { if ( ! $caller_current() ) { return false; } $after = null === $hydrate ? self::caller_user( $principal, $site ) : $hydrate( $principal, $site ); if ( ! $after instanceof \WP_User || ! $after->exists() || ! $caller_current() || array( $after->caps, $after->allcaps ) !== $snapshot ) { return false; } } return $caller_current() && get_current_user_id() === $principal; } finally { if ( null !== $hydrate ) { // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Preserve the committed lease's existing multi-site restoration; caller hydration restores only its exact owned references. $GLOBALS['wp_roles'] = $roles; } } } /** * Check the fixed site/main-site grants for changing the installation deletion choice. * * Installation deletion choice: fixed grants, never a generic capability API. * * @phpstan-impure * * @return bool Whether this current scope authorizes the explicit installation purge choice. */ public function valid_for_installation_purge(): bool { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $connection = $wpdb; $origin = Derived::native_context( $connection ); if ( self::$checking_capability || $this->network_view || get_current_user_id() !== $this->principal || (int) get_current_blog_id() !== $this->site_id || self::network() !== $this->network_id ) { return false; } if ( ! is_multisite() ) { return $this->valid_for_all( array( 'logged_ai_manage_settings', 'logged_ai_purge_data' ) ) && Derived::native_context( $connection ) === $origin; } if ( (int) get_main_network_id() !== $this->network_id ) { return false; } $main_site = (int) get_main_site_id( $this->network_id ); if ( $main_site < 1 || Derived::native_context( $connection ) !== $origin ) { return false; } $requirements = array( $this->site_id => array_values( array_unique( array( $this->capability, 'logged_ai_manage_settings' ) ) ) ); $requirements[ $main_site ] = array_values( array_unique( array_merge( $requirements[ $main_site ] ?? array(), array( 'logged_ai_purge_network_data', 'delete_plugins' ) ) ) ); $required = array_values( array_unique( array_merge( ...array_values( $requirements ) ) ) ); return self::can_all( $required, $main_site, $requirements ) && Derived::native_context( $connection ) === $origin; } /** * Create a network-origin scope after current-network and capability checks. * * Network-origin facts require a separate authority and never enter site queries. * * @phpstan-impure * * @param string $capability Native capability selected for the authorization decision. * * @return self|\WP_Error Current network authorization scope, or an access error. */ public static function current_network( string $capability = 'logged_ai_view_network_events' ): self|\WP_Error { if ( ! self::network_authorized() || ! in_array( $capability, self::NETWORK_CAPS, true ) || ! self::can( 'manage_network_options' ) || ! self::can( $capability ) ) { return new \WP_Error( 'logged_ai_forbidden', 'Network evidence access denied.', array( 'status' => 403 ) ); } return new self( $capability, true ); } /** * Check the account against current-network administrator storage or explicit override. * * Recheck the actual network, bypassing cached process-network super-admin lists. * * @phpstan-impure * * @param int $user_id Explicit account identifier, or zero for the current native account. * * @return bool Whether the selected account is an administrator of the actual current network. */ public static function network_authorized( int $user_id = 0 ): bool { global $wpdb, $super_admins; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ if ( ! is_multisite() || self::network() < 1 ) { return false; } $user = $user_id > 0 ? get_userdata( $user_id ) : wp_get_current_user(); if ( ! $user || ! $user->exists() ) { return false; } // Core supports an explicit installation-wide super-admin configuration. if ( isset( $super_admins ) ) { return is_array( $super_admins ) && in_array( $user->user_login, $super_admins, true ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Read current network super-admin membership; cached grants could survive revocation. $raw = $wpdb->get_var( $wpdb->prepare( "SELECT meta_value FROM %i WHERE site_id=%d AND meta_key='site_admins' ORDER BY meta_id LIMIT 1", $wpdb->sitemeta, self::network() ) ); if ( '' !== $wpdb->last_error ) { return false; } $admins = null === $raw ? array( 'admin' ) : maybe_unserialize( $raw ); return is_array( $admins ) && in_array( $user->user_login, $admins, true ); } /** * Build a network administration URL through the actual network main site. * * @param string $path Path appended within the actual network administration area. * * @return string Native administration URL for the selected network path. */ public static function network_admin_url( string $path = '' ): string { return get_admin_url( (int) get_main_site_id( self::network() ), 'network/' . ltrim( $path, '/' ) ); } /** * Resolve the actual network from the currently selected native site. * * Native switch_to_blog() does not change WordPress's process-level current network. * * @phpstan-impure * * @return int One on a single site, the selected site network, or zero when unavailable. */ public static function network(): int { if ( ! is_multisite() ) { return 1; } $site = get_site( get_current_blog_id() ); if ( ! $site ) { return 0; } return (int) $site->network_id; } /** * Check all requested permissions within one current captured authorization scope. * * Require a coherent native grant snapshot for every permission at one disclosure point. * * @phpstan-impure * * @param array $capabilities Bounded native capability names required together. * * @return bool Whether every allowlisted permission and the original scope remain authorized. */ public function valid_for_all( array $capabilities ): bool { if ( ! $capabilities || count( $capabilities ) > 12 || get_current_user_id() !== $this->principal || self::network() !== $this->network_id || ( ! $this->network_view && (int) get_current_blog_id() !== $this->site_id ) ) { return false; } // The optional add-on owns investigation grants; Free only understands this fixed authority name. $allowed = array_merge( $this->network_view ? self::NETWORK_CAPS : self::SITE_CAPS, array( 'manage_privacy_options', 'logged_ai_manage_investigations', 'activate_plugins', 'manage_network_plugins', 'update_plugins' ) ); foreach ( $capabilities as $capability ) { if ( ! is_string( $capability ) || ! in_array( $capability, $allowed, true ) ) { return false; } } $required = array_values( array_unique( array_merge( array( $this->capability ), $capabilities, $this->network_view ? array( 'manage_network_options' ) : array() ) ) ); $grant_site = $this->network_view && in_array( 'logged_ai_manage_investigations', $required, true ) ? (int) get_main_site_id( $this->network_id ) : 0; return self::can_all( $required, $grant_site ) && get_current_user_id() === $this->principal && self::network() === $this->network_id && ( $this->network_view || (int) get_current_blog_id() === $this->site_id ); } /** * Check a native editor's mapped primitives together with this site's read scope. * * The caller must still evaluate native object meta capabilities. This method * refreshes their bounded primitive grants in one coherent decision; it does * not cache approvals across objects or broaden the scope's own authority. * * @param array $capabilities Primitive names mapped by native object checks. * @return bool Whether the original site scope and all current grants agree. */ public function valid_for_native( array $capabilities ): bool { if ( $this->network_view || ! $capabilities || count( $capabilities ) > 12 || get_current_user_id() !== $this->principal || (int) get_current_blog_id() !== $this->site_id || self::network() !== $this->network_id ) { return false; } foreach ( $capabilities as $capability ) { if ( ! is_string( $capability ) || 'do_not_allow' === $capability || ! preg_match( '/\A[a-zA-Z0-9_]{1,100}\z/D', $capability ) ) { return false; } } $required = array_values( array_unique( array_merge( array( $this->capability ), $capabilities ) ) ); return self::can_all( $required ) && get_current_user_id() === $this->principal && (int) get_current_blog_id() === $this->site_id && self::network() === $this->network_id; } /** * Check one additional allowlisted permission while preserving this scope binding. * * A read scope never substitutes for a mutation/export permission. * * @phpstan-impure * * @param string $capability Native capability selected for the authorization decision. * * @return bool Whether the scope and requested permission remain authorized. */ public function valid_for( string $capability ): bool { if ( $this->network_view ) { return in_array( $capability, self::NETWORK_CAPS, true ) && $this->valid() && self::can( $capability ); } return in_array( $capability, self::SITE_CAPS, true ) && $this->valid() && self::can( $capability ); } }