array( 'category' => 'user' === $entity ? 'identity' : 'configuration', 'action' => $action, 'object_type' => $entity, 'severity' => $severity, 'fields' => $fields, 'diffs' => $diffs, 'event_scope' => $network ? 'network' : 'site', ); $network = array( 'target_network' => 'id' ); return array( 'configuration.role_definition_added' => $def( 'role_definition_added', 'site', array( 'role' => 'identifier' ) ), 'configuration.role_definition_removed' => $def( 'role_definition_removed', 'site', array( 'role' => 'identifier' ) ), 'configuration.role_capability_changed' => $def( 'role_cap_changed', 'site', array( 'role' => 'identifier', 'capability' => 'identifier', ), array( 'permission' => array( 'absent', 'granted', 'denied' ) ), false, 'high' ), 'identity.capability_changed' => $def( 'capability_changed', 'user', array( 'capability' => 'identifier' ), array( 'permission' => array( 'absent', 'granted', 'denied' ) ), false, 'high' ), 'identity.site_membership_added' => $def( 'membership_added', 'user', array( 'roles' => array( 'list' => 'identifier' ) ) ), 'identity.site_membership_removed' => $def( 'membership_removed', 'user', array( 'roles' => array( 'list' => 'identifier' ) ) ), 'identity.super_admin_granted' => $def( 'super_admin_granted', 'user', $network + array( 'effect_scope' => array( 'installation' ) ), array(), true, 'high' ), 'identity.super_admin_revoked' => $def( 'super_admin_revoked', 'user', $network + array( 'effect_scope' => array( 'installation' ) ), array(), true, 'high' ), 'network.site_created' => $def( 'site_created', 'site', $network + array( 'target_site' => 'id', 'initialization' => array( 'not_yet_observed' ), ), array(), true ), 'network.site_changed' => $def( 'site_changed', 'site', $network + array( 'target_site' => 'id' ), array_merge( array_fill_keys( self::FLAGS, 'bool' ), array( 'address' => 'bool', 'network' => 'id', ) ), true ), 'network.site_archived' => $def( 'site_archived', 'site', $network + array( 'target_site' => 'id' ), array( 'archived' => 'bool' ), true ), 'network.site_restored' => $def( 'site_restored', 'site', $network + array( 'target_site' => 'id' ), array( 'archived' => 'bool' ), true ), 'network.site_deleted' => $def( 'site_deleted', 'site', $network + array( 'target_site' => 'id' ), array(), true ), 'network.option_changed' => $def( 'network_option_changed', 'network', $network + array( 'option' => self::OPTIONS, 'change' => array( 'added', 'changed', 'deleted' ), 'registration_enabled' => 'bool', 'confirmation' => array( 'confirmed', 'not_applicable' ), ), array( 'value' => 'bool' ), true ), 'configuration.network_plugin_activated' => $def( 'network_plugin_on', 'plugin', $network + array( 'component' => 'identifier' ), array(), true ), 'configuration.network_plugin_deactivated' => $def( 'network_plugin_off', 'plugin', $network + array( 'component' => 'identifier' ), array(), true ), ); } /** * Register native metadata, role, network-option and site-lifecycle observers. * * @return void */ public static function boot(): void { self::role_hooks(); add_action( 'switch_blog', array( self::class, 'role_hooks' ), PHP_INT_MIN, 0 ); add_filter( 'pre_update_option', array( self::class, 'role_expected' ), PHP_INT_MAX, 3 ); foreach ( array( 'add', 'delete' ) as $operation ) { add_action( $operation . '_option', static function ( $name, $value = null ) use ( $operation ) { self::role_simple_before( $operation, $name, $value ); }, PHP_INT_MIN, 2 ); } add_action( 'updated_option', static function ( $name, $old, $value ) { self::role_after( 'update', $name, $value ); }, PHP_INT_MIN, 3 ); add_action( 'added_option', static function ( $name, $value ) { self::role_after( 'add', $name, $value ); }, PHP_INT_MIN, 2 ); add_action( 'deleted_option', static function ( $name ) { self::role_after( 'delete', $name, null ); }, PHP_INT_MIN ); foreach ( array( 'add', 'update', 'delete' ) as $operation ) { add_action( $operation . '_user_meta', static function ( ...$args ) use ( $operation ) { self::meta_before( $operation, false, $args ); }, PHP_INT_MIN, 4 ); add_action( $operation . '_user_meta', static function ( ...$args ) use ( $operation ) { self::meta_before( $operation, true, $args ); }, PHP_INT_MAX, 4 ); add_action( array( 'add' => 'added', 'update' => 'updated', 'delete' => 'deleted', )[ $operation ] . '_user_meta', static function ( ...$args ) use ( $operation ) { self::meta_after( $operation, $args ); }, PHP_INT_MIN, 4 ); } if ( ! is_multisite() ) { return; } foreach ( self::OPTIONS as $option ) { foreach ( array( 'add', 'update' ) as $operation ) { $hook = 'pre_' . $operation . '_site_option_' . $option; add_filter( $hook, static function ( $value, ...$args ) use ( $operation ) { self::option_before( $operation, false, $value, $args ); return $value; }, PHP_INT_MIN, 4 ); add_filter( $hook, static function ( $value, ...$args ) use ( $operation ) { self::option_before( $operation, true, $value, $args ); return $value; }, PHP_INT_MAX, 4 ); } add_action( 'pre_delete_site_option_' . $option, static function ( $name, $network ) { self::option_delete_before( $name, $network ); }, PHP_INT_MIN, 2 ); } add_action( 'add_site_option', static function ( $name, $value, $network ) { self::option_after( 'add', $name, $value, null, $network ); }, PHP_INT_MIN, 3 ); add_action( 'update_site_option', static function ( $name, $value, $old, $network ) { self::option_after( 'update', $name, $value, $old, $network ); }, PHP_INT_MIN, 4 ); add_action( 'delete_site_option', static function ( $name, $network ) { self::option_after( 'delete', $name, null, null, $network ); }, PHP_INT_MIN, 2 ); add_filter( 'can_add_user_to_blog', array( self::class, 'membership_actor' ), PHP_INT_MIN, 4 ); add_action( 'remove_user_from_blog', array( self::class, 'membership_remove_actor' ), PHP_INT_MIN, 2 ); add_action( 'activate_plugin', array( self::class, 'plugin_actor' ), PHP_INT_MIN, 2 ); add_action( 'deactivate_plugin', array( self::class, 'plugin_actor' ), PHP_INT_MIN, 2 ); add_action( 'grant_super_admin', array( self::class, 'super_actor' ), PHP_INT_MIN ); add_action( 'revoke_super_admin', array( self::class, 'super_actor' ), PHP_INT_MIN ); add_filter( 'wp_normalize_site_data', array( self::class, 'site_actor' ), PHP_INT_MIN ); add_action( 'wp_validate_site_data', array( self::class, 'site_before' ), PHP_INT_MAX, 3 ); add_action( 'wp_insert_site', array( self::class, 'site_inserted' ), PHP_INT_MIN ); add_action( 'wp_update_site', array( self::class, 'site_updated' ), PHP_INT_MIN, 2 ); add_action( 'wp_validate_site_deletion', array( self::class, 'site_delete_before' ), PHP_INT_MIN, 2 ); add_action( 'wp_delete_site', array( self::class, 'site_deleted' ), PHP_INT_MIN ); } /** * Validate a bounded metadata identifier and reject parent-directory sequences. * * @param mixed $value Supplied native value to validate or minimize. * * @return bool Whether the value satisfies the supported identifier grammar. */ private static function id( mixed $value ): bool { return is_string( $value ) && 1 === preg_match( '/\A[a-zA-Z0-9_.:\/-]{1,160}\z/D', $value ) && ! str_contains( $value, '..' ); } /** * Read the current native hook-stack depth for mutation matching. * * @return int Number of currently active hook entries. */ private static function depth(): int { global $wp_current_filter; return count( $wp_current_filter ); } /** * Count selected native function frames within the bounded backtrace. * * @param array $names Native function names whose visible calls should be counted. * * @return int Number of matching native calls visible in the trace. */ private static function native_depth( array $names ): int { $depth = 0; // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace -- Bounded argument-free frames bind nested native mutation attribution; no trace is persisted. foreach ( debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 32 ) as $frame ) { if ( in_array( $frame['function'], $names, true ) ) { ++$depth; } } return $depth; } /** * Capture the actor and current native site/network for a pending mutation. * * @return array Actor and origin fields used by completion checks. */ private static function context(): array { return array( 'actor' => Store::actor_context(), 'origin_site' => (int) get_current_blog_id(), 'origin_network' => Scope::network(), ); } /** * Retain one bounded mutation frame and report discarded request state. * * @param string $key Exact internal mutation or native metadata key. * @param array $frame Captured mutation fields and native origin. * * @return void */ private static function put( string $key, array $frame ): void { if ( count( self::$frames ) >= 128 ) { self::$frames = array(); Store::degraded( 'collector_context_incomplete' ); } self::$frames[ $key ] = $frame; } /** * Consume the pending mutation frame for an exact key. * * @param string $key Exact internal mutation or native metadata key. * * @return array|null Captured frame, or null when the key has no pending operation. */ private static function take( string $key ): ?array { $frame = self::$frames[ $key ] ?? null; unset( self::$frames[ $key ] ); return $frame; } /** * Check that the captured mutation origin still matches the current site/network. * * @param array $frame Captured mutation fields and native origin. * * @return bool Whether both origin identifiers remain current. */ private static function context_valid( array $frame ): bool { return (int) get_current_blog_id() === $frame['origin_site'] && Scope::network() === $frame['origin_network']; } /** * Describe an approved field change with optional redaction of both values. * * @param string $name Native option or approved field name. * @param mixed $before Native value or record observed before the mutation. * @param mixed $after Native value or record reported after the mutation. * @param bool $hidden Whether both field values must be redacted. * * @return array Captured or redacted before-and-after descriptor. */ private static function diff( string $name, mixed $before, mixed $after, bool $hidden = false ): array { return array( 'field' => $name, 'before' => $hidden ? null : $before, 'after' => $hidden ? null : $after, 'availability' => $hidden ? 'redacted' : 'captured', ); } /** * Capture a minimized mutation in its validated target site and restore any switch. * * Target IDs originate in validated native records, never HTTP filters. * * @param array $frame Captured mutation fields and native origin. * @param string $code Registered minimized event code. * @param int $entity Native object identifier associated with the event. * @param int|null $subject Affected user identifier, or null when no user subject applies. * @param array $facts Approved event facts within the registered schema. * @param array $diffs Approved minimized change descriptors. * * @return void */ private static function emit( array $frame, string $code, int $entity, ?int $subject, array $facts, array $diffs = array() ): void { if ( ! self::context_valid( $frame ) ) { Store::degraded( 'collector_context_incomplete' ); return; } $site = (int) $frame['target_site']; $network_id = (int) $frame['target_network']; if ( is_multisite() ) { $record = get_site( $site ); if ( ! $record || (int) $record->network_id !== $network_id ) { Store::degraded( 'collector_context_incomplete' ); return; } } $switched = (int) get_current_blog_id() !== $site; if ( $switched ) { switch_to_blog( $site ); } try { if ( Scope::network() !== $network_id ) { return; } Store::capture( array( 'code' => $code, 'object_id' => $entity, 'subject_id' => $subject, 'actor_context' => $frame['actor'], 'outcome' => 'stored_change', 'payload' => array( 'observed_via' => 'native_metadata_or_site_write', 'confidence' => 'successful_write_with_prewrite_snapshot', 'facts' => $facts, 'diffs' => $diffs, 'before_consistency' => 'prewrite_snapshot_not_serializable_against_concurrent_writes', ), ) ); } finally { if ( $switched ) { restore_current_blog(); } } } /** * Capture membership-operation attribution before native role mutation. * * @param mixed $allowed Native membership decision passed through without modification. * @param mixed $user Native user identifier associated with this operation. * @param mixed $role Requested native membership role; not retained by this actor snapshot. * @param mixed $site Selected native site identifier. * * @return mixed The original membership decision, unchanged. */ public static function membership_actor( mixed $allowed, mixed $user, mixed $role, mixed $site ): mixed { self::put( 'member:' . (int) $user . ':' . (int) $site . ':' . self::native_depth( array( 'add_user_to_blog', 'remove_user_from_blog' ) ), self::context() ); return $allowed; } /** * Capture attribution for native site-membership removal. * * @param mixed $user Native user identifier associated with this operation. * @param mixed $site Selected native site identifier. * * @return void */ public static function membership_remove_actor( mixed $user, mixed $site ): void { self::membership_actor( true, $user, null, $site ); } /** * Capture attribution for an eligible network plugin activation or deactivation. * * @param mixed $component Native plugin component identifier supplied to the lifecycle hook. * @param mixed $network Whether the native plugin operation applies network-wide. * * @return void */ public static function plugin_actor( mixed $component, mixed $network ): void { if ( $network && self::id( $component ) ) { self::put( 'plugin:' . $component . ':' . self::native_depth( array( 'activate_plugin', 'deactivate_plugins' ) ), self::context() ); } } /** * Resolve a native capability-meta key to an existing site and its network. * * @param string $key Exact internal mutation or native metadata key. * * @return array|null Trusted target identifiers, or null for an unsupported or missing site. */ private static function target( string $key ): ?array { global $wpdb; $prefix = $wpdb->base_prefix; if ( $key === $prefix . 'capabilities' ) { $site = 1; } elseif ( is_multisite() && 1 === preg_match( '/\A' . preg_quote( $prefix, '/' ) . '([1-9][0-9]{0,18})_capabilities\z/D', $key, $match ) ) { $site = (int) $match[1]; } elseif ( ! is_multisite() && $key === $wpdb->prefix . 'capabilities' ) { $site = (int) get_current_blog_id(); } else { return null; } $record = is_multisite() ? get_site( $site ) : null; if ( is_multisite() && ! $record ) { return null; } return array( 'target_site' => $site, 'target_network' => $record ? (int) $record->network_id : 1, ); } /** * Validate and sort a bounded map of native capability booleans. * * @param mixed $value Supplied native value to validate or minimize. * * @return array|null Normalized capability map, or null for unsupported input. */ private static function caps( mixed $value ): ?array { if ( ! is_array( $value ) || count( $value ) > 512 ) { return null; } foreach ( $value as $key => $flag ) { if ( ! self::id( $key ) || ! is_bool( $flag ) ) { return null; } } ksort( $value, SORT_STRING ); return $value; } /** * Read one bounded native capability-meta value without instantiating objects. * * @param int $user Native user identifier associated with this operation. * @param string $key Exact internal mutation or native metadata key. * * @return array|false Existence and capability fields, or false for a failed or incompatible read. */ private static function meta_read( int $user, string $key ): array|false { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Read exact persisted site-capability metadata before and after membership or grant mutations. $rows = $wpdb->get_col( $wpdb->prepare( 'SELECT meta_value FROM %i WHERE user_id=%d AND meta_key=%s LIMIT 2', $wpdb->usermeta, $user, $key ) ); if ( '' !== $wpdb->last_error || count( $rows ) > 1 || ( $rows && strlen( $rows[0] ) > 16384 ) ) { return false; } if ( ! $rows ) { return array( 'exists' => false, 'caps' => array(), ); } // 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. $caps = self::caps( @unserialize( $rows[0], array( 'allowed_classes' => false ) ) ); return null === $caps ? false : array( 'exists' => true, 'caps' => $caps, ); } /** * Normalize native metadata hook arguments for a supported mutation stage. * * @param string $operation Internal native mutation operation being observed. * @param array $args Native hook arguments for the selected mutation stage. * @param bool $after Whether these arguments come from the native completion hook. * * @return array|null User, key and value fields, or null when required arguments are unavailable. */ private static function meta_args( string $operation, array $args, bool $after = false ): ?array { if ( 'add' === $operation && ! $after ) { array_unshift( $args, 0 ); } if ( count( $args ) < 4 || ! is_string( $args[2] ) || (int) $args[1] < 1 ) { return null; } return array( 'user' => (int) $args[1], 'key' => $args[2], 'value' => $args[3], ); } /** * Capture early attribution and late intended capability state for a native write. * * @param string $operation Internal native mutation operation being observed. * @param bool $late Whether this is the final intended-value snapshot after native filters. * @param array $args Native hook arguments for the selected mutation stage. * * @return void */ public static function meta_before( string $operation, bool $late, array $args ): void { $args = self::meta_args( $operation, $args ); $target = $args ? self::target( $args['key'] ) : null; if ( ! $target ) { return; } $key = 'meta:' . $operation . ':' . $args['user'] . ':' . $args['key'] . ':' . self::depth(); if ( ! $late ) { $context = self::context(); $depth = self::native_depth( array( 'add_user_to_blog', 'remove_user_from_blog' ) ); $native = $depth ? self::take( 'member:' . $args['user'] . ':' . $target['target_site'] . ':' . $depth ) : null; if ( $native ) { if ( ! self::context_valid( $native ) ) { unset( self::$frames[ $key ] ); return; } $context = $native; } self::put( $key, $context + $target ); return; } $frame = self::$frames[ $key ] ?? null; $before = self::meta_read( $args['user'], $args['key'] ); $expected = 'delete' === $operation ? array() : self::caps( $args['value'] ); if ( ! $frame || ! self::context_valid( $frame ) || false === $before || null === $expected || ( 'add' === $operation && $before['exists'] ) ) { unset( self::$frames[ $key ] ); return; } $switched = get_current_blog_id() !== $target['target_site']; if ( $switched ) { switch_to_blog( $target['target_site'] ); } try { $known_roles = array_keys( wp_roles()->roles ); } finally { if ( $switched ) { restore_current_blog(); } } self::$frames[ $key ] = $frame + array( 'before' => $before, 'expected' => $expected, 'roles' => $known_roles, ); } /** * Verify persisted capability metadata before recording bounded membership and grant changes. * * @param string $operation Internal native mutation operation being observed. * @param array $args Native hook arguments for the selected mutation stage. * * @return void */ public static function meta_after( string $operation, array $args ): void { $args = self::meta_args( $operation, $args, true ); if ( ! $args ) { return; } $key = 'meta:' . $operation . ':' . $args['user'] . ':' . $args['key'] . ':' . self::depth(); $frame = self::take( $key ); if ( ! $frame || ! isset( $frame['before'] ) || ! self::context_valid( $frame ) ) { return; } $after = self::meta_read( $args['user'], $args['key'] ); if ( false === $after || ( 'delete' !== $operation ) !== $after['exists'] || $after['caps'] !== $frame['expected'] || ( 'delete' !== $operation && self::caps( $args['value'] ) !== $frame['expected'] ) ) { Store::degraded( 'collector_context_incomplete' ); return; } $before = $frame['before']; $roles = static fn( $caps ) => array_values( array_intersect( array_keys( array_filter( $caps ) ), $frame['roles'] ) ); $old_roles = $roles( $before['caps'] ); $new_roles = $roles( $after['caps'] ); sort( $old_roles ); sort( $new_roles ); if ( is_multisite() && $before['exists'] !== $after['exists'] ) { self::emit( $frame, $after['exists'] ? 'identity.site_membership_added' : 'identity.site_membership_removed', $args['user'], $args['user'], array( 'roles' => array_slice( $after['exists'] ? $new_roles : $old_roles, 0, 32 ) ) ); } // Roles owns ordinary current-site UPDATEs. Off-current writes need the trusted target scope. if ( 'update' === $operation && $frame['target_site'] !== $frame['origin_site'] && $old_roles !== $new_roles ) { switch_to_blog( $frame['target_site'] ); try { Store::role_change( $args['user'], $old_roles, $new_roles, $frame['actor'] ); } finally { restore_current_blog(); } } $changed = array(); foreach ( array_unique( array_merge( array_keys( $before['caps'] ), array_keys( $after['caps'] ) ) ) as $cap ) { if ( in_array( $cap, $frame['roles'], true ) ) { continue; } $old = array_key_exists( $cap, $before['caps'] ) ? ( $before['caps'][ $cap ] ? 'granted' : 'denied' ) : 'absent'; $new = array_key_exists( $cap, $after['caps'] ) ? ( $after['caps'][ $cap ] ? 'granted' : 'denied' ) : 'absent'; if ( $old !== $new ) { $changed[] = array( $cap, $old, $new ); } } if ( count( $changed ) > 32 ) { Store::degraded( 'collector_context_incomplete' ); return; } foreach ( $changed as [ $cap, $old, $new ] ) { self::emit( $frame, 'identity.capability_changed', $args['user'], $args['user'], array( 'capability' => $cap ), array( self::diff( 'permission', $old, $new ) ) ); } } /** * Read a bounded current-network option with explicit absence and failure states. * * @param string $option Allowlisted native network option name. * @param int $network Selected native network identifier. * * @return array|false Existence and decoded value fields, or false for a failed or incompatible read. */ private static function option_read( string $option, int $network ): array|false { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Read actual network-option presence and value for verified network mutation facts. $rows = $wpdb->get_col( $wpdb->prepare( 'SELECT meta_value FROM %i WHERE site_id=%d AND meta_key=%s LIMIT 2', $wpdb->sitemeta, $network, $option ) ); if ( '' !== $wpdb->last_error || count( $rows ) > 1 || ( $rows && strlen( $rows[0] ) > 16384 ) ) { return false; } return array( 'exists' => (bool) $rows, // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- Decode bounded persisted network option data with classes disabled for exact before/after observation; this never executes serialized objects or displays parser diagnostics. 'value' => $rows ? ( is_serialized( $rows[0] ) ? @unserialize( $rows[0], array( 'allowed_classes' => false ) ) : $rows[0] ) : null, ); } /** * Capture native super-administrator mutation attribution and its process network. * * @param mixed $user Native user identifier associated with this operation. * * @return void */ public static function super_actor( mixed $user ): void { self::put( 'super:' . (int) $user . ':' . self::native_depth( array( 'grant_super_admin', 'revoke_super_admin' ) ), self::context() + array( 'operation' => current_filter(), 'network' => (int) get_current_network_id(), ) ); } /** * Fingerprint a bounded native option value using its storage representation. * * @param mixed $value Supplied native value to validate or minimize. * * @return string|null Equality digest, or null when the value exceeds supported bounds. */ private static function option_value( mixed $value ): ?string { if ( is_object( $value ) || is_resource( $value ) || ( is_array( $value ) && count( $value ) > 256 ) ) { return null; } $encoded = wp_json_encode( $value ); if ( ! is_string( $encoded ) || strlen( $encoded ) > 16384 ) { return null; } // Native option SQL stores scalar booleans/integers as strings. $stored = (string) maybe_serialize( $value ); return strlen( $stored ) <= 16384 ? hash( 'sha256', $stored ) : null; } /** * Resolve a network to a verified main-site capture target. * * @param int $network Selected native network identifier. * * @return array|null Target site and network fields, or null when the relationship is unavailable. */ private static function network_target( int $network ): ?array { $site = (int) get_main_site_id( $network ); $record = get_site( $site ); return $record && (int) $record->network_id === $network ? array( 'target_site' => $site, 'target_network' => $network, ) : null; } /** * Capture network-option attribution and the final intended stored-value digest. * * @param string $operation Internal native mutation operation being observed. * @param bool $late Whether this is the final intended-value snapshot after native filters. * @param mixed $value Supplied native value to validate or minimize. * @param array $args Native hook arguments for the selected mutation stage. * * @return void */ public static function option_before( string $operation, bool $late, mixed $value, array $args ): void { $option = $args[ 'update' === $operation ? 1 : 0 ] ?? ''; $network = (int) ( $args[ 'update' === $operation ? 2 : 1 ] ?? 0 ); $target = in_array( $option, self::OPTIONS, true ) ? self::network_target( $network ) : null; if ( ! $target ) { return; } $key = 'option:' . $operation . ':' . $network . ':' . $option . ':' . self::depth(); if ( ! $late ) { self::put( $key, self::context() + $target ); return; } $frame = self::$frames[ $key ] ?? null; $before = self::option_read( $option, $network ); $digest = self::option_value( $value ); if ( ! $frame || ! self::context_valid( $frame ) || false === $before || null === $digest ) { unset( self::$frames[ $key ] ); return; } self::$frames[ $key ] = $frame + array( 'before' => $before, 'expected' => $digest, ); } /** * Capture an allowlisted network option before its native deletion. * * @param mixed $option Allowlisted native network option name. * @param mixed $network Selected native network identifier. * * @return void */ public static function option_delete_before( mixed $option, mixed $network ): void { $network = (int) $network; $target = is_string( $option ) && in_array( $option, self::OPTIONS, true ) ? self::network_target( $network ) : null; if ( ! $target ) { return; } $before = self::option_read( $option, $network ); if ( false !== $before ) { self::put( 'option:delete:' . $network . ':' . $option . ':' . self::depth(), self::context() + $target + array( 'before' => $before, 'expected' => null, ) ); } } /** * Verify the stored network mutation and emit bounded minimized configuration facts. * * @param string $operation Internal native mutation operation being observed. * @param mixed $option Allowlisted native network option name. * @param mixed $value Supplied native value to validate or minimize. * @param mixed $old Prior native option or site value supplied by the hook. * @param mixed $network Selected native network identifier. * * @return void */ public static function option_after( string $operation, mixed $option, mixed $value, mixed $old, mixed $network ): void { $network = (int) $network; if ( ! is_string( $option ) || ! in_array( $option, self::OPTIONS, true ) ) { return; } $frame = self::take( 'option:' . $operation . ':' . $network . ':' . $option . ':' . self::depth() ); if ( ! $frame || ! isset( $frame['before'] ) || ! self::context_valid( $frame ) ) { return; } $after = self::option_read( $option, $network ); if ( false === $after || ( 'delete' !== $operation ) !== $after['exists'] || ( 'delete' !== $operation && ( self::option_value( $value ) !== $frame['expected'] || self::option_value( $after['value'] ) !== $frame['expected'] ) ) ) { Store::degraded( 'collector_context_incomplete' ); return; } $before = $frame['before']['value']; $next = $after['value']; if ( 'site_admins' === $option ) { // Core defaults to the login 'admin' only when the row is absent. A // configured global override means option writes do not change authority. if ( isset( $GLOBALS['super_admins'] ) ) { return; } if ( ! $frame['before']['exists'] ) { $before = array( 'admin' ); } if ( ! $after['exists'] ) { $next = array( 'admin' ); } if ( ! is_array( $before ) || ! is_array( $next ) ) { Store::degraded( 'collector_context_incomplete' ); return; } foreach ( array_merge( $before, $next ) as $login ) { if ( ! is_string( $login ) || strlen( $login ) > 60 ) { return; } } } if ( 'site_admins' === $option || 'active_sitewide_plugins' === $option ) { $before = is_array( $before ) ? $before : array(); $next = is_array( $next ) ? $next : array(); if ( count( $before ) > 256 || count( $next ) > 256 ) { return; } $previous = 'site_admins' === $option ? $before : array_keys( $before ); $current = 'site_admins' === $option ? $next : array_keys( $next ); $removed = array_diff( $previous, $current ); $added = array_diff( $current, $previous ); if ( count( $removed ) + count( $added ) > 32 ) { Store::degraded( 'collector_context_incomplete' ); return; } foreach ( array( 'removed' => $removed, 'added' => $added, ) as $change => $items ) { foreach ( $items as $item ) { if ( ! is_string( $item ) ) { continue; } if ( 'site_admins' === $option ) { $user = get_user_by( 'login', $item ); if ( ! $user ) { continue; } $depth = self::native_depth( array( 'grant_super_admin', 'revoke_super_admin' ) ); $super = $depth ? self::take( 'super:' . (int) $user->ID . ':' . $depth ) : null; $event_frame = $frame; if ( $super ) { if ( $super['network'] !== $network || ! self::context_valid( $super ) || ( 'added' === $change ? 'grant_super_admin' : 'revoke_super_admin' ) !== $super['operation'] ) { continue; } $event_frame['actor'] = $super['actor']; } $origin = self::network_target( $event_frame['origin_network'] ); if ( ! $origin ) { continue; } $event_frame['target_site'] = $origin['target_site']; $event_frame['target_network'] = $origin['target_network']; self::emit( $event_frame, 'added' === $change ? 'identity.super_admin_granted' : 'identity.super_admin_revoked', (int) $user->ID, (int) $user->ID, array( 'target_network' => $network, 'effect_scope' => 'installation', ), array() ); } elseif ( self::id( $item ) ) { $depth = self::native_depth( array( 'activate_plugin', 'deactivate_plugins' ) ); $native = $depth ? self::take( 'plugin:' . $item . ':' . $depth ) : null; $event_frame = $frame; if ( $native ) { if ( ! self::context_valid( $native ) ) { continue; } $event_frame['actor'] = $native['actor']; } self::emit( $event_frame, 'added' === $change ? 'configuration.network_plugin_activated' : 'configuration.network_plugin_deactivated', 0, null, array( 'target_network' => $network, 'component' => $item, ), array() ); } } } return; } $facts = array( 'target_network' => $network, 'option' => $option, 'change' => array( 'add' => 'added', 'update' => 'changed', 'delete' => 'deleted', )[ $operation ], ); if ( 'registration' === $option ) { $facts['registration_enabled'] = in_array( $next, array( 'user', 'blog', 'all' ), true ); } if ( 'admin_email' === $option ) { $facts['confirmation'] = 'delete' !== $operation ? 'confirmed' : 'not_applicable'; } self::emit( $frame, 'network.option_changed', $network, null, $facts, array( self::diff( 'value', null, null, true ) ) ); } /** * Register the current site-role option observer once within the request bound. * * Dynamic first filter captures actor before any per-role-option callback. * * @return void */ public static function role_hooks(): void { global $wpdb; $name = $wpdb->get_blog_prefix( get_current_blog_id() ) . 'user_roles'; if ( isset( self::$role_hooks[ $name ] ) || count( self::$role_hooks ) >= 128 ) { return; } self::$role_hooks[ $name ] = true; add_filter( 'pre_update_option_' . $name, static function ( $value, $old, $option ) { if ( self::role_option( $option ) ) { self::put( 'role:update:' . $option . ':' . self::depth(), self::context() + array( 'target_site' => (int) get_current_blog_id(), 'target_network' => Scope::network(), ) ); } return $value; }, PHP_INT_MIN, 3 ); } /** * Check that an option name is the current native site-role storage key. * * @param mixed $name Native option or approved field name. * * @return bool Whether the supplied value identifies the current site-role option. */ private static function role_option( mixed $name ): bool { global $wpdb; return is_string( $name ) && $name === $wpdb->get_blog_prefix( get_current_blog_id() ) . 'user_roles'; } /** * Normalize bounded native role definitions to sorted capability maps. * * @param mixed $value Supplied native value to validate or minimize. * * @return array|null Role-to-capability map, or null for unsupported input. */ private static function role_map( mixed $value ): ?array { if ( ! is_array( $value ) || count( $value ) > 128 ) { return null; } $map = array(); $count = 0; foreach ( $value as $role => $data ) { if ( ! self::id( $role ) || ! is_array( $data ) || ! isset( $data['capabilities'] ) ) { return null; } $caps = self::caps( $data['capabilities'] ); if ( null === $caps ) { return null; } $count += count( $caps ); if ( $count > 4096 ) { return null; } $map[ $role ] = $caps; } ksort( $map, SORT_STRING ); return $map; } /** * Read bounded native role storage without instantiating serialized objects. * * @param string $name Native option or approved field name. * * @return array|false Existence and normalized role fields, or false when the read is incompatible. */ private static function role_read( string $name ): array|false { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Read actual persisted role definitions for native before and after verification. $raw = $wpdb->get_var( $wpdb->prepare( 'SELECT option_value FROM %i WHERE option_name=%s', $wpdb->options, $name ) ); if ( '' !== $wpdb->last_error || ( null !== $raw && strlen( $raw ) > 131072 ) ) { return false; } // 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. $map = null === $raw ? array() : self::role_map( @unserialize( $raw, array( 'allowed_classes' => false ) ) ); return null === $map ? false : array( 'exists' => null !== $raw, 'roles' => $map, ); } /** * Bind the final proposed role-option value to its captured native mutation. * * @param mixed $value Supplied native value to validate or minimize. * @param mixed $name Native option or approved field name. * @param mixed $old Prior native option or site value supplied by the hook. * * @return mixed The original native filter value, unchanged. */ public static function role_expected( mixed $value, mixed $name, mixed $old ): mixed { // 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. if ( ! self::role_option( $name ) ) { return $value; } $key = 'role:update:' . $name . ':' . self::depth(); $frame = self::$frames[ $key ] ?? null; $before = self::role_read( $name ); $expected = self::role_map( $value ); if ( ! $frame || ! self::context_valid( $frame ) || false === $before || null === $expected ) { unset( self::$frames[ $key ] ); return $value; } self::$frames[ $key ] = $frame + array( 'before' => $before, 'expected' => $expected, ); return $value; } /** * Capture a role-option addition or deletion with its expected role map. * * @param string $operation Internal native mutation operation being observed. * @param mixed $name Native option or approved field name. * @param mixed $value Supplied native value to validate or minimize. * * @return void */ public static function role_simple_before( string $operation, mixed $name, mixed $value ): void { if ( ! self::role_option( $name ) ) { return; } $before = self::role_read( $name ); $expected = 'delete' === $operation ? array() : self::role_map( $value ); if ( false === $before || null === $expected ) { return; } self::put( 'role:' . $operation . ':' . $name . ':' . self::depth(), self::context() + array( 'target_site' => (int) get_current_blog_id(), 'target_network' => Scope::network(), 'before' => $before, 'expected' => $expected, ) ); } /** * Verify stored role definitions before emitting bounded role and capability changes. * * @param string $operation Internal native mutation operation being observed. * @param mixed $name Native option or approved field name. * @param mixed $value Supplied native value to validate or minimize. * * @return void */ public static function role_after( string $operation, mixed $name, mixed $value ): void { if ( ! is_string( $name ) ) { return; } $frame = self::take( 'role:' . $operation . ':' . $name . ':' . self::depth() ); if ( ! $frame || ! isset( $frame['before'] ) || ! self::context_valid( $frame ) || ! self::role_option( $name ) ) { return; } $after = self::role_read( $name ); if ( false === $after || ( 'delete' !== $operation ) !== $after['exists'] || $after['roles'] !== $frame['expected'] || ( 'delete' !== $operation && self::role_map( $value ) !== $frame['expected'] ) ) { Store::degraded( 'collector_context_incomplete' ); return; } $before = $frame['before']['roles']; $next = $after['roles']; $events = array(); foreach ( array_unique( array_merge( array_keys( $before ), array_keys( $next ) ) ) as $role ) { if ( ! array_key_exists( $role, $before ) || ! array_key_exists( $role, $next ) ) { $events[] = array( 'code' => array_key_exists( $role, $next ) ? 'configuration.role_definition_added' : 'configuration.role_definition_removed', 'facts' => array( 'role' => $role ), 'diffs' => array(), ); } foreach ( array_unique( array_merge( array_keys( $before[ $role ] ?? array() ), array_keys( $next[ $role ] ?? array() ) ) ) as $cap ) { $old = array_key_exists( $cap, $before[ $role ] ?? array() ) ? ( $before[ $role ][ $cap ] ? 'granted' : 'denied' ) : 'absent'; $new = array_key_exists( $cap, $next[ $role ] ?? array() ) ? ( $next[ $role ][ $cap ] ? 'granted' : 'denied' ) : 'absent'; if ( $old !== $new ) { $events[] = array( 'code' => 'configuration.role_capability_changed', 'facts' => array( 'role' => $role, 'capability' => $cap, ), 'diffs' => array( self::diff( 'permission', $old, $new ) ), ); } if ( count( $events ) > 32 ) { Store::degraded( 'collector_context_incomplete' ); return; } } if ( count( $events ) > 32 ) { Store::degraded( 'collector_context_incomplete' ); return; } } foreach ( $events as $event ) { self::emit( $frame, $event['code'], $frame['target_site'], null, $event['facts'], $event['diffs'] ); } } /** * Count native site-insert and site-update calls in the bounded trace. * * @return int Number of matching site mutations visible in the trace. */ private static function site_depth(): int { $depth = 0; // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace -- Bounded argument-free site-insert/update frames bind the native mutation depth; no trace is persisted. foreach ( debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 32 ) as $frame ) { if ( in_array( $frame['function'], array( 'wp_insert_site', 'wp_update_site' ), true ) ) { ++$depth; } } return $depth; } /** * Capture site-mutation attribution at supported native call depths. * * @param mixed $data Native mutation fields supplied to the hook. * * @return mixed The original native filter value, unchanged. */ public static function site_actor( mixed $data ): mixed { $depth = self::site_depth(); if ( $depth > 0 && $depth <= 16 ) { self::put( 'site_actor:' . $depth, self::context() ); } return $data; } /** * Minimize site flags and network identity while hashing address fields. * * @param mixed $site Native site fields to validate and minimize. * * @return array|null Comparable minimized site fields, or null for incomplete input. */ private static function site_snapshot( mixed $site ): ?array { if ( ! is_array( $site ) ) { return null; } $result = array(); foreach ( array_merge( self::FLAGS, array( 'domain', 'path', 'network_id' ) ) as $key ) { if ( ! isset( $site[ $key ] ) || ! is_scalar( $site[ $key ] ) ) { return null; } $result[ $key ] = in_array( $key, array( 'domain', 'path' ), true ) ? hash( 'sha256', (string) $site[ $key ] ) : (int) $site[ $key ]; } return $result; } /** * Project native site fields needed for mutation verification. * * @param \WP_Site $site Native site object whose verification fields are projected. * * @return array Flags, network identity and transient address fields for snapshot minimization. */ private static function site_array( \WP_Site $site ): array { return array( 'network_id' => (int) $site->network_id, 'domain' => $site->domain, 'path' => $site->path, 'public' => $site->public, 'archived' => $site->archived, 'mature' => $site->mature, 'spam' => $site->spam, 'deleted' => $site->deleted, ); } /** * Capture validated intended site fields and the prior minimized snapshot. * * @param mixed $errors Native site validation result supplied by the hook. * @param mixed $data Native mutation fields supplied to the hook. * @param mixed $old Prior native option or site value supplied by the hook. * * @return void */ public static function site_before( mixed $errors, mixed $data, mixed $old ): void { $frame = self::take( 'site_actor:' . self::site_depth() ); $snapshot = self::site_snapshot( $data ); $target = $frame && self::context_valid( $frame ) && $snapshot && $errors instanceof \WP_Error && ! $errors->has_errors() ? self::network_target( $snapshot['network_id'] ) : null; if ( ! $target ) { return; } self::put( 'site:' . ( $old instanceof \WP_Site ? (int) $old->blog_id : 'new' ) . ':' . self::site_depth(), $frame + $target + array( 'before' => $old instanceof \WP_Site ? self::site_snapshot( self::site_array( $old ) ) : null, 'expected' => $snapshot, ) ); } /** * Route native site creation through stored-row verification. * * @param mixed $site Native site record supplied to the lifecycle hook. * * @return void */ public static function site_inserted( mixed $site ): void { self::site_after( $site, false ); } /** * Route native site updates through stored-row verification. * * @param mixed $site Native site record supplied to the lifecycle hook. * @param mixed $old Prior native option or site value supplied by the hook. * * @return void */ public static function site_updated( mixed $site, mixed $old ): 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. self::site_after( $site, true ); } /** * Verify persisted site fields before emitting creation, archive or change facts. * * @param mixed $site Native site record supplied to the lifecycle hook. * @param bool $update Whether the native operation updates an existing record. * * @return void */ private static function site_after( mixed $site, bool $update ): void { if ( ! $site instanceof \WP_Site ) { return; } $frame = self::take( 'site:' . ( $update ? (int) $site->blog_id : 'new' ) . ':' . self::site_depth() ); if ( ! $frame || ! self::context_valid( $frame ) || self::site_snapshot( self::site_array( $site ) ) !== $frame['expected'] ) { return; } global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Verify the actual site row matches the intended native mutation before emitting network facts. $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE blog_id=%d', $wpdb->blogs, $site->blog_id ), ARRAY_A ); if ( ! $row ) { return; } $row['network_id'] = $row['site_id']; if ( self::site_snapshot( $row ) !== $frame['expected'] ) { return; } $facts = array( 'target_site' => (int) $site->blog_id, 'target_network' => (int) $site->network_id, ); if ( ! $update ) { self::emit( $frame, 'network.site_created', (int) $site->blog_id, null, $facts + array( 'initialization' => 'not_yet_observed' ), array() ); return; } $diffs = array(); $archive = null; foreach ( self::FLAGS as $flag ) { if ( $frame['before'][ $flag ] !== $frame['expected'][ $flag ] ) { $diff = self::diff( $flag, (bool) $frame['before'][ $flag ], (bool) $frame['expected'][ $flag ] ); if ( 'archived' === $flag ) { $archive = $diff; } else { $diffs[] = $diff; } } } if ( $frame['before']['domain'] !== $frame['expected']['domain'] || $frame['before']['path'] !== $frame['expected']['path'] ) { $diffs[] = self::diff( 'address', null, null, true ); } if ( $frame['before']['network_id'] !== $frame['expected']['network_id'] ) { $diffs[] = self::diff( 'network', $frame['before']['network_id'], $frame['expected']['network_id'] ); } if ( $archive ) { self::emit( $frame, $archive['after'] ? 'network.site_archived' : 'network.site_restored', (int) $site->blog_id, null, $facts, array( $archive ) ); } if ( $diffs ) { self::emit( $frame, 'network.site_changed', (int) $site->blog_id, null, $facts, $diffs ); } } /** * Capture deletion attribution for a site with a distinct surviving main site. * * @param mixed $errors Native site validation result supplied by the hook. * @param mixed $site Native site record supplied to the lifecycle hook. * * @return void */ public static function site_delete_before( mixed $errors, mixed $site ): void { $target = $site instanceof \WP_Site ? self::network_target( (int) $site->network_id ) : null; if ( ! $target || (int) $site->blog_id === $target['target_site'] ) { return; } self::put( 'site_delete:' . (int) $site->blog_id, self::context() + $target ); } /** * Record native site deletion only after the site row is verified absent. * * @param mixed $site Native site record supplied to the lifecycle hook. * * @return void */ public static function site_deleted( mixed $site ): void { global $wpdb; if ( ! $site instanceof \WP_Site ) { return; } $frame = self::take( 'site_delete:' . (int) $site->blog_id ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Verify error-free physical site absence before recording native site deletion. $exists = $wpdb->get_var( $wpdb->prepare( 'SELECT blog_id FROM %i WHERE blog_id=%d', $wpdb->blogs, $site->blog_id ) ); if ( $frame && null === $exists && '' === $wpdb->last_error ) { self::emit( $frame, 'network.site_deleted', (int) $site->blog_id, null, array( 'target_site' => (int) $site->blog_id, 'target_network' => (int) $site->network_id, ), array() ); } } }