Designing audit logs for enterprise WordPress setups tracking internal user modifications to customer support tickets
Core Requirements for Enterprise Audit Logging in WordPress
Enterprise-grade WordPress deployments, particularly those handling sensitive customer data and requiring strict compliance, necessitate robust audit logging. When internal users—such as customer support agents, administrators, or content managers—modify critical data like customer support tickets, a detailed, immutable audit trail is paramount. This trail must capture who made the change, what was changed, when it was changed, and from where. For WordPress, this typically involves tracking modifications within the database, specifically to custom post types or core WordPress objects representing tickets.
Key considerations for such a system include:
- Granularity: Logging specific field-level changes, not just “record updated.”
- Immutability: Ensuring logs cannot be tampered with by users or even administrators.
- Performance: Minimizing the performance impact on the WordPress application and database.
- Scalability: Handling a potentially large volume of log entries.
- Accessibility: Providing a clear, searchable interface for authorized personnel to review logs.
- Security: Protecting log data from unauthorized access and ensuring secure storage.
Database Schema Design for Audit Logs
A dedicated database table is the most straightforward and performant approach for storing audit logs. We’ll define a schema that captures the essential information for each logged event. Assuming a custom post type named support_ticket is used to represent customer tickets, our audit log table might look like this:
Table Name: wp_audit_logs
CREATE TABLE wp_audit_logs (
log_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED NULL DEFAULT NULL,
username VARCHAR(255) NULL DEFAULT NULL,
ip_address VARCHAR(100) NULL DEFAULT NULL,
timestamp DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
action VARCHAR(50) NOT NULL, -- e.g., 'create', 'update', 'delete', 'status_change'
object_type VARCHAR(50) NOT NULL, -- e.g., 'support_ticket', 'comment'
object_id BIGINT UNSIGNED NOT NULL,
field_name VARCHAR(100) NULL DEFAULT NULL, -- For field-level logging
old_value LONGTEXT NULL DEFAULT NULL,
new_value LONGTEXT NULL DEFAULT NULL,
PRIMARY KEY (log_id),
INDEX idx_object_type_id (object_type, object_id),
INDEX idx_user_id (user_id),
INDEX idx_timestamp (timestamp)
);
Explanation of Fields:
log_id: Unique identifier for each log entry.user_id: WordPress user ID of the person making the change. NULL for system-generated actions.username: Username for easier readability, denormalized for performance.ip_address: IP address from which the action originated.timestamp: When the action occurred.action: The type of operation performed (e.g., ‘update’, ‘status_change’).object_type: The type of WordPress object being modified (e.g., ‘support_ticket’).object_id: The ID of the specific object being modified.field_name: The specific field that was changed (e.g., ‘ticket_status’, ‘assigned_agent’). NULL if the entire object was created/deleted or if it’s a high-level action.old_value: The value of the field before the change. Stored as TEXT to accommodate potentially large values.new_value: The value of the field after the change. Stored as TEXT.
WordPress Plugin Development: Hooking into Ticket Modifications
To capture these modifications, we need to hook into WordPress’s action and filter system. For custom post types, the primary hooks for saving data are save_post_{post_type} and the more generic save_post. We’ll leverage these to intercept updates to our support_ticket post type.
/**
* Plugin Name: Enterprise Ticket Audit Log
* Description: Logs modifications to support tickets for enterprise auditing.
* Version: 1.0
* Author: Antigravity
*/
// Prevent direct access
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// Define the audit log table name
define( 'ENTERPRISE_AUDIT_LOG_TABLE', 'wp_audit_logs' );
/**
* Initialize the plugin: create table if not exists and register hooks.
*/
function enterprise_audit_log_init() {
enterprise_audit_log_create_table();
add_action( 'save_post_support_ticket', 'enterprise_audit_log_save_ticket_changes', 10, 3 );
// Add other relevant hooks, e.g., for status changes if managed via meta
add_action( 'transition_post_status', 'enterprise_audit_log_track_status_transition', 10, 3 );
}
register_activation_hook( __FILE__, 'enterprise_audit_log_init' );
add_action( 'plugins_loaded', 'enterprise_audit_log_init' );
/**
* Creates the audit log table if it doesn't exist.
*/
function enterprise_audit_log_create_table() {
global $wpdb;
$table_name = $wpdb->prefix . ENTERPRISE_AUDIT_LOG_TABLE;
$charset_collate = $wpdb->get_charset_collate();
if ( $wpdb->get_var( "SHOW TABLES LIKE '$table_name'" ) !== $table_name ) {
$sql = "CREATE TABLE $table_name (
log_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED NULL DEFAULT NULL,
username VARCHAR(255) NULL DEFAULT NULL,
ip_address VARCHAR(100) NULL DEFAULT NULL,
timestamp DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
action VARCHAR(50) NOT NULL,
object_type VARCHAR(50) NOT NULL,
object_id BIGINT UNSIGNED NOT NULL,
field_name VARCHAR(100) NULL DEFAULT NULL,
old_value LONGTEXT NULL DEFAULT NULL,
new_value LONGTEXT NULL DEFAULT NULL,
PRIMARY KEY (log_id),
INDEX idx_object_type_id (object_type, object_id),
INDEX idx_user_id (user_id),
INDEX idx_timestamp (timestamp)
) $charset_collate;";
require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );
dbDelta( $sql );
}
}
/**
* Logs changes made to a support ticket post.
*
* @param int $post_id Post ID.
* @param WP_Post $post Post object.
* @param bool $update Whether this is an existing post being updated.
*/
function enterprise_audit_log_save_ticket_changes( $post_id, $post, $update ) {
// Prevent infinite loops and autosaves
if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
return;
}
// Check user capabilities
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return;
}
// Ensure this is a 'support_ticket' post type (redundant due to hook, but good practice)
if ( 'support_ticket' !== $post->post_type ) {
return;
}
$current_user = wp_get_current_user();
$user_id = $current_user->ID;
$username = $current_user->user_login;
$ip_address = $_SERVER['REMOTE_ADDR'] ?? 'N/A'; // Get IP address
// If it's a new post, log creation
if ( ! $update ) {
enterprise_audit_log_entry( array(
'user_id' => $user_id,
'username' => $username,
'ip_address' => $ip_address,
'action' => 'create',
'object_type' => 'support_ticket',
'object_id' => $post_id,
'field_name' => null,
'old_value' => null,
'new_value' => null, // Could log initial title/content if desired
) );
return;
}
// For updates, compare old and new values
$old_post_data = get_post_meta( $post_id, '_original_post_data', true ); // We'll store this temporarily
delete_post_meta( $post_id, '_original_post_data' ); // Clean up temporary meta
// If no old data, it might be the first save after creation, or a race condition.
// For simplicity, we'll assume this is handled by the 'create' log.
if ( empty( $old_post_data ) ) {
// Log a general update if specific field diffing isn't possible
enterprise_audit_log_entry( array(
'user_id' => $user_id,
'username' => $username,
'ip_address' => $ip_address,
'action' => 'update',
'object_type' => 'support_ticket',
'object_id' => $post_id,
'field_name' => null,
'old_value' => null,
'new_value' => null,
) );
return;
}
$new_post_data = array(
'post_title' => $post->post_title,
'post_content' => $post->post_content,
// Add any other core post fields you want to track
);
// Compare core post fields
foreach ( $new_post_data as $field => $new_value ) {
if ( isset( $old_post_data[$field] ) && $old_post_data[$field] !== $new_value ) {
enterprise_audit_log_entry( array(
'user_id' => $user_id,
'username' => $username,
'ip_address' => $ip_address,
'action' => 'update',
'object_type' => 'support_ticket',
'object_id' => $post_id,
'field_name' => $field,
'old_value' => $old_post_data[$field],
'new_value' => $new_value,
) );
}
}
// Track custom meta fields (e.g., ticket status, priority, assigned agent)
$meta_keys_to_track = array(
'_ticket_status',
'_ticket_priority',
'_assigned_agent_id',
'_customer_email',
// Add all relevant meta keys for your support tickets
);
foreach ( $meta_keys_to_track as $meta_key ) {
$old_meta_value = isset( $old_post_data['meta'][$meta_key] ) ? $old_post_data['meta'][$meta_key] : null;
$new_meta_value = get_post_meta( $post_id, $meta_key, true );
// Handle cases where meta might be deleted or added
if ( $old_meta_value !== $new_meta_value ) {
enterprise_audit_log_entry( array(
'user_id' => $user_id,
'username' => $username,
'ip_address' => $ip_address,
'action' => 'update_meta',
'object_type' => 'support_ticket',
'object_id' => $post_id,
'field_name' => $meta_key,
'old_value' => is_array( $old_meta_value ) ? serialize( $old_meta_value ) : $old_meta_value, // Serialize arrays for storage
'new_value' => is_array( $new_meta_value ) ? serialize( $new_meta_value ) : $new_meta_value,
) );
}
}
}
/**
* Captures post status transitions, useful for ticket status changes.
*
* @param string $new_status The new post status.
* @param string $old_status The old post status.
* @param WP_Post $post The post object.
*/
function enterprise_audit_log_track_status_transition( $new_status, $old_status, $post ) {
// Only log if the status actually changed and it's our ticket type
if ( $new_status === $old_status || 'support_ticket' !== $post->post_type ) {
return;
}
// Prevent infinite loops and autosaves
if ( wp_is_post_revision( $post->ID ) || wp_is_post_autosave( $post->ID ) ) {
return;
}
// Check user capabilities
if ( ! current_user_can( 'edit_post', $post->ID ) ) {
return;
}
$current_user = wp_get_current_user();
$user_id = $current_user->ID;
$username = $current_user->user_login;
$ip_address = $_SERVER['REMOTE_ADDR'] ?? 'N/A';
enterprise_audit_log_entry( array(
'user_id' => $user_id,
'username' => $username,
'ip_address' => $ip_address,
'action' => 'status_change',
'object_type' => 'support_ticket',
'object_id' => $post->ID,
'field_name' => 'post_status',
'old_value' => $old_status,
'new_value' => $new_status,
) );
}
/**
* Inserts a single audit log entry into the database.
*
* @param array $log_data Associative array of log data.
*/
function enterprise_audit_log_entry( $log_data ) {
global $wpdb;
$table_name = $wpdb->prefix . ENTERPRISE_AUDIT_LOG_TABLE;
$defaults = array(
'user_id' => null,
'username' => null,
'ip_address' => 'N/A',
'timestamp' => current_time( 'mysql' ),
'action' => 'unknown',
'object_type' => 'unknown',
'object_id' => 0,
'field_name' => null,
'old_value' => null,
'new_value' => null,
);
$log_data = wp_parse_args( $log_data, $defaults );
// Ensure user_id and username are consistent if user_id is provided
if ( ! empty( $log_data['user_id'] ) ) {
$user = get_user_by( 'id', $log_data['user_id'] );
if ( $user ) {
$log_data['username'] = $user->user_login;
} else {
// If user_id is invalid, clear it and username
$log_data['user_id'] = null;
$log_data['username'] = 'System';
}
} elseif ( empty( $log_data['username'] ) ) {
// If no user_id and no username provided, assume system action
$log_data['username'] = 'System';
}
// Sanitize values before insertion
$wpdb->insert( $table_name, array(
'user_id' => $log_data['user_id'],
'username' => sanitize_text_field( $log_data['username'] ),
'ip_address' => sanitize_text_field( $log_data['ip_address'] ),
'timestamp' => $log_data['timestamp'],
'action' => sanitize_key( $log_data['action'] ),
'object_type' => sanitize_key( $log_data['object_type'] ),
'object_id' => absint( $log_data['object_id'] ),
'field_name' => $log_data['field_name'] ? sanitize_text_field( $log_data['field_name'] ) : null,
'old_value' => $log_data['old_value'] ? wp_kses_post( $log_data['old_value'] ) : null, // Use wp_kses_post for potentially rich text
'new_value' => $log_data['new_value'] ? wp_kses_post( $log_data['new_value'] ) : null,
), array(
'%d', // user_id
'%s', // username
'%s', // ip_address
'%s', // timestamp
'%s', // action
'%s', // object_type
'%d', // object_id
'%s', // field_name
'%s', // old_value
'%s', // new_value
) );
}
/**
* Stores the original post data before saving.
* This is a crucial step for diffing.
*
* @param int $post_id Post ID.
*/
function enterprise_audit_log_store_original_data( $post_id ) {
// Only run for 'support_ticket' post type and when not an autosave/revision
if ( get_post_type( $post_id ) !== 'support_ticket' || wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
return;
}
// Check if we already stored it to avoid overwriting during meta updates
if ( get_post_meta( $post_id, '_original_post_data', true ) ) {
return;
}
$post = get_post( $post_id );
$original_data = array(
'post_title' => $post->post_title,
'post_content' => $post->post_content,
'meta' => array(),
);
// Fetch all relevant meta keys to store their original values
$meta_keys_to_track = array(
'_ticket_status',
'_ticket_priority',
'_assigned_agent_id',
'_customer_email',
// Add all relevant meta keys
);
foreach ( $meta_keys_to_track as $meta_key ) {
$original_data['meta'][$meta_key] = get_post_meta( $post_id, $meta_key, true );
}
// Store this temporarily. It will be deleted by save_post_support_ticket after comparison.
update_post_meta( $post_id, '_original_post_data', $original_data );
}
add_action( 'save_post_support_ticket', 'enterprise_audit_log_store_original_data', 5, 1 ); // Run very early
Key aspects of the PHP code:
- Table Creation:
enterprise_audit_log_create_table()usesdbDelta()to ensure the audit log table exists upon plugin activation. - Hooking into Saves:
save_post_support_ticketis the primary hook. It’s triggered whenever a post of typesupport_ticketis saved. - Storing Original Data: A crucial technique is to store the *original* state of the post and its meta *before* the save operation completes. This is done in
enterprise_audit_log_store_original_data, hooked very early intosave_post_support_ticket. This temporary meta data (_original_post_data) is then retrieved and deleted inenterprise_audit_log_save_ticket_changesfor comparison. - Diffing Logic:
enterprise_audit_log_save_ticket_changescompares the retrieved original data with the data that has just been saved. It logs changes for core post fields (title, content) and custom meta fields. - Status Transitions:
transition_post_statushook is used to specifically log changes in thepost_status, which is often used for ticket states (e.g., ‘open’, ‘closed’, ‘pending’). enterprise_audit_log_entry(): A helper function to standardize the insertion of log data into the database, including sanitization and handling of user information.- Security & Performance: Checks for autosaves, revisions, and user capabilities are included. IP address and username are captured. Values are sanitized.
- Serialization: Array-based meta values are serialized before storing to ensure data integrity.
Advanced Considerations and Enhancements
The provided code forms a solid foundation. For enterprise deployments, several enhancements are critical:
1. Immutable Storage and Log Rotation
Storing logs directly in the WordPress database, while convenient, is not truly immutable. A determined administrator could potentially alter the wp_audit_logs table. For true immutability:
- External Logging Service: Integrate with a dedicated logging service (e.g., ELK stack, Splunk, AWS CloudWatch Logs, Google Cloud Logging). Logs can be sent via API or a syslog daemon. This decouples logging from the WordPress application and provides advanced querying and retention policies.
- Write-Ahead Logging (WAL): For critical systems, consider implementing a write-ahead log mechanism where changes are first written to a separate, append-only log file before being committed to the database.
- Database Permissions: Restrict direct database access for WordPress users. Use dedicated database users with minimal privileges.
- Log Rotation: Implement a strategy to archive or delete old logs based on compliance requirements and storage capacity. This can be managed by the external logging service or a cron job.
2. User Interface for Log Review
Authorized personnel (e.g., compliance officers, senior support managers) need an interface to view and search logs. This would typically be a custom admin page within WordPress:
/**
* Adds an Audit Log admin menu page.
*/
function enterprise_audit_log_admin_menu() {
add_submenu_page(
'edit.php?post_type=support_ticket', // Parent slug
__( 'Ticket Audit Log', 'enterprise-audit-log' ),
__( 'Audit Log', 'enterprise-audit-log' ),
'manage_options', // Capability required to view
'ticket-audit-log',
'enterprise_audit_log_render_page'
);
}
add_action( 'admin_menu', 'enterprise_audit_log_admin_menu' );
/**
* Renders the Audit Log admin page content.
*/
function enterprise_audit_log_render_page() {
global $wpdb;
$table_name = $wpdb->prefix . ENTERPRISE_AUDIT_LOG_TABLE;
// Basic search/filter parameters
$object_id = isset( $_GET['object_id'] ) ? intval( $_GET['object_id'] ) : '';
$user_id = isset( $_GET['user_id'] ) ? intval( $_GET['user_id'] ) : '';
$action = isset( $_GET['action'] ) ? sanitize_key( $_GET['action'] ) : '';
$date_from = isset( $_GET['date_from'] ) ? sanitize_text_field( $_GET['date_from'] ) : '';
$date_to = isset( $_GET['date_to'] ) ? sanitize_text_field( $_GET['date_to'] ) : '';
$args = array();
$where_clauses = array();
if ( ! empty( $object_id ) ) {
$where_clauses[] = $wpdb->prepare( "object_id = %d", $object_id );
}
if ( ! empty( $user_id ) ) {
$where_clauses[] = $wpdb->prepare( "user_id = %d", $user_id );
}
if ( ! empty( $action ) ) {
$where_clauses[] = $wpdb->prepare( "action = %s", $action );
}
if ( ! empty( $date_from ) ) {
$where_clauses[] = $wpdb->prepare( "timestamp >= %s", $date_from . ' 00:00:00' );
}
if ( ! empty( $date_to ) ) {
$where_clauses[] = $wpdb->prepare( "timestamp <= %s", $date_to . ' 23:59:59' );
}
$where_sql = '';
if ( ! empty( $where_clauses ) ) {
$where_sql = ' WHERE ' . implode( ' AND ', $where_clauses );
}
// Pagination
$per_page = 50;
$current_page = isset( $_GET['paged'] ) ? absint( $_GET['paged'] ) : 1;
$offset = ( $current_page - 1 ) * $per_page;
$total_logs = $wpdb->get_var( "SELECT COUNT(log_id) FROM $table_name $where_sql" );
$logs = $wpdb->get_results( "SELECT * FROM $table_name $where_sql ORDER BY timestamp DESC LIMIT $offset, $per_page" );
$total_pages = ceil( $total_logs / $per_page );
?>
()
' . __( 'Edit Ticket', 'enterprise-audit-log' ) . '</a>';
}
return $actions;
}
// This hook is for the post list table, not directly for the audit log page,
//