Export Personal Data / Erase Personal Data) so a site owner can * honour a GDPR-style request against the log without hand-querying the * database. * * Erasure anonymizes matching rows rather than deleting them outright: this * plugin's whole purpose is a security audit trail, and a full delete would * let anyone scrub the record of actions taken while logged in under an * email they later request erased — the same precedent WordPress core * itself sets (its own comments eraser anonymizes the comment author rather * than deleting the comment). The event itself — that something of a given * type happened to a given object at a given time — is retained; every * identifying field about a matched person is not. * * Export and erasure find a person's rows the same way, through * ActivityLog::get_events_for_person(), and treat each row the same way, so * an export always shows what an erasure would change (#18): * * - Rows where they were the ACTING user: logged under their email, or * under their account (user_id) whatever its email was then, so events * from before they changed address are theirs too. Exported with the IP * address; erasure clears user_id, user_email and ip_address — not just * IP. * - Rows where they are the AFFECTED user (object_type='user' AND * object_id = their account): exported as date, event and summary, but * not whoever acted's IP. Erasure clears object_id and regenerates the * summary — which embeds their username in plain text, e.g. "User alice * logged in" — from a per-event-type template rather than editing it in * place, which would risk a fragile partial-string match. * - Among those, an event only they can have performed that names no * actor: a login or logout written before #13 (which stored none), or * signing themselves up. Its IP address is theirs, so it is exported and * erased with the rest. * - A row can match both conditions at once (their own login or logout * since #13, or changing their own role) and gets both treatments. When * the account no longer exists only the email finds it, and a row where * they are both actor and subject still gets its summary regenerated. * - The two places a second identifying value can hide inside the `details` * JSON column in this schema: an admin_email setting change stores the * old and new email address verbatim, and (issue #2) a post_updated event * can store the post's author before/after as raw user IDs and its * excerpt before/after as free text. Both are scrubbed specifically, on * the same acting-match rows whose user_id/user_email/ip_address are * already cleared — a post_updated row's `details` is never someone * else's data to redact on THIS row, only the row's own actor's, exactly * like every other field redaction_fields() clears for that match. Other * settings' values (URLs, role names, plugin/theme slugs) and every other * post_updated detail (title, status, slug, menu order, parent, content's * character counts and revision id) are not personal data and are left * alone. Generic content-scanning of arbitrary JSON is deliberately NOT * attempted beyond these two concrete, verified cases — the risk of * corrupting or silently over-redacting unrelated operational data * outweighs the benefit. * - The one place a post_updated row's `details` names someone who is NOT * its own actor: an administrator reassigning a post's author touches * the previous and/or new author's identity on a row that belongs, as * far as every other field is concerned, to whoever made the edit. Found * through a second, dedicated source — a coarse SQL pre-filter over * `get_post_author_reference_candidates()`, confirmed in PHP against the * subject's own id — since neither the acting-user nor the object-type * match above can reach it (a post's object_type is its post type, never * 'user'). Export includes that row's details without the actor's IP, * the same as any other row someone else performed; erasure replaces * only the subject's own id inside `author.before`/`author.after` with * 0, leaving the other party's id, the rest of the details, and the * summary untouched — the acting user's own data on that row is theirs, * not the subject's, exactly as the object-matched case above already * establishes for a different event shape. * * - The account events (#5, Events\AccountEvents) are object-matched rows * like the ones above: their summaries are rebuilt from templates, and an * application password's name (which the person chose, and which can name * them) is removed from details. None of them stores a password, a hash or * an email address to begin with. The privacy-tool rows (#5, * Events\ToolsEvents) name a request by id and never the requester, and a * `user_request` post -- whose title IS the requester's email -- is no * longer a post event at all (EventLogger::INTERNAL_POST_TYPES). * * - Imported rows (#21, Import\Importer) are ordinary rows to both tools: * the importer keeps the source's actor (user_id, and the address the * account had) and, where the source names it, the affected account as a * `user` object, so the same two matches find them and the same templates * rebuild their summaries. Their one difference is text another plugin * wrote, which scrub_imported_text() clears of the person's names. * * - The request context (#7, Context\RequestContext): country, city, * device class, browser and OS describe the request the IP address came * from, so they are exported wherever the IP address is and cleared * wherever it is cleared. The login flags (`new_country`, `new_device`, * and since the login-alerts slice `impossible_travel`, `dormant_days`, * `anonymous_network`) stay in details: a bare "true" or a day count * names no place and no device. * * Not present anywhere in this plugin's own write path, verified by * reading EventLogger/Database rather than assumed: a display name (only * user_login/user_email are ever captured) or a user-agent string (#7 * parses it at log time and stores only the device class, browser and OS * it names; the string itself is never written). Nothing to scrub because nothing is stored -- except * in another plugin's imported words: Stream's summaries name people by * display name, so scrub_imported_text() replaces it too. * * items_removed/items_retained are reported honestly per the erasers API's * own contract: both true whenever at least one row was touched (something * identifying WAS removed; the anonymized event record WAS retained), with * an explanatory message so a site owner forwarding the response to a data * subject isn't implying nothing survived. */ final class PersonalData { /** * Rows returned per export() page. */ private const EXPORT_PAGE_SIZE = 250; /** * Rows fetched (per match type) per erase() call. */ private const ERASE_BATCH_SIZE = 500; /** * Regenerated summary text for object-matched rows whose event type * embeds a username with no other data worth preserving (contrast * user_role_changed, handled separately to keep the role name). * * @var array */ private const OBJECT_SUMMARY_TEMPLATES = array( 'user_login' => 'User [redacted] logged in', 'user_logout' => 'User [redacted] logged out', 'user_created' => 'User [redacted] created', 'user_deleted' => 'User [redacted] deleted', // Session management (#6): the summaries name the user whose // session ended, and are rebuilt here like every other user event. 'session_destroyed' => 'Session of [redacted] ended by an administrator', 'sessions_destroyed' => 'Sessions of [redacted] ended', 'session_idle_expired' => 'Session of [redacted] ended after inactivity', 'session_limit_evicted' => 'Oldest session of [redacted] ended by the session limit', // Account changes (#5, Events\AccountEvents): the summaries name the // account, and the application-password ones its name too. 'user_profile_updated' => 'User [redacted] profile updated', 'user_email_changed' => 'User [redacted] email address changed', 'user_password_changed' => 'User [redacted] password changed', 'user_password_reset' => 'User [redacted] password reset', 'app_password_created' => 'Application password created for [redacted]', 'app_password_revoked' => 'Application password revoked for [redacted]', // Network rows about a user (#8, Network\NetworkEvents). 'super_admin_granted' => 'Super admin granted to [redacted]', 'super_admin_revoked' => 'Super admin revoked from [redacted]', 'user_added_to_site' => 'User [redacted] added to a site', 'user_removed_from_site' => 'User [redacted] removed from a site', // Suspicious-login alerts, written by the Pro add-on through free's // own logger (docs/login-alerts.md). Named here as strings, not // classes -- free never references Pro -- because the rows are log // rows and this eraser is the one that holds every log row to #18. 'login_alert' => 'Suspicious login alert for [redacted]', 'login_alert_reported' => 'User [redacted] reported a login as not theirs', 'login_alert_flagged' => 'Login by [redacted] flagged as suspicious', // Third-party integrations (#5, Events\Integrations): LMS and // membership rows are about the student or member, and a user's // custom fields row names them. 'lms_enrolled' => 'User [redacted] enrolled in a course', 'lms_unenrolled' => 'User [redacted] removed from a course', 'lms_course_completed' => 'User [redacted] completed a course', 'lms_quiz_completed' => 'User [redacted] completed a quiz', 'lms_group_joined' => 'User [redacted] added to a group', 'lms_group_left' => 'User [redacted] removed from a group', 'membership_started' => 'Membership started for [redacted]', 'membership_changed' => 'Membership changed for [redacted]', 'membership_cancelled' => 'Membership cancelled for [redacted]', 'membership_paused' => 'Membership paused for [redacted]', 'membership_resumed' => 'Membership resumed for [redacted]', 'membership_expired' => 'Membership expired for [redacted]', 'membership_refunded' => 'Membership refunded for [redacted]', 'acf_fields_updated' => 'Custom fields of [redacted] updated', ); /** * Details keys a session event (#6) carries about the person whose * session it was, removed when that person's data is erased: the * session's IP address, as the log stored it. */ private const SESSION_DETAIL_KEYS_ERASED = array( 'session_ip' ); /** * Details keys an application-password event (#5) carries about the * person it belongs to: the name they gave it ("Alice's phone"), removed * with the summary that repeated it. The uuid stays: it names a * credential, not a person. */ private const APP_PASSWORD_DETAIL_KEYS_ERASED = array( 'app_name' ); /** * Register hooks. */ public static function register_hooks(): void { add_filter( 'wp_privacy_personal_data_exporters', array( __CLASS__, 'register_exporter' ) ); add_filter( 'wp_privacy_personal_data_erasers', array( __CLASS__, 'register_eraser' ) ); add_action( 'admin_init', array( __CLASS__, 'add_privacy_policy_content' ) ); } /** * Suggested text for the site's privacy policy (Settings > Privacy), * describing what the log keeps -- including, since #7, location and * device. It reads the site's own settings, so the text says what THIS * site does: truncated or full addresses, device on or off, geolocation * on or off. */ public static function add_privacy_policy_content(): void { if ( ! function_exists( 'wp_add_privacy_policy_content' ) ) { return; } wp_add_privacy_policy_content( __( '404ops Activity Log', '404ops-activity-log' ), wp_kses_post( self::privacy_policy_text() ) ); } /** * The suggested privacy-policy text, as HTML paragraphs. */ public static function privacy_policy_text(): string { $paragraphs = array( __( 'This site keeps a security activity log: when someone signs in, signs out, fails to sign in, or changes content, users, plugins, themes or settings, the log records what happened, when, which account did it, and the IP address the request came from.', '404ops-activity-log' ), (bool) get_option( EventLogger::OPTION_STORE_FULL_IPS, false ) ? __( 'IP addresses are stored in full.', '404ops-activity-log' ) : __( 'IP addresses are stored shortened: the last part of the address is removed before it is saved.', '404ops-activity-log' ), ); if ( RequestContext::records_device() ) { $paragraphs[] = __( 'The log also records the kind of device used (desktop, mobile, tablet or automated), the browser and the operating system, as read from the browser\'s user agent. The user-agent text itself is not stored.', '404ops-activity-log' ); } if ( '' !== GeoDatabase::path() ) { $paragraphs[] = __( 'The log records the country (and, where known, the city) the IP address belongs to, looked up in a geolocation database kept on this site; the address is not sent anywhere to do this. Where IP addresses are stored shortened, the location is looked up from the full address before it is shortened, and only the location is kept.', '404ops-activity-log' ); } $paragraphs[] = ActivityLog::retention_unlimited() ? __( 'Log entries are kept until an administrator deletes them.', '404ops-activity-log' ) : sprintf( /* translators: %d: number of days events are kept. */ __( 'Log entries are kept for %d days and then deleted.', '404ops-activity-log' ), ActivityLog::retention_days_or_default( get_option( 'ops_activity_log_retention_days', ActivityLog::DEFAULT_RETENTION_DAYS ) ) ); $paragraphs[] = __( 'You can ask for a copy of the entries about you, or for them to be anonymized, through this site\'s personal-data request process.', '404ops-activity-log' ); return '

' . implode( '

', array_map( 'esc_html', $paragraphs ) ) . '

'; } /** * @param array $exporters * @return array */ public static function register_exporter( array $exporters ): array { $exporters['404ops-activity-log'] = array( 'exporter_friendly_name' => __( '404ops Activity Log', '404ops-activity-log' ), 'callback' => array( __CLASS__, 'export' ), ); // The network log's network rows (#8): on the main site, whose // settings those rows follow, and only while there is a network log. if ( NetworkLog::is_active() && is_main_site() ) { $exporters['404ops-activity-log-network'] = array( 'exporter_friendly_name' => __( '404ops Activity Log (network)', '404ops-activity-log' ), 'callback' => array( __CLASS__, 'export_network' ), ); } return $exporters; } /** * @param array $erasers * @return array */ public static function register_eraser( array $erasers ): array { $erasers['404ops-activity-log'] = array( 'eraser_friendly_name' => __( '404ops Activity Log', '404ops-activity-log' ), 'callback' => array( __CLASS__, 'erase' ), ); if ( NetworkLog::is_active() && is_main_site() ) { $erasers['404ops-activity-log-network'] = array( 'eraser_friendly_name' => __( '404ops Activity Log (network)', '404ops-activity-log' ), 'callback' => array( __CLASS__, 'erase_network' ), ); } return $erasers; } /** * wp_privacy_personal_data_exporters callback. * * Pages through two sources in sequence: the primary one (acting-user or * object-matched rows, unchanged from before issue #2's author-reference * gap) for as many pages as ActivityLog::count_events_for_person() says * it holds, then the secondary one (get_post_author_reference_candidates(), * confirmed per row) for as long as ITS OWN pages keep coming back full. * A COUNT query per call is the cost of routing a single, WordPress-driven * page number across two differently-shaped sources without any * server-side state between calls; there is no other input to route on. * * @param string $email_address Requester's email address. * @param int $page Page number (1-based), supplied by the privacy tool. * @return array */ public static function export( string $email_address, int $page = 1 ): array { return self::export_from( self::site_store(), $email_address, $page ); } /** * The network log's exporter (#8), registered on the main site while the * network log is in use: the NETWORK rows about the person (a super-admin * grant, being added to a site, their own network actions). The copies * of site rows are each site's, exported by that site's exporter. On a * network only super admins may run the privacy tools at all (core maps * `export_others_personal_data` to `manage_network` there). * * @param string $email_address Requester's email address. * @param int $page Page number (1-based). * @return array */ public static function export_network( string $email_address, int $page = 1 ): array { return self::export_from( self::network_store(), $email_address, $page ); } /** * Where a person's rows are read and written: the site's own log, or the * network log's network rows. Same rules, different table. * * @return array{count: callable, find: callable, authors: callable, update: callable, group: string, label: string} */ private static function site_store(): array { return array( 'count' => array( ActivityLog::class, 'count_events_for_person' ), 'find' => array( ActivityLog::class, 'get_events_for_person' ), 'authors' => array( ActivityLog::class, 'get_post_author_reference_candidates' ), 'update' => array( ActivityLog::class, 'update_event' ), 'group' => 'ops-activity-log', 'label' => __( '404ops Activity Log', '404ops-activity-log' ), ); } /** * The network rows' store. No post events are network rows, so there is * no author-reference source to page through. * * @return array{count: callable, find: callable, authors: callable, update: callable, group: string, label: string} */ private static function network_store(): array { return array( 'count' => array( NetworkLog::class, 'count_events_for_person' ), 'find' => array( NetworkLog::class, 'get_events_for_person' ), 'authors' => static fn(): array => array(), 'update' => array( NetworkLog::class, 'update_row' ), 'group' => 'ops-activity-log-network', 'label' => __( '404ops Activity Log (network)', '404ops-activity-log' ), ); } /** * export()'s body, over a store. * * @param array $store site_store() or network_store(). * @param string $email_address Requester's email address. * @param int $page Page number (1-based). * @return array */ private static function export_from( array $store, string $email_address, int $page ): array { if ( '' === trim( $email_address ) ) { return array( 'data' => array(), 'done' => true, ); } $subject_id = self::resolve_user_id( $email_address ); $primary_pages = (int) ceil( (int) call_user_func( $store['count'], $email_address, $subject_id ) / self::EXPORT_PAGE_SIZE ); if ( $page <= $primary_pages ) { $events = (array) call_user_func( $store['find'], $email_address, $subject_id, self::EXPORT_PAGE_SIZE, $page ); return array( 'data' => self::export_items( $events, $subject_id, $store ), // The fetched count, not $primary_pages, decides "full page // vs. last page" -- the same signal export() always used, // robust to a row landing between the COUNT above and this // SELECT. Never done while an account might still have a // secondary (author-reference) match to look up. 'done' => count( $events ) < self::EXPORT_PAGE_SIZE && null === $subject_id, ); } if ( null === $subject_id ) { // Unreachable in practice: $primary_pages is 0 whenever // $subject_id is null (count_events_for_person() then has no // object-match branch to find rows through), so $page (always // >= 1) is never <= 0 and this branch is never taken -- kept as // a direct guard rather than relying on that arithmetic alone. return array( 'data' => array(), 'done' => true, ); } $secondary_page = $page - $primary_pages; $candidates = (array) call_user_func( $store['authors'], self::EXPORT_PAGE_SIZE, $secondary_page ); $matches = array_values( array_filter( $candidates, static function ( array $row ) use ( $subject_id ): bool { // Excludes a row where the subject is ALSO this row's own // actor: already exported (with IP, and its author/excerpt // already fully scrubbed on erasure) via the primary // source above, per #18's "exported once" rule. return ! self::is_actor( $row, $subject_id ) && self::is_author_reference( $row, $subject_id ); } ) ); return array( 'data' => self::export_items( $matches, $subject_id, $store ), // Based on the CANDIDATE page's own size, not how many of its // rows were confirmed matches: a full coarse page can still hold // zero rows about this particular subject, and there may yet be // more pages after it. 'done' => count( $candidates ) < self::EXPORT_PAGE_SIZE, ); } /** * One page's worth of rows in the wp_privacy_personal_data_exporters * item shape. * * @param array> $events ActivityLog rows. * @param int|null $subject_id The requester's resolved account, if any. * @param array $store Which log they came from (its group id and label). * @return array> */ private static function export_items( array $events, ?int $subject_id, array $store ): array { $export_items = array(); foreach ( $events as $event ) { $data = array( array( 'name' => __( 'Date', '404ops-activity-log' ), 'value' => (string) ( $event['timestamp'] ?? '' ), ), array( 'name' => __( 'Event', '404ops-activity-log' ), 'value' => (string) ( $event['event_type'] ?? '' ), ), array( 'name' => __( 'Summary', '404ops-activity-log' ), 'value' => (string) ( $event['summary'] ?? '' ), ), ); // The IP address is theirs only when they did it; on an event // someone else performed (on their account, or naming them as a // post's author without their being the actor), it is that other // person's. if ( self::is_actor( $event, $subject_id ) || ( self::is_subject( $event, $subject_id ) && self::is_unattributed_own_event( $event ) ) ) { $data[] = array( 'name' => __( 'IP Address', '404ops-activity-log' ), 'value' => (string) ( $event['ip_address'] ?? '' ), ); // Where they were and what they used (#7) travel with the IP // address: the same request, the same person, the same rule. foreach ( self::context_export_fields() as $column => $label ) { if ( isset( $event[ $column ] ) && '' !== (string) $event[ $column ] ) { $data[] = array( 'name' => $label, 'value' => (string) $event[ $column ], ); } } } // What the event recorded about itself (issue #2), when it // recorded anything: the same JSON erase() redacts on this row // (see redaction_fields()), so what is exported and what // erasure changes agree row for row, per #18. $details_json = isset( $event['details'] ) ? (string) $event['details'] : ''; if ( '' !== $details_json ) { $data[] = array( 'name' => __( 'Details', '404ops-activity-log' ), 'value' => $details_json, ); } $export_items[] = array( 'group_id' => (string) $store['group'], 'group_label' => (string) $store['label'], 'item_id' => (string) $store['group'] . '-' . (string) ( $event['id'] ?? '' ), 'data' => $data, ); } return $export_items; } /** * The context columns (#7) the exporter includes, labelled. * * @return array */ private static function context_export_fields(): array { return array( 'country' => __( 'Country', '404ops-activity-log' ), 'city' => __( 'City', '404ops-activity-log' ), 'device_class' => __( 'Device', '404ops-activity-log' ), 'browser' => __( 'Browser', '404ops-activity-log' ), 'os' => __( 'Operating system', '404ops-activity-log' ), ); } /** * The context columns set to NULL, for every place erasure clears an * IP address: they describe the same request. * * @return array */ private static function cleared_context(): array { return array_fill_keys( RequestContext::COLUMNS, null ); } /** * wp_privacy_personal_data_erasers callback. * * Ignores $page and re-queries from scratch every call: a row stops * matching as soon as it's redacted (an acting row's user_email and * user_id are cleared, an affected row's object_id is), so repeated calls * converge correctly regardless of what page number WordPress happens to * pass. The secondary, post-author-reference batch (see export()'s own * docblock for why that source exists) follows the same principle: a * scrubbed author id no longer matches is_author_reference(), so it is * fetched from page 1 every call too, rather than tracked across calls. * * @param string $email_address Requester's email address. * @param int $page Page number (1-based); see above for why it's unused. * @return array */ public static function erase( string $email_address, int $page = 1 ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- $page is required by the wp_privacy_personal_data_erasers callback signature; unused here for the reason in this method's own docblock. return self::erase_from( self::site_store(), $email_address ); } /** * The network log's eraser (#8): the network rows about the person, by * the same rules. See export_network(). * * @param string $email_address Requester's email address. * @param int $page Unused, as in erase(). * @return array */ public static function erase_network( string $email_address, int $page = 1 ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- see erase(). return self::erase_from( self::network_store(), $email_address ); } /** * erase()'s body, over a store. * * @param array $store site_store() or network_store(). * @param string $email_address Requester's email address. * @return array */ private static function erase_from( array $store, string $email_address ): array { if ( '' === trim( $email_address ) ) { return self::empty_erase_result(); } $subject_id = self::resolve_user_id( $email_address ); $rows = (array) call_user_func( $store['find'], $email_address, $subject_id, self::ERASE_BATCH_SIZE, 1 ); $updated = 0; foreach ( $rows as $row ) { $fields = self::redaction_fields( $row, self::is_actor( $row, $subject_id ), self::is_subject( $row, $subject_id ) ); $fields = self::scrub_imported_text( $row, $fields, $email_address, $subject_id ); if ( array() !== $fields && call_user_func( $store['update'], (int) $row['id'], $fields ) ) { ++$updated; } } // A full batch means more may remain. $more_may_remain = count( $rows ) === self::ERASE_BATCH_SIZE; if ( null !== $subject_id ) { $candidates = (array) call_user_func( $store['authors'], self::ERASE_BATCH_SIZE, 1 ); foreach ( $candidates as $row ) { // Already fully handled above (author AND excerpt scrubbed, // alongside user_id/email/ip) when the subject is also this // row's own actor -- see redaction_fields(). if ( self::is_actor( $row, $subject_id ) ) { continue; } $scrubbed = self::scrub_post_author_reference( isset( $row['details'] ) ? (string) $row['details'] : null, $subject_id ); if ( null !== $scrubbed && call_user_func( $store['update'], (int) $row['id'], array( 'details' => $scrubbed ) ) ) { ++$updated; } } $more_may_remain = $more_may_remain || count( $candidates ) === self::ERASE_BATCH_SIZE; } return array( 'items_removed' => $updated > 0, 'items_retained' => $updated > 0, 'messages' => $updated > 0 ? array( self::retained_message() ) : array(), 'done' => ! $more_may_remain, ); } /** * @return array */ private static function empty_erase_result(): array { return array( 'items_removed' => false, 'items_retained' => false, 'messages' => array(), 'done' => true, ); } /** * Resolve $email to a WP_User id, if one currently exists. A privacy * request can outlive the account it was made about (e.g. requested * after the account was deleted) — in that case there's nothing to * resolve, and object-linked rows simply aren't reachable this way; * only the acting-user match (a plain stored string, independent of * whether the account still exists) still finds them. */ private static function resolve_user_id( string $email ): ?int { $user = get_user_by( 'email', $email ); return ( $user && $user->ID > 0 ) ? (int) $user->ID : null; } /** * What a person is called in text: their email address, and their login * and display name while the account exists. The display name is there * for Stream (#22), whose rendered summaries name people by it; this * plugin never writes one itself. * * @return string[] */ private static function names_of( string $email, ?int $subject_id ): array { $names = array( $email ); $user = null !== $subject_id ? get_userdata( $subject_id ) : false; if ( $user instanceof \WP_User ) { $names[] = (string) $user->user_login; $names[] = (string) $user->display_name; } // Longest first, so a login inside a display name ("Ann" in "Ann // Lee") does not leave the rest of the name behind. $names = array_values( array_unique( array_filter( $names, static fn( string $name ): bool => '' !== $name ) ) ); usort( $names, static fn( string $a, string $b ): int => strlen( $b ) <=> strlen( $a ) ); return $names; } /** * An imported row's text without the person's names in it (#21). * * A row this plugin wrote has a summary from its own templates, which * redact_summary() rebuilds. An imported row may carry another plugin's * words: a generic row's summary is that plugin's message, and a mapped * row keeps it in `details.imported.message`. There is no template to * rebuild those from, so the person's login, display name and email * address are replaced where they appear -- each only as a whole word, * so a short login does not eat into other words. Only imported rows already * matched to the person (as actor or subject) are touched, and only * their names; the rest of the message is someone else's record of what * happened. Email addresses in imported text are withheld at import * (Import\SourceTables::is_withheld_key()), so this is the second line. * * @param array $row The row. * @param array $fields redaction_fields()'s answer so far. * @param string $email The person's email address. * @param int|null $subject_id Their account, when it still exists. * @return array */ private static function scrub_imported_text( array $row, array $fields, string $email, ?int $subject_id ): array { if ( '' === (string) ( $row['import_source'] ?? '' ) || array() === $fields ) { return $fields; } $names = self::names_of( $email, $subject_id ); $summary = (string) ( $fields['summary'] ?? $row['summary'] ?? '' ); $scrubbed = self::without_names( $summary, $names ); if ( $scrubbed !== $summary ) { $fields['summary'] = $scrubbed; } $details = json_decode( (string) ( $fields['details'] ?? $row['details'] ?? '' ), true ); if ( is_array( $details ) && isset( $details['imported']['message'] ) && is_string( $details['imported']['message'] ) ) { $message = self::without_names( $details['imported']['message'], $names ); if ( $message !== $details['imported']['message'] ) { $details['imported']['message'] = $message; $fields['details'] = (string) wp_json_encode( $details ); } } return $fields; } /** * @param string $text Text. * @param string[] $names Names to replace. */ private static function without_names( string $text, array $names ): string { foreach ( $names as $name ) { if ( '' === $name ) { continue; } $replaced = preg_replace( '/(? $row One ActivityLog::get_events_for_person() row. */ private static function is_actor( array $row, ?int $subject_id ): bool { if ( 1 === (int) ( $row['matched_email'] ?? 0 ) ) { return true; } return null !== $subject_id && (int) ( $row['user_id'] ?? 0 ) === $subject_id; } /** * Whether the event was done to the person's account. * * @param array $row One ActivityLog::get_events_for_person() row. */ private static function is_subject( array $row, ?int $subject_id ): bool { return null !== $subject_id && 'user' === ( $row['object_type'] ?? null ) && (int) ( $row['object_id'] ?? 0 ) === $subject_id; } /** * Whether a post_updated row's details actually name $subject_id as the * post's author before or after the edit -- the PHP-side confirmation * get_post_author_reference_candidates()'s coarse SQL match needs (its * own docblock explains why the SQL side alone can't be exact). * * @param array $row One ActivityLog::get_post_author_reference_candidates() row. */ private static function is_author_reference( array $row, int $subject_id ): bool { if ( 'post_updated' !== ( $row['event_type'] ?? '' ) ) { return false; } $details = json_decode( isset( $row['details'] ) ? (string) $row['details'] : '', true ); if ( ! is_array( $details ) || ! isset( $details['author'] ) || ! is_array( $details['author'] ) ) { return false; } foreach ( array( 'before', 'after' ) as $side ) { if ( array_key_exists( $side, $details['author'] ) && null !== $details['author'][ $side ] && $subject_id === (int) $details['author'][ $side ] ) { return true; } } return false; } /** * An event on someone's account that names no actor but that only they * can have performed: a login or logout written before #13, which stored * none, or signing themselves up. Its IP address is theirs. * * @param array $row One ActivityLog::get_events_for_person() row. */ private static function is_unattributed_own_event( array $row ): bool { $names_an_actor = '' !== (string) ( $row['user_email'] ?? '' ) || (int) ( $row['user_id'] ?? 0 ) > 0; return ! $names_an_actor && in_array( (string) ( $row['event_type'] ?? '' ), array( 'user_login', 'user_logout', 'user_created' ), true ); } /** * The full set of column values to clear/rewrite for one row, given * which of the two match conditions it satisfies. A row can satisfy * both (e.g. someone whose own role change they performed themselves). * * @param array $row One ActivityLog::get_events() row. * @return array Column => new value, empty if nothing to change. */ private static function redaction_fields( array $row, bool $acting_match, bool $object_match ): array { $fields = array(); if ( $acting_match ) { $fields['user_id'] = null; $fields['user_email'] = ''; $fields['ip_address'] = null; $fields = array_merge( $fields, self::cleared_context() ); $event_type = (string) ( $row['event_type'] ?? '' ); $stored_details = isset( $row['details'] ) ? (string) $row['details'] : null; $scrubbed_details = null; if ( 'setting_changed' === $event_type ) { $scrubbed_details = self::scrub_admin_email_details( $stored_details ); } elseif ( 'post_updated' === $event_type ) { $scrubbed_details = self::scrub_post_updated_details( $stored_details ); } if ( null !== $scrubbed_details ) { $fields['details'] = $scrubbed_details; } } // In your own login, logout or role change you are both the actor // and the subject. When the account is already deleted no user ID // resolves, so only the email finds the row, but the username in its // summary is still yours. $actor = (int) ( $row['user_id'] ?? 0 ); $subject = (int) ( $row['object_id'] ?? 0 ); $own_event = $acting_match && $actor > 0 && $actor === $subject; if ( ( $object_match || $own_event ) && 'user' === ( $row['object_type'] ?? null ) ) { $fields['object_id'] = null; if ( self::is_unattributed_own_event( $row ) ) { $fields['ip_address'] = null; $fields = array_merge( $fields, self::cleared_context() ); } $redacted_summary = self::redact_summary( (string) ( $row['event_type'] ?? '' ), $row ); if ( null !== $redacted_summary ) { $fields['summary'] = $redacted_summary; } $scrubbed_object = self::scrub_object_details( (string) ( $row['event_type'] ?? '' ), isset( $row['details'] ) ? (string) $row['details'] : null ); if ( null !== $scrubbed_object ) { $fields['details'] = $scrubbed_object; } } return $fields; } /** * Regenerated (not edited-in-place) summary text for an object-matched * row — rebuilding from a known template per event type is far more * robust than trying to string-replace a specific username out of free * text, which risks a partial match against unrelated content. * * @param array $row */ private static function redact_summary( string $event_type, array $row ): ?string { if ( isset( self::OBJECT_SUMMARY_TEMPLATES[ $event_type ] ) ) { return self::OBJECT_SUMMARY_TEMPLATES[ $event_type ]; } if ( 'user_role_changed' === $event_type ) { $details = json_decode( isset( $row['details'] ) ? (string) $row['details'] : '', true ); $role = is_array( $details ) ? (string) ( $details['new_role'] ?? '' ) : ''; return '' !== $role ? sprintf( 'User [redacted] role changed to %s', $role ) : 'User [redacted] role changed'; } // An unrecognized event type carrying object_type=user: object_id // is still cleared by the caller; leave summary as-is rather than // guess at a shape this class doesn't know. return null; } /** * An object-matched row's details without the keys that describe the * erased person: a session event's session IP (#6), an application * password's name (#5). Null when the row is neither, or carries none. */ private static function scrub_object_details( string $event_type, ?string $details_json ): ?string { if ( str_starts_with( $event_type, 'session' ) ) { $keys = self::SESSION_DETAIL_KEYS_ERASED; } elseif ( str_starts_with( $event_type, 'app_password_' ) ) { $keys = self::APP_PASSWORD_DETAIL_KEYS_ERASED; } else { return null; } if ( null === $details_json || '' === $details_json ) { return null; } $details = json_decode( $details_json, true ); if ( ! is_array( $details ) || array() === array_intersect_key( $details, array_flip( $keys ) ) ) { return null; } $encoded = wp_json_encode( array_diff_key( $details, array_flip( $keys ) ) ); return false === $encoded ? null : $encoded; } /** * Redact an admin_email setting-change's old/new values (the one * concrete case in this schema where `details` carries a raw email * address). Any other option's details are returned unchanged (null, * meaning "no rewrite needed"). */ private static function scrub_admin_email_details( ?string $details_json ): ?string { if ( null === $details_json || '' === $details_json ) { return null; } $details = json_decode( $details_json, true ); if ( ! is_array( $details ) || 'admin_email' !== ( $details['option'] ?? '' ) ) { return null; } $details['old_value'] = '[redacted]'; $details['new_value'] = '[redacted]'; return (string) wp_json_encode( $details ); } /** * Redact a post_updated event's `author` (raw user IDs identifying * whoever the post's author was before/after) and `excerpt` * (before/after free text) detail fields — issue #2's two additions to * this schema that can name or quote a person. Every other post_updated * detail (title, status, slug, menu_order, parent, and content's * character counts / revision id) is structural information about the * post, not personal data, and is left alone, the same principle * scrub_admin_email_details() applies to a setting_changed row's other * option values. * * Only ever called for the acting-match row (the person who made this * edit) — see redaction_fields() — so this is that person's own action * being redacted, not a scan for some OTHER person's data that happens * to be named in it. */ private static function scrub_post_updated_details( ?string $details_json ): ?string { if ( null === $details_json || '' === $details_json ) { return null; } $details = json_decode( $details_json, true ); if ( ! is_array( $details ) ) { return null; } $changed = false; if ( isset( $details['author'] ) && is_array( $details['author'] ) ) { if ( array_key_exists( 'before', $details['author'] ) ) { $details['author']['before'] = null; } if ( array_key_exists( 'after', $details['author'] ) ) { $details['author']['after'] = null; } $changed = true; } if ( isset( $details['excerpt'] ) && is_array( $details['excerpt'] ) ) { if ( array_key_exists( 'before', $details['excerpt'] ) ) { $details['excerpt']['before'] = '[redacted]'; } if ( array_key_exists( 'after', $details['excerpt'] ) ) { $details['excerpt']['after'] = '[redacted]'; } $changed = true; } return $changed ? (string) wp_json_encode( $details ) : null; } /** * Replace $subject_id, wherever it appears in a post_updated row's * `author.before` / `author.after`, with 0 -- a sentinel no real * WordPress account ever has. Narrower than scrub_post_updated_details() * above on purpose: this is called for a row the subject did NOT act * on, so only their own id is theirs to redact here. The other side of * `author` (a different person's id, when the two differ), the rest of * `details`, and the row's summary all stay exactly as recorded -- * they belong to whoever performed the edit, not to $subject_id. * * @param int $subject_id The erasure subject's resolved account id (never null: see erase()'s and export()'s own null guards). */ private static function scrub_post_author_reference( ?string $details_json, int $subject_id ): ?string { if ( null === $details_json || '' === $details_json ) { return null; } $details = json_decode( $details_json, true ); if ( ! is_array( $details ) || ! isset( $details['author'] ) || ! is_array( $details['author'] ) ) { return null; } $changed = false; foreach ( array( 'before', 'after' ) as $side ) { if ( array_key_exists( $side, $details['author'] ) && null !== $details['author'][ $side ] && $subject_id === (int) $details['author'][ $side ] ) { $details['author'][ $side ] = 0; $changed = true; } } return $changed ? (string) wp_json_encode( $details ) : null; } private static function retained_message(): string { return __( 'Personal data (email address, user ID, and IP address) was removed from matching activity log entries. An anonymized record of each event — what happened and when — was retained for security auditing.', '404ops-activity-log' ); } }