__( 'Logged.ai authenticated evidence reference (current site)', 'logged-ai' ), 'callback' => array( self::class, 'native_exporter' ), ); return $exporters; } ); add_filter( 'wp_privacy_personal_data_erasers', static function ( $erasers ) { $erasers['logged-ai'] = array( 'eraser_friendly_name' => __( 'Logged.ai activity evidence (current site)', 'logged-ai' ), 'callback' => array( self::class, 'eraser' ), ); return $erasers; } ); add_action( 'admin_init', static function () { if ( function_exists( 'wp_add_privacy_policy_content' ) ) { wp_add_privacy_policy_content( 'Logged.ai', '

' . esc_html__( 'Logged.ai stores selected administrative activity locally with user IDs, object IDs, timestamps, changed field names and supported minimized differences. Default retention is 90 days. Minimized request observations default to 7 days and bounded request groups and inactive response history to 30 days. Direct connection addresses default to masked; explicitly selected full addresses have a separate 14-day expiry, and disabled mode removes address context. Masked addresses are not individual identities. No request bodies, full query strings, cookies, passwords or authentication headers are retained. No vendor telemetry or cloud AI is required.', 'logged-ai' ) . '

' . esc_html__( 'Administrators control site retention and access. WordPress personal data export/erasure covers locally associated accounts on the current site; deleted accounts require their former user ID, and other sites need separate authorized handling. Erasure removes matching activity records and linked request address context. Already downloaded files cannot be recalled. Deactivation and uninstall preserve data unless installation-wide deletion was explicitly selected. These product defaults are not a legal compliance determination; adapt this text and settings to your actual configuration and use.', 'logged-ai' ) . '

' ); } } ); if ( defined( 'WP_CLI' ) && WP_CLI ) { \WP_CLI::add_command( 'logged-ai retention', array( self::class, 'cli' ) ); \WP_CLI::add_command( 'logged-ai privacy', array( self::class, 'privacy_cli' ) ); \WP_CLI::add_command( 'logged-ai status', array( self::class, 'status_cli' ) ); } } /** * Schedule the periodic site retention worker when absent. * * @return void */ public static function activate(): void { if ( ! wp_next_scheduled( 'logged_ai_retention' ) ) { wp_schedule_event( time() + 300, 'hourly', 'logged_ai_retention' ); } } /** * Schedule one near-term bounded site privacy batch when absent. * * @return void */ public static function schedule(): void { if ( ! wp_next_scheduled( 'logged_ai_privacy_batch' ) ) { wp_schedule_single_event( time() + 10, 'logged_ai_privacy_batch' ); } } /** * Require site settings authority before returning retention statistics. * * @param Scope $scope Current operation scope whose capabilities are rechecked. * * @return array|\WP_Error Current scope counts and cutoff, or an access/storage error. */ public static function stats( Scope $scope ): array|\WP_Error { if ( ! $scope->valid_for( 'logged_ai_manage_settings' ) ) { return new \WP_Error( 'forbidden', 'Retention access denied.' ); } return self::counts( $scope->network_id, $scope->site_id ); } /** * Compute the current site event-retention cutoff in UTC. * * @return string Second-resolution UTC cutoff from the configured event lifetime. */ private static function cutoff(): string|\WP_Error { $settings = Settings::current(); return is_wp_error( $settings ) ? $settings : gmdate( 'Y-m-d H:i:s', time() - $settings['event_days'] * DAY_IN_SECONDS ); } /** * Count scoped activity and current retention eligibility with database errors suppressed. * * @param int $network Selected network identifier. * @param int $site Selected site identifier. * * @return array|\WP_Error Scoped event counts and cutoff, or an explicit unavailable-count error. */ private static function counts( int $network, int $site ): array|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $old = $wpdb->suppress_errors( true ); try { $cutoff = self::cutoff(); if ( is_wp_error( $cutoff ) ) { return $cutoff; } $events_table = Store::table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Read current scoped event and retention counts; cached totals could misreport privacy work. $row = $wpdb->get_row( $wpdb->prepare( 'SELECT COUNT(*) AS event_count, COALESCE(SUM(occurred_at < %s),0) AS eligible_events FROM %i WHERE network_id=%d AND site_id=%d', $cutoff, $events_table, $network, $site ), ARRAY_A ); if ( '' !== $wpdb->last_error || ! is_array( $row ) ) { return new \WP_Error( 'storage', 'Retention counts are unavailable.' ); } return array( 'event_count' => (int) $row['event_count'], 'eligible_events' => (int) $row['eligible_events'], 'cutoff_utc' => $cutoff, 'network_id' => $network, 'site_id' => $site, 'batch_limit' => 500, ); } finally { $wpdb->suppress_errors( $old ); } } /** * Check remaining site work through a bounded current locking selection. * * Current bounded completion proof; an older caller snapshot cannot claim that no row remains. * * @param int $network Captured network identifier. * @param int $site Captured site identifier. * @param string|null $cutoff Retention cutoff, or null for identity erasure. * @param int|null $user User whose remaining identities must be checked. * * @return array|\WP_Error Completion and a count below 501, or a lower-bound sentinel or read error. */ private static function current_remaining( int $network, int $site, ?string $cutoff = null, ?int $user = null ): array|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ if ( ! Derived::mutation_allowed() ) { return new \WP_Error( 'forbidden', 'Privacy completion authority or scope changed.' ); } $ids = null !== $cutoff // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Lock authoritative remaining IDs; cached absence cannot prove privacy completion. The complete query binds every table and scope/retention or identity value. ? $wpdb->get_col( $wpdb->prepare( 'SELECT id FROM %i WHERE network_id=%d AND site_id=%d AND occurred_at < %s ORDER BY id LIMIT 501 FOR UPDATE', Store::table(), $network, $site, $cutoff ) ) // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Lock authoritative remaining IDs; cached absence cannot prove privacy completion. The complete query binds every table and scope/retention or identity value. : $wpdb->get_col( $wpdb->prepare( "SELECT id FROM %i WHERE network_id=%d AND site_id=%d AND (actor_id=%d OR subject_id=%d OR (object_type='user' AND object_id=%d)) ORDER BY id LIMIT 501 FOR UPDATE", Store::table(), $network, $site, $user, $user, $user ) ); /** * Validate current event IDs before reporting site privacy completion from the untyped wpdb adapter. * * @var mixed $ids */ if ( '' !== $wpdb->last_error || ! is_array( $ids ) ) { return new \WP_Error( 'storage', 'Current privacy completion could not be verified. Retry without assuming completion.' ); } if ( ! Derived::mutation_allowed() ) { return new \WP_Error( 'forbidden', 'Privacy completion authority or scope changed.' ); } $count = count( $ids ); return array( 'remaining' => $count < 501 ? $count : null, 'remaining_at_least' => $count, 'done' => 0 === $count, ); } /** * Authorize one bounded site event-retention batch. * * @param Scope $scope Current operation scope whose capabilities are rechecked. * @param int $limit Requested batch limit clamped to the operation's supported finite range. * * @return array|\WP_Error Cleanup progress, or an access/adapter/storage error. */ public static function prune( Scope $scope, int $limit = 500 ): array|\WP_Error { if ( ! $scope->valid_for( 'logged_ai_manage_settings' ) || ! $scope->valid_for( 'logged_ai_purge_data' ) ) { return new \WP_Error( 'forbidden', 'Evidence purge permission is required.', array( 'status' => 403 ) ); } return self::prune_site( $scope->network_id, $scope->site_id, $limit, Store::actor_context(), $scope ); } /** * Run site retention within the shared privacy mutation and optional operator authority. * * @param int $network Selected network identifier. * @param int $site Selected site identifier. * @param int $limit Requested batch limit clamped to the operation's supported finite range. * @param array $actor Captured actor context used only for the minimized retention summary. * @param Scope|null $scope Current operation scope whose capabilities are rechecked. Null selects the internal server adapter. * * @return array|\WP_Error Locked cleanup result, or an unavailable/current-authority error. */ private static function prune_site( int $network, int $site, int $limit, array $actor, ?Scope $scope = null ): array|\WP_Error { return Derived::with_mutation( static fn() => self::prune_locked( $network, $site, $limit, $actor, $scope ), $scope ? static fn() => $scope->valid_for_all( array( 'logged_ai_manage_settings', 'logged_ai_purge_data' ) ) : null ); } /** * Reduce dependent context before deleting exact expired source events and completing adapters. * * @param int $network Selected network identifier. * @param int $site Selected site identifier. * @param int $limit Requested batch limit clamped to the operation's supported finite range. * @param array $actor Captured actor context used only for the minimized retention summary. * @param Scope|null $scope Current operation scope whose capabilities are rechecked. Null selects the internal server adapter. * * @return array|\WP_Error Removed count, cutoff and bounded completion evidence, or an explicit cleanup/audit error. */ private static function prune_locked( int $network, int $site, int $limit, array $actor, ?Scope $scope ): array|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $limit = max( 1, min( 500, $limit ) ); $old = $wpdb->suppress_errors( true ); try { $settings = Settings::current(); if ( is_wp_error( $settings ) ) { return $settings; } $cutoff = gmdate( 'Y-m-d H:i:s', time() - $settings['event_days'] * DAY_IN_SECONDS ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Lock the exact current expired-source batch before reducing its dependent personal copies. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT id,uid,request_id FROM %i WHERE network_id=%d AND site_id=%d AND occurred_at < %s ORDER BY occurred_at,id LIMIT %d FOR UPDATE', Store::table(), $network, $site, $cutoff, $limit ), ARRAY_A ); if ( ! is_array( $rows ) || '' !== $wpdb->last_error ) { return new \WP_Error( 'storage', 'Retention could not load its batch.' ); } if ( $scope && ( ! $scope->valid_for( 'logged_ai_manage_settings' ) || ! $scope->valid_for( 'logged_ai_purge_data' ) ) ) { return new \WP_Error( 'forbidden', 'Evidence purge permission changed.', array( 'status' => 403 ) ); } if ( $rows && method_exists( Requests::class, 'erase_event_context' ) ) { // @phpstan-ignore function.alreadyNarrowedType (Requests is an optionally loaded module; retain its event-context cleanup capability gate.) $request_ids = array_values( array_unique( array_column( $rows, 'request_id' ) ) ); $context = $scope ? Requests::erase_event_context( $scope, $request_ids ) : Requests::erase_event_context_for_retention( $request_ids ); if ( is_wp_error( $context ) ) { Store::degraded( 'retention_failed' ); return $context; } if ( ! $context['done'] ) { self::schedule(); return array( 'removed' => 0, 'remaining' => null, 'done' => false, 'context_cleanup_remaining' => $context['remaining'], ); } } $count = 0; $derived = Derived::erase_events( $network, $site, $rows, array( 'kind' => 'retention' ) ); if ( is_wp_error( $derived ) ) { return $derived; } if ( ! $derived['done'] ) { self::schedule(); return array( 'removed' => 0, 'derived_removed' => $derived['removed'], 'remaining' => null, 'done' => false, 'derived_cleanup_pending' => true, ); } if ( $scope && ( ! $scope->valid_for( 'logged_ai_manage_settings' ) || ! $scope->valid_for( 'logged_ai_purge_data' ) ) ) { return new \WP_Error( 'forbidden', 'Evidence purge permission changed.', array( 'status' => 403 ) ); } if ( $rows ) { $ids = array_map( 'intval', array_column( $rows, 'id' ) ); $marks = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); // Delete exactly the rows whose linked personal context was scrubbed. // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The Store table uses native %i. marks contains one generated %d per bounded selected source ID after intval; network/site and all IDs are separate prepared arguments. Original source rows remain until dependent cleanup completes. Delete the exact stored source batch only after its dependent privacy cleanup completes. One native table %i and two scope %d placeholders precede N generated ID %d slots; the table, network, site and exactly N integer IDs are supplied in order. $count = $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE network_id=%d AND site_id=%d AND id IN ($marks)", Store::table(), $network, $site, ...$ids ) ); } if ( false === $count ) { Store::degraded( 'retention_failed' ); return new \WP_Error( 'storage', 'Retention failed; retry is safe.' ); } $completed = Derived::complete_events( $network, $site, $rows ); if ( is_wp_error( $completed ) || ! $completed['done'] ) { return is_wp_error( $completed ) ? $completed : new \WP_Error( 'privacy', 'Source privacy cleanup needs a retry.' ); } if ( $count > 0 && ! Store::capture( array( 'code' => 'privacy.retention_pruned', 'outcome' => 'completed', 'actor_context' => $actor, 'object_id' => $site, 'payload' => array( 'observed_via' => 'logged_ai_retention', 'facts' => array( 'removed_count' => $count, 'retention_days' => $settings['event_days'], ), ), ) ) ) { return new \WP_Error( 'audit_failed', 'Expired events were removed, but their summary could not be recorded.' ); } $remaining = self::current_remaining( $network, $site, $cutoff ); if ( is_wp_error( $remaining ) ) { return $remaining; } if ( ! $remaining['done'] ) { self::schedule(); } return array( 'removed' => $count, 'cutoff_utc' => $cutoff, 'scope' => array( 'network_id' => $network, 'site_id' => $site, ), ) + $remaining; } finally { $wpdb->suppress_errors( $old ); } } /** * Run bounded site and optional-module retention while recording finite health outcomes. * * @return void */ public static function cron(): void { $result = self::prune_site( Scope::network(), (int) get_current_blog_id(), 500, array( 'id' => 0, 'channel' => 'cron', ) ); if ( class_exists( Health::class ) ) { Health::record( 'retention', $result ); } // Operational health is bounded and independently expires; no arbitrary error text survives. $health = get_option( 'logged_ai_collection_health', array() ); $settings = Settings::current(); if ( ! is_wp_error( $settings ) && isset( $health['time_utc'] ) && is_string( $health['time_utc'] ) && $health['time_utc'] < gmdate( 'Y-m-d H:i:s', time() - $settings['diagnostic_days'] * DAY_IN_SECONDS ) ) { delete_option( 'logged_ai_collection_health' ); } // Request privacy cleanup has its own server-known cron scope, without assuming a logged-in user. if ( class_exists( Requests::class ) ) { $requests = Requests::retention(); if ( class_exists( Health::class ) ) { Health::record( 'request_retention', $requests ); } } if ( class_exists( AuthCollectors::class ) ) { AuthCollectors::retention(); AccountAuthCollectors::retention(); } $advanced = AdvancedAlertMaintenance::retention(); if ( is_wp_error( $advanced ) ) { Store::degraded( 'retention_failed' ); if ( class_exists( Health::class ) ) { Health::record( 'retention', $advanced ); } } elseif ( ! $advanced['done'] ) { self::schedule(); } $baseline = BaselinePrivacy::retention(); if ( is_wp_error( $baseline ) ) { Store::degraded( 'retention_failed' ); if ( class_exists( Health::class ) ) { Health::record( 'retention', $baseline ); } } elseif ( ! $baseline['done'] && ! wp_next_scheduled( 'logged_ai_privacy_batch' ) ) { if ( ! wp_schedule_single_event( time() + 60, 'logged_ai_privacy_batch' ) ) { Store::degraded( 'retention_failed' ); } } $incident = IncidentPrivacy::retention(); if ( is_wp_error( $incident ) ) { Store::degraded( 'retention_failed' ); if ( class_exists( Health::class ) ) { Health::record( 'retention', $incident ); } } elseif ( ! $incident['done'] && ! wp_next_scheduled( 'logged_ai_privacy_batch' ) ) { if ( ! wp_schedule_single_event( time() + 60, 'logged_ai_privacy_batch' ) ) { Store::degraded( 'retention_failed' ); } } if ( class_exists( ReportPrivacy::class ) ) { $report = ReportPrivacy::retention(); if ( is_wp_error( $report ) ) { Store::degraded( 'retention_failed' ); if ( class_exists( Health::class ) ) { Health::record( 'retention', $report ); } } elseif ( ! $report['done'] && ! wp_next_scheduled( 'logged_ai_privacy_batch' ) ) { if ( ! wp_schedule_single_event( time() + 60, 'logged_ai_privacy_batch' ) ) { Store::degraded( 'retention_failed' ); } } } if ( class_exists( IncidentRunnerPrivacy::class ) ) { $runner = IncidentRunnerPrivacy::retention(); if ( is_wp_error( $runner ) ) { Store::degraded( 'retention_failed' ); if ( class_exists( Health::class ) ) { Health::record( 'retention', $runner ); } } elseif ( ! $runner['done'] && ! wp_next_scheduled( 'logged_ai_privacy_batch' ) ) { if ( ! wp_schedule_single_event( time() + 60, 'logged_ai_privacy_batch' ) ) { Store::degraded( 'retention_failed' ); } } } } /** * Require WordPress privacy-tool authority and resolve the current site settings scope. * * @return Scope|\WP_Error Authorized current-site scope, or a privacy/settings access error. */ private static function privacy_scope(): Scope|\WP_Error { if ( ! Scope::can( 'manage_privacy_options' ) ) { return new \WP_Error( 'forbidden', 'Privacy tool access denied.' ); } return Scope::current( 'logged_ai_manage_settings' ); } /** * Return a protected administrator reference through the WordPress personal-data exporter. * * Core stores its export in public uploads. Do not duplicate audit facts into that sink. * * @param string $email Account email used transiently to resolve the current registered user. * @param int $page WordPress privacy page argument; the protected reference result completes in one response. * * @return array|\WP_Error One evidence-access reference, an empty account result, or an access error. */ public static function native_exporter( string $email, int $page = 1 ): array|\WP_Error { // 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. $scope = self::privacy_scope(); if ( is_wp_error( $scope ) ) { return $scope; } $user = get_user_by( 'email', $email ); if ( ! $user ) { return array( 'data' => array(), 'done' => true, ); } $reference = Exports::personal_reference( $scope, (int) $user->ID ); if ( is_wp_error( $reference ) ) { return $reference; } return array( 'data' => array( array( 'group_id' => 'logged-ai-private-evidence', 'group_label' => 'Logged.ai private evidence', 'item_id' => 'logged-ai-private-evidence', 'data' => array( array( 'name' => 'Private evidence access', 'value' => $reference['message'], ), array( 'name' => 'Administrator review', 'value' => esc_url_raw( $reference['url'] ), ), ), ), ), 'done' => true, ); } /** * Project one private evidence page and independently cursor-paged retained identity context. * * @param string $email Account email used transiently to resolve the current registered user. * @param int $page One-based bounded activity page; related adapters retain separate supplied cursors. * @param array $baseline_after Independent retained baseline decision and history cursors. * @param string $incident_after Retained incident-governance cursor. * @param array $report_after Independent saved-search, schedule and report-job cursors. * @param array $advanced_after Advanced alert identity-context cursor and binding state. * * @return array|\WP_Error Personal records, per-adapter cursors and combined completion, or an explicit error. */ public static function exporter( string $email, int $page = 1, array $baseline_after = array( 'decisions' => '0', 'history' => '0', ), string $incident_after = '0', array $report_after = array( 'searches' => '0', 'schedules' => '0', 'jobs' => '0', ), array $advanced_after = array() ): array|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $scope = self::privacy_scope(); if ( is_wp_error( $scope ) ) { return $scope; } if ( ! $scope->valid_for( 'logged_ai_export_events' ) ) { return new \WP_Error( 'forbidden', 'Evidence export access denied.' ); } $user = get_user_by( 'email', $email ); if ( ! $user ) { return array( 'data' => array(), 'done' => true, ); } if ( $page < 1 || $page > 1000000 ) { return new \WP_Error( 'page', 'Invalid privacy export page.' ); } $old = $wpdb->suppress_errors( true ); try { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Read current authorized personal event rows; cached evidence could survive privacy erasure. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE network_id=%d AND site_id=%d AND (actor_id=%d OR subject_id=%d OR (object_type=%s AND object_id=%d)) ORDER BY id LIMIT 100 OFFSET %d', Store::table(), $scope->network_id, $scope->site_id, $user->ID, $user->ID, 'user', $user->ID, ( $page - 1 ) * 100 ), ARRAY_A ); if ( '' !== $wpdb->last_error || ! is_array( $rows ) ) { return new \WP_Error( 'storage', 'Privacy export is unavailable. Retry without assuming completion.' ); } } finally { $wpdb->suppress_errors( $old ); } if ( ! $scope->valid_for_all( array( 'logged_ai_manage_settings', 'logged_ai_export_events', 'manage_privacy_options' ) ) ) { return new \WP_Error( 'forbidden', 'Privacy export permission or scope changed.', array( 'status' => 403 ) ); } $data = array(); foreach ( $rows as $row ) { $detail = Store::decode( $row ); $assessments = Derived::personal_projection( $scope, $row ); if ( is_wp_error( $assessments ) ) { return $assessments; } if ( ! is_array( $detail['detail'] ?? null ) ) { return new \WP_Error( 'storage', 'Stored privacy evidence is unavailable.' ); } $detail['detail']['linked_assessments'] = $assessments; if ( class_exists( IncidentRunnerPrivacy::class ) ) { $receipt = IncidentRunnerPrivacy::source_projection( $scope, $row ); if ( is_wp_error( $receipt ) ) { return $receipt; } $detail['detail']['incident_processing'] = $receipt; } $data[] = array( 'group_id' => 'logged-ai-events', 'group_label' => 'Logged.ai activity evidence', 'item_id' => 'logged-ai-event-' . $row['id'], 'data' => array( array( 'name' => 'Event ID', 'value' => (string) $row['id'], ), array( 'name' => 'Occurred (UTC)', 'value' => $row['occurred_at'], ), array( 'name' => 'Action', 'value' => $row['code'], ), array( 'name' => 'Outcome', 'value' => $row['outcome'], ), array( 'name' => 'Minimized evidence', 'value' => wp_json_encode( $detail, JSON_UNESCAPED_SLASHES ), ), ), ); } if ( 1 === $page ) { $temporary = AccountAuthCollectors::personal_projection( $scope, (int) $user->ID ); if ( is_wp_error( $temporary ) ) { return $temporary; } if ( $temporary['records'] ) { $data[] = array( 'group_id' => 'logged-ai-temporary-authentication', 'group_label' => 'Logged.ai temporary claimed-account authentication context', 'item_id' => 'logged-ai-temporary-authentication', 'data' => array( array( 'name' => 'Temporary context (not an audit event or actor attribution)', 'value' => wp_json_encode( $temporary, JSON_UNESCAPED_SLASHES ), ), ), ); } } if ( 1 === $page && class_exists( IncidentRunnerPrivacy::class ) ) { $runner = IncidentRunnerPrivacy::personal_projection( $scope, (int) $user->ID ); if ( is_wp_error( $runner ) ) { return $runner; } if ( $runner['control'] ) { $data[] = array( 'group_id' => 'logged-ai-incident-background', 'group_label' => 'Logged.ai incident background consent and progress', 'item_id' => 'logged-ai-incident-background', 'data' => array( array( 'name' => 'Stored operator configuration', 'value' => wp_json_encode( $runner, JSON_UNESCAPED_SLASHES ), ), ), ); } } $baseline = BaselinePrivacy::personal_projection( $scope, (int) $user->ID, $baseline_after ); if ( is_wp_error( $baseline ) ) { return $baseline; } if ( $baseline['baseline'] || $baseline['decisions'] || $baseline['history'] ) { $data[] = array( 'group_id' => 'logged-ai-baseline-governance', 'group_label' => 'Logged.ai retained baseline and governance', 'item_id' => 'logged-ai-baseline-page-' . $page, 'data' => array( array( 'name' => 'Baseline and governance page', 'value' => wp_json_encode( $baseline, JSON_UNESCAPED_SLASHES ), ), ), ); } $incident = IncidentPrivacy::personal_projection( $scope, (int) $user->ID, $incident_after ); if ( is_wp_error( $incident ) ) { return $incident; } if ( $incident['records'] ) { $data[] = array( 'group_id' => 'logged-ai-incident-governance', 'group_label' => 'Logged.ai retained incident relationships and governance', 'item_id' => 'logged-ai-incident-page-' . $page, 'data' => array( array( 'name' => 'Incident relationships and governance page', 'value' => wp_json_encode( $incident, JSON_UNESCAPED_SLASHES ), ), ), ); } $report = class_exists( ReportPrivacy::class ) ? ReportPrivacy::personal_projection( $scope, (int) $user->ID, $report_after ) : array( 'searches' => array(), 'schedules' => array(), 'jobs' => array(), 'done' => true, 'next' => $report_after, ); if ( is_wp_error( $report ) ) { return $report; } if ( $report['searches'] || $report['schedules'] || $report['jobs'] ) { $data[] = array( 'group_id' => 'logged-ai-report-configuration', 'group_label' => 'Logged.ai retained saved searches and report schedules', 'item_id' => 'logged-ai-report-configuration-page-' . $page, 'data' => array( array( 'name' => 'Saved search and report schedule page', 'value' => wp_json_encode( $report, JSON_UNESCAPED_SLASHES ), ), ), ); } $advanced = AdvancedAlertIdentityPrivacy::personal_projection( $scope, (int) $user->ID, $advanced_after ); if ( is_wp_error( $advanced ) ) { return $advanced; } $advanced_next = $advanced['next']; $advanced_binding = $advanced['binding']; unset( $advanced['next'], $advanced['binding'] ); $case_progress = AdvancedAlertCasePrivacy::personal_projection( $scope, (int) $user->ID ); if ( is_wp_error( $case_progress ) ) { return $case_progress; } $advanced['case_privacy'] = $case_progress; if ( $advanced['records'] || $advanced['progress'] || $case_progress['progress'] ) { $data[] = array( 'group_id' => 'logged-ai-advanced-alerts', 'group_label' => 'Logged.ai retained advanced alert context', 'item_id' => 'logged-ai-advanced-alerts-page-' . $page, 'data' => array( array( 'name' => 'Retained alert context (credentials excluded)', 'value' => wp_json_encode( $advanced, JSON_UNESCAPED_SLASHES ), ), ), ); } if ( ! $scope->valid_for_all( array( 'logged_ai_manage_settings', 'logged_ai_export_events', 'manage_privacy_options' ) ) ) { return new \WP_Error( 'forbidden', 'Privacy export permission or scope changed before its response.' ); } $current_binding = AdvancedAlertIdentityPrivacy::context_binding( $scope ); if ( is_wp_error( $current_binding ) ) { return $current_binding; } if ( ! hash_equals( $advanced_binding, $current_binding ) ) { return new \WP_Error( 'privacy_changed', 'Advanced alert privacy context changed before export disclosure.' ); } return array( 'data' => $data, 'done' => count( $rows ) < 100 && $baseline['done'] && $incident['done'] && $report['done'] && $advanced['done'], 'baseline_next' => $baseline['next'], 'incident_next' => $incident['next'], 'report_next' => $report['next'], 'advanced_next' => $advanced_next, ); } /** * Adapt current-site account erasure to the WordPress privacy callback format. * * @param string $email Account email used transiently to resolve the current registered user. * @param int $page Native privacy callback page argument; this adapter performs one bounded step. * * @return array|\WP_Error Native removed/progress flags and bounded-cleanup guidance, or an erasure error. */ public static function eraser( string $email, int $page = 1 ): array|\WP_Error { // 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. $scope = self::privacy_scope(); if ( is_wp_error( $scope ) ) { return $scope; } if ( ! $scope->valid_for( 'logged_ai_purge_data' ) ) { return new \WP_Error( 'forbidden', 'Evidence purge permission is required.', array( 'status' => 403 ) ); } $user = get_user_by( 'email', $email ); if ( ! $user ) { return array( 'items_removed' => false, 'items_retained' => false, 'messages' => array(), 'done' => true, ); } $result = self::erase_user( $scope, (int) $user->ID ); if ( is_wp_error( $result ) ) { return $result; } return array( 'items_removed' => $result['removed'] > 0 || ( $result['derived_removed'] ?? 0 ) > 0, 'items_retained' => false, 'messages' => array( 'Matching activity, temporary account-target counts, linked assessments and baseline governance are removed from this site in bounded steps. Previously downloaded files cannot be recalled.' ), 'done' => $result['done'], ); } /** * Preview one original-event batch through an independent current reader. * * Preview one bounded original-event selection without invoking privacy cleanup. * * @param Scope $scope Current operation scope whose capabilities are rechecked. * @param int $user Positive current or former user identifier selected for privacy handling. * @param int $limit Requested batch limit clamped to the operation's supported finite range. * * @return array|\WP_Error Selected source-row count and effect limitations, or an authority/identity/read error. */ public static function erase_user_preview( Scope $scope, int $user, int $limit = 100 ): array|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ $caller = $wpdb; $origin = Derived::native_context( $caller ); $current = static fn(): bool => ( $GLOBALS['wpdb'] ?? null ) === $caller && Derived::native_context( $caller ) === $origin; $network = 0 === $scope->site_id; $caps = $network ? array( 'logged_ai_manage_network_settings', 'logged_ai_purge_network_data', 'manage_privacy_options' ) : array( 'logged_ai_manage_settings', 'logged_ai_purge_data', 'manage_privacy_options' ); $allowed = static fn(): bool => $current() && $scope->valid_for_all( $caps ) && $current(); if ( ! $allowed() || ( $network && ( ! is_multisite() || ( class_exists( Migrations::class ) && ! Migrations::ready( 'network' ) ) ) ) || ! $current() ) { return new \WP_Error( 'forbidden', 'Privacy preview requires current privacy and evidence purge permissions.', array( 'status' => 403 ) ); } if ( $user < 1 ) { return new \WP_Error( 'identity', 'Use a positive user ID.' ); } $limit = max( 1, min( 500, $limit ) ); if ( ! class_exists( PreviewConnection::class, false ) ) { require_once __DIR__ . '/class-previewconnection.php'; } $table = Store::table(); // Use an independent current reader: snapshot-conflict errors must never // abort a transaction owned by the caller. Original erasure is unchanged. return PreviewConnection::run( $allowed, static function ( \wpdb $reader ) use ( $table, $scope, $user, $limit, $network ): array|\WP_Error { $rows = PreviewConnection::rows( $reader, $reader->prepare( 'SELECT id FROM %i WHERE network_id=%d AND site_id=%d AND (actor_id=%d OR subject_id=%d OR (object_type=%s AND object_id=%d)) ORDER BY id LIMIT %d FOR UPDATE', $table, $scope->network_id, $scope->site_id, $user, $user, 'user', $user, $limit ), array( 'id' ), $limit ); if ( is_wp_error( $rows ) ) { return $rows; } return array( 'preview_version' => 1, 'dry_run' => true, 'network_id' => (string) $scope->network_id, 'site_id' => (string) $scope->site_id, 'user_id' => (string) $user, 'batch_limit' => $limit, 'selected_event_rows' => count( $rows ), 'more_events_may_remain' => count( $rows ) === $limit, 'complete_effect_estimate' => false, 'dependent_cleanup' => $network ? 'Not enumerated. Execution first invalidates matching current-network alerts and exports, then reduces linked request context and derived copies before deleting eligible network-origin events. Other sites and networks require separate authorized handling.' : 'Not enumerated. Execution first invalidates matching alerts and exports and reduces retained identities, reports, request context, account-target counts, incidents, baselines and derived copies before deleting eligible site events.', 'selection_semantics' => 'At most one original-event batch selected now by actor, subject or user-object identity. Pending cleanup may prevent any source deletion on a later invocation. A zero event count does not prove that no other personal data remains. This preview makes no changes and reserves no future result. Previously downloaded bytes cannot be recalled.', ); }, array( $table ) ); } /** * Run bounded current-site identity erasure under the shared authorized mutation. * * @param Scope $scope Current operation scope whose capabilities are rechecked. * @param int $user Positive current or former user identifier selected for privacy handling. * @param int $limit Requested batch limit clamped to the operation's supported finite range. * * @return array|\WP_Error Cleanup progress, or a current-authority or downstream adapter error. */ public static function erase_user( Scope $scope, int $user, int $limit = 100 ): array|\WP_Error { return Derived::with_mutation( static fn() => self::erase_user_locked( $scope, $user, $limit ), static fn() => $scope->valid_for_all( array( 'logged_ai_manage_settings', 'logged_ai_purge_data', 'manage_privacy_options' ) ) ); } /** * Reduce retained personal context before deleting matching source events and completing cleanup. * * @param Scope $scope Current operation scope whose capabilities are rechecked. * @param int $user Positive current or former user identifier selected for privacy handling. * @param int $limit Requested batch limit clamped to the operation's supported finite range. * * @return array|\WP_Error Source/adapter-reported removal progress and completion, or explicit incomplete/error results. */ private static function erase_user_locked( Scope $scope, int $user, int $limit ): array|\WP_Error { global $wpdb; /** * Current WordPress database adapter used by this operation. * * @var \wpdb $wpdb */ if ( ! $scope->valid_for( 'logged_ai_manage_settings' ) || ! $scope->valid_for( 'logged_ai_purge_data' ) || ! Scope::can( 'manage_privacy_options' ) ) { return new \WP_Error( 'forbidden', 'Privacy erasure requires privacy and evidence purge permissions.', array( 'status' => 403 ) ); } if ( $user < 1 ) { return new \WP_Error( 'identity', 'Use a positive user ID.' ); } $alerts = Alerts::invalidate_site( $scope, $user ); if ( is_wp_error( $alerts ) ) { return $alerts; } if ( ! $alerts ) { return array( 'removed' => 0, 'remaining' => null, 'done' => false, ); } $exports_done = Exports::invalidate_site( $scope, $user ); if ( is_wp_error( $exports_done ) ) { return $exports_done; } if ( ! $exports_done ) { return array( 'removed' => 0, 'remaining' => null, 'done' => false, ); } $advanced = AdvancedAlertIdentityPrivacy::erase_user( $scope, $user ); if ( is_wp_error( $advanced ) ) { return $advanced; } if ( ! $advanced['done'] ) { return array( 'removed' => 0, 'derived_removed' => $advanced['removed'], 'remaining' => null, 'done' => false, 'advanced_alert_cleanup_pending' => true, ); } $runner = class_exists( IncidentRunnerPrivacy::class ) ? IncidentRunnerPrivacy::erase_user( $scope, $user ) : array( 'removed' => 0, 'done' => true, ); if ( is_wp_error( $runner ) ) { return $runner; } $runner['removed'] += $advanced['removed']; if ( ! $runner['done'] ) { return array( 'removed' => 0, 'derived_removed' => $runner['removed'], 'remaining' => null, 'done' => false, 'incident_runner_cleanup_pending' => true, ); } $report = class_exists( ReportPrivacy::class ) ? ReportPrivacy::erase_user( $scope, $user ) : array( 'removed' => 0, 'done' => true, ); if ( is_wp_error( $report ) ) { return $report; } $report['removed'] += $runner['removed']; if ( ! $report['done'] ) { return array( 'removed' => 0, 'derived_removed' => $report['removed'], 'remaining' => null, 'done' => false, 'report_cleanup_pending' => true, ); } if ( method_exists( Requests::class, 'erase_user' ) ) { // @phpstan-ignore function.alreadyNarrowedType (Requests is an optionally loaded module; retain its user-cleanup capability gate.) $requests = Requests::erase_user( $scope, $user, $limit ); if ( is_wp_error( $requests ) ) { return $requests; } if ( ! $requests['done'] ) { return array( 'removed' => (int) ( $requests['updated'] ?? 0 ), 'derived_removed' => $report['removed'], 'remaining' => (int) $requests['remaining'], 'done' => false, ); } } $temporary = AccountAuthCollectors::erase_user( $scope, $user ); if ( is_wp_error( $temporary ) ) { return $temporary; } $incident = IncidentPrivacy::erase_user( $scope, $user ); if ( is_wp_error( $incident ) ) { return $incident; } $incident['removed'] += $report['removed']; if ( ! $incident['done'] ) { return array( 'removed' => 0, 'derived_removed' => $incident['removed'] + $temporary, 'temporary_auth_removed' => $temporary, 'remaining' => null, 'done' => false, 'incident_cleanup_pending' => true, ); } $baseline = BaselinePrivacy::erase_user( $scope, $user ); if ( is_wp_error( $baseline ) ) { return $baseline; } $baseline['removed'] += $incident['removed']; if ( ! $baseline['done'] ) { return array( 'removed' => 0, 'derived_removed' => $baseline['removed'] + $temporary, 'temporary_auth_removed' => $temporary, 'remaining' => null, 'done' => false, 'baseline_cleanup_pending' => true, ); } $limit = max( 1, min( 500, $limit ) ); if ( ! $scope->valid_for( 'logged_ai_manage_settings' ) || ! $scope->valid_for( 'logged_ai_purge_data' ) || ! Scope::can( 'manage_privacy_options' ) ) { return new \WP_Error( 'forbidden', 'Privacy erasure permission changed.', array( 'status' => 403 ) ); } $old = $wpdb->suppress_errors( true ); try { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Lock the current personal source batch before clearing its dependent identities. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT id,uid FROM %i WHERE network_id=%d AND site_id=%d AND (actor_id=%d OR subject_id=%d OR (object_type='user' AND object_id=%d)) ORDER BY id LIMIT %d FOR UPDATE", Store::table(), $scope->network_id, $scope->site_id, $user, $user, $user, $limit ), ARRAY_A ); /** * Validate site event references before running their bounded erasure batch from the untyped wpdb adapter. * * @var mixed $rows */ if ( ! is_array( $rows ) || '' !== $wpdb->last_error ) { return new \WP_Error( 'storage', 'Privacy erasure could not load its bounded event references.' ); } $derived = Derived::erase_events( $scope->network_id, $scope->site_id, $rows, array( 'kind' => 'user', 'subject' => $user, ) ); if ( is_wp_error( $derived ) ) { return $derived; } if ( ! $derived['done'] ) { return array( 'removed' => 0, 'derived_removed' => $derived['removed'] + $temporary + $baseline['removed'], 'temporary_auth_removed' => $temporary, 'remaining' => null, 'done' => false, 'derived_cleanup_pending' => true, ); } if ( ! $scope->valid_for_all( array( 'logged_ai_manage_settings', 'logged_ai_purge_data', 'manage_privacy_options' ) ) ) { return new \WP_Error( 'forbidden', 'Privacy erasure permission changed.' ); } $removed = 0; if ( $rows ) { $ids = array_column( $rows, 'id' ); $marks = implode( ',', array_fill( 0, count( $ids ), '%s' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The preceding prepare binds the fixed current-scope identity predicate; only bounded source IDs and a native identifier are bound here. The Store table uses %i; where was prepared from current scope/user identity using fixed fields and operators. marks is one generated %s per bounded selected source ID, supplied separately. All interpolated syntax is fixed or already prepared. Erase actual selected personal source rows after dependent cleanup and current authority checks. The only unbound fields are one table %i and N generated ID %s slots; arguments are the table then N selected IDs, while where is already prepared. $removed = $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE network_id=%d AND site_id=%d AND (actor_id=%d OR subject_id=%d OR (object_type='user' AND object_id=%d)) AND id IN ($marks)", Store::table(), $scope->network_id, $scope->site_id, $user, $user, $user, ...$ids ) ); } if ( false === $removed ) { Store::degraded( 'privacy_failed' ); return new \WP_Error( 'storage', 'Privacy deletion failed. Retry is safe.' ); } $completed = Derived::complete_events( $scope->network_id, $scope->site_id, $rows ); if ( is_wp_error( $completed ) || ! $completed['done'] ) { return is_wp_error( $completed ) ? $completed : new \WP_Error( 'privacy', 'Source privacy cleanup needs a retry.' ); } // No erased ID/email is placed in the summary, even for a self-erasure by an administrator. if ( $removed > 0 && ! Store::capture( array( 'code' => 'privacy.identity_erased', 'outcome' => 'completed', 'actor_context' => array( 'id' => 0, 'channel' => Store::channel(), ), 'object_id' => $scope->site_id, 'payload' => array( 'observed_via' => 'logged_ai_privacy', 'facts' => array( 'removed_count' => $removed ), ), ) ) ) { return new \WP_Error( 'audit_failed', 'Identity records were removed, but the erasure summary failed.' ); } $remaining = self::current_remaining( $scope->network_id, $scope->site_id, null, $user ); if ( is_wp_error( $remaining ) ) { return $remaining; } return array( 'removed' => $removed, 'derived_removed' => $derived['removed'] + $temporary + $baseline['removed'], 'temporary_auth_removed' => $temporary, ) + $remaining; } finally { $wpdb->suppress_errors( $old ); } } /** * Dispatch explicit current-site retention inspection or one confirmed prune batch. * * @param array $args Positional WP-CLI command arguments. * @param array $assoc Named WP-CLI operation flags and filters. * * @return void */ public static function cli( array $args, array $assoc ): void { $scope = Scope::current( 'logged_ai_manage_settings' ); if ( is_wp_error( $scope ) ) { \WP_CLI::error( PresentationTranslations::text( $scope->get_error_message() ) ); } $operation = $args[0] ?? 'dry-run'; if ( 'dry-run' === $operation && ! $assoc ) { $result = self::stats( $scope ); } elseif ( 'prune' === $operation && isset( $assoc['yes'] ) && ! array_diff( array_keys( $assoc ), array( 'yes' ) ) ) { $result = self::prune( $scope ); } else { \WP_CLI::error( __( 'Use retention dry-run, or retention prune --yes. Include --url= --user=. One bounded batch per invocation.', 'logged-ai' ) ); } if ( is_wp_error( $result ) ) { \WP_CLI::error( PresentationTranslations::text( $result->get_error_message() ) ); } \WP_CLI::line( wp_json_encode( $result, JSON_PRETTY_PRINT ) ); } /** * Require an exact preview or execution flag for one current-site user-erasure batch. * * @param array $args Positional WP-CLI command arguments. * @param array $assoc Named WP-CLI operation flags and filters. * * @return void */ public static function privacy_cli( array $args, array $assoc ): void { $scope = Scope::current( 'logged_ai_manage_settings' ); if ( is_wp_error( $scope ) || ! Scope::can( 'manage_privacy_options' ) ) { \WP_CLI::error( __( 'Privacy erasure requires an authorized administrator.', 'logged-ai' ) ); } $dry_run = array( 'dry-run' => true ) === $assoc; if ( 'erase-user' !== ( $args[0] ?? '' ) || 2 !== count( $args ) || ! is_string( $args[1] ?? null ) || ! preg_match( '/\A[1-9][0-9]{0,17}\z/D', $args[1] ) || ( ! $dry_run && array( 'yes' => true ) !== $assoc ) ) { \WP_CLI::error( __( 'Use privacy erase-user ID with exactly --dry-run or --yes, --url= and --user=. One bounded batch per invocation.', 'logged-ai' ) ); } $result = $dry_run ? self::erase_user_preview( $scope, (int) $args[1] ) : self::erase_user( $scope, (int) $args[1] ); if ( is_wp_error( $result ) ) { \WP_CLI::error( PresentationTranslations::text( $result->get_error_message() ) ); } \WP_CLI::line( wp_json_encode( $result, JSON_PRETTY_PRINT ) ); } /** * Print scoped operational health or the supported retention fallback without mutation. * * @param array $args Positional WP-CLI command arguments. * @param array $assoc Named WP-CLI operation flags and filters. * * @return void */ public static function status_cli( array $args, array $assoc ): void { $scope = Scope::current( 'logged_ai_manage_settings' ); if ( is_wp_error( $scope ) || $args || $assoc ) { \WP_CLI::error( __( 'Use logged-ai status --user= --url=.', 'logged-ai' ) ); } if ( class_exists( Health::class ) ) { $snapshot = Health::snapshot( $scope ); if ( is_wp_error( $snapshot ) ) { \WP_CLI::error( PresentationTranslations::text( $snapshot->get_error_message() ) ); } \WP_CLI::line( wp_json_encode( $snapshot, JSON_PRETTY_PRINT ) ); return; } $stats = self::stats( $scope ); if ( is_wp_error( $stats ) ) { \WP_CLI::error( PresentationTranslations::text( $stats->get_error_message() ) ); } \WP_CLI::line( wp_json_encode( array( 'status' => 'release', 'version' => LOGGED_AI_VERSION, 'retention' => $stats, 'collection_health' => get_option( 'logged_ai_collection_health', array( 'state' => 'no_recorded_degradation' ) ), 'next_retention_utc' => wp_next_scheduled( 'logged_ai_retention' ) ? gmdate( 'c', wp_next_scheduled( 'logged_ai_retention' ) ) : null, 'schema_version' => (int) get_option( 'logged_ai_schema_version' ), ), JSON_PRETTY_PRINT ) ); } }