From b45137452399d0ef356f3b8fdfe3760a07172840 Mon Sep 17 00:00:00 2001 From: Matt Gros Date: Sun, 2 Aug 2026 13:08:35 -0400 Subject: [PATCH] feat(audit): add system-wide audit log Only per-ticket ticket_activities existed; admin, configuration, security, and user actions outside a ticket were not recorded. Port the Laravel reference AuditLog into the plugin's table-based convention: - add the escalated_audit_logs table (Activator + dbDelta) with actor, action, polymorphic auditable, JSON old/new snapshots, IP + user agent - add the AuditLog model with a single reusable record() helper - add an admin list/filter REST endpoint (GET /admin/audit-logs) gated by the existing audit.view capability - record entries at key mutation sites: settings + webhooks, role grant/revoke, API token create/revoke, 2FA enable/disable, and knowledge base article/category CRUD - bump plugin version to 1.4.0 so maybe_upgrade() creates the table on existing installs No new capabilities are introduced; the Activator capability count is unchanged. Adds WP_UnitTestCase coverage proving each site writes a row and that the admin list returns and filters entries. --- escalated.php | 4 +- includes/Admin/class-admin-settings.php | 73 ++- includes/Admin/class-admin-users.php | 11 + includes/Api/class-api-bootstrap.php | 1 + includes/Api/class-api-token-controller.php | 24 + .../Api/class-article-category-controller.php | 23 +- includes/Api/class-article-controller.php | 24 +- includes/Api/class-audit-log-controller.php | 173 +++++++ includes/Api/class-two-factor-controller.php | 9 + includes/Models/AuditLog.php | 275 +++++++++++ includes/class-activator.php | 25 + tests/Test_Activator.php | 1 + tests/Test_Audit_Log.php | 462 ++++++++++++++++++ 13 files changed, 1093 insertions(+), 12 deletions(-) create mode 100644 includes/Api/class-audit-log-controller.php create mode 100644 includes/Models/AuditLog.php create mode 100644 tests/Test_Audit_Log.php diff --git a/escalated.php b/escalated.php index c778060..184affc 100644 --- a/escalated.php +++ b/escalated.php @@ -4,7 +4,7 @@ * Plugin Name: Escalated * Plugin URI: https://github.com/escalated-dev/escalated-wordpress * Description: A full-featured helpdesk and ticketing system with multi-role support, SLA tracking, escalation rules, inbound email, macros, and REST API. - * Version: 1.3.0 + * Version: 1.4.0 * Author: Escalated * Author URI: https://escalated.dev * License: MIT @@ -18,7 +18,7 @@ exit; } -define('ESCALATED_VERSION', '1.3.0'); +define('ESCALATED_VERSION', '1.4.0'); define('ESCALATED_PLUGIN_FILE', __FILE__); define('ESCALATED_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('ESCALATED_PLUGIN_URL', plugin_dir_url(__FILE__)); diff --git a/includes/Admin/class-admin-settings.php b/includes/Admin/class-admin-settings.php index b11eba0..ca9fc6a 100644 --- a/includes/Admin/class-admin-settings.php +++ b/includes/Admin/class-admin-settings.php @@ -2,6 +2,7 @@ namespace Escalated\Admin; +use Escalated\Models\AuditLog; use Escalated\Models\Setting; class Admin_Settings @@ -39,7 +40,21 @@ public function handle_save(): void wp_die(esc_html__('Security check failed.', 'escalated')); } - $fields = [ + $this->persist(wp_unslash($_POST)); + + $redirect = admin_url('admin.php?page=escalated-settings&message=saved'); + wp_safe_redirect($redirect); + exit; + } + + /** + * The editable settings keys and their sanitizers. + * + * @return array + */ + private function fields(): array + { + return [ // General 'ticket_reference_prefix' => 'sanitize_text_field', 'default_priority' => 'sanitize_text_field', @@ -86,11 +101,36 @@ public function handle_save(): void // Maintenance 'activity_purge_days' => 'absint', ]; + } + + /** + * Persist a submitted (already unslashed) settings payload and write a + * single `settings.updated` audit entry capturing the keys that changed. + * + * Extracted from handle_save() so it is unit-testable without the + * redirect/exit at the HTTP boundary. Webhook fields (webhook_url, + * webhook_secret) flow through here too, so webhook changes are audited. + * + * @param array $input + */ + public function persist(array $input): void + { + $fields = $this->fields(); + + $audit_keys = array_merge( + array_keys($fields), + ['guest_policy_mode', 'guest_policy_user_id', 'guest_policy_signup_url_template'] + ); + + // Snapshot the prior values so we can diff after saving. + $before = []; + foreach ($audit_keys as $key) { + $before[$key] = Setting::get($key); + } foreach ($fields as $key => $sanitizer) { - if (isset($_POST[$key])) { - $value = wp_unslash($_POST[$key]); - $value = call_user_func($sanitizer, $value); + if (isset($input[$key])) { + $value = call_user_func($sanitizer, $input[$key]); if ($key === 'ticket_reference_prefix' && ! $this->is_valid_ticket_reference_prefix((string) $value)) { continue; @@ -128,9 +168,28 @@ public function handle_save(): void Setting::set('guest_policy_signup_url_template', ''); } - $redirect = admin_url('admin.php?page=escalated-settings&message=saved'); - wp_safe_redirect($redirect); - exit; + // Diff and record a single audit entry for the changed keys. Secret + // values are recorded as a redacted marker rather than plaintext. + $secret_keys = ['webhook_secret', 'inbound_email_password']; + $old_values = []; + $new_values = []; + foreach ($audit_keys as $key) { + $after = Setting::get($key); + if ((string) $after === (string) $before[$key]) { + continue; + } + if (in_array($key, $secret_keys, true)) { + $old_values[$key] = $before[$key] !== null && $before[$key] !== '' ? '********' : null; + $new_values[$key] = $after !== null && $after !== '' ? '********' : null; + } else { + $old_values[$key] = $before[$key]; + $new_values[$key] = $after; + } + } + + if ($new_values !== []) { + AuditLog::record('settings.updated', 'Settings', null, $old_values, $new_values); + } } /** diff --git a/includes/Admin/class-admin-users.php b/includes/Admin/class-admin-users.php index 7e4edaf..d09f2ec 100644 --- a/includes/Admin/class-admin-users.php +++ b/includes/Admin/class-admin-users.php @@ -2,6 +2,8 @@ namespace Escalated\Admin; +use Escalated\Models\AuditLog; + /** * Users management admin page. * @@ -191,6 +193,15 @@ public static function update_role(int $user_id, string $role, bool $value, ?int } } + AuditLog::record( + $value ? 'user.role_granted' : 'user.role_revoked', + 'User', + (int) $user->ID, + null, + ['role' => $role, 'value' => $value], + $current_user_id + ); + return ['ok' => true]; } diff --git a/includes/Api/class-api-bootstrap.php b/includes/Api/class-api-bootstrap.php index 1d59f9e..b2a9ea5 100644 --- a/includes/Api/class-api-bootstrap.php +++ b/includes/Api/class-api-bootstrap.php @@ -33,6 +33,7 @@ public function register_routes(): void new Dashboard_Controller, new Api_Token_Controller, new Two_Factor_Controller, + new Audit_Log_Controller, new Events_Controller, new Widget_Controller, new Article_Controller, diff --git a/includes/Api/class-api-token-controller.php b/includes/Api/class-api-token-controller.php index 013ed03..fe59b47 100644 --- a/includes/Api/class-api-token-controller.php +++ b/includes/Api/class-api-token-controller.php @@ -7,6 +7,7 @@ namespace Escalated\Api; use Escalated\Models\ApiToken; +use Escalated\Models\AuditLog; use WP_REST_Request; use WP_REST_Response; use WP_REST_Server; @@ -20,6 +21,16 @@ class Api_Token_Controller extends Base_Controller */ protected $rest_base = 'admin/api-tokens'; + /** + * User ID resolved by the permission callback for the current request. + * + * Bearer-token requests never call wp_set_current_user, so get_current_user_id() + * is 0 inside handlers — we cache the resolved token user here for auditing. + * + * @var int|null + */ + protected $acting_user_id = null; + /** * Register routes. */ @@ -111,6 +122,8 @@ public function admin_permissions_check(WP_REST_Request $request) return $this->error('escalated_unauthorized', __('Invalid or expired API token.', 'escalated'), 401); } + $this->acting_user_id = $user_id; + $user = get_userdata($user_id); if (! $user || ! $user->has_cap('escalated_api_token_manage')) { @@ -232,6 +245,12 @@ public function create_item($request) $token = ApiToken::find($result['record']->id); + AuditLog::record('api_token.created', 'ApiToken', (int) $token->id, null, [ + 'name' => $name, + 'user_id' => $user_id, + 'abilities' => $abilities, + ], $this->acting_user_id); + return $this->success([ 'message' => __('API token created successfully. Store this token securely - it will not be shown again.', 'escalated'), 'token' => [ @@ -274,6 +293,11 @@ public function delete_item($request) return $this->error('escalated_delete_failed', __('Failed to revoke API token.', 'escalated'), 500); } + AuditLog::record('api_token.deleted', 'ApiToken', $token_id, [ + 'name' => $token->name, + 'user_id' => (int) $token->user_id, + ], null, $this->acting_user_id); + return $this->success([ 'message' => __('API token revoked successfully.', 'escalated'), ]); diff --git a/includes/Api/class-article-category-controller.php b/includes/Api/class-article-category-controller.php index 80d37f3..f58b4dc 100644 --- a/includes/Api/class-article-category-controller.php +++ b/includes/Api/class-article-category-controller.php @@ -18,6 +18,7 @@ namespace Escalated\Api; use Escalated\Models\ArticleCategory; +use Escalated\Models\AuditLog; use WP_Error; use WP_REST_Request; use WP_REST_Server; @@ -132,6 +133,11 @@ public function store(WP_REST_Request $request) return $this->error('escalated_create_failed', __('Failed to create category.', 'escalated'), 500); } + AuditLog::record('kb_category.created', 'ArticleCategory', (int) $id, null, [ + 'name' => $validated['name'], + 'slug' => $validated['slug'], + ]); + return $this->success(['id' => (int) $id, 'category' => $this->format_category(ArticleCategory::find($id))], 201); } @@ -141,7 +147,8 @@ public function store(WP_REST_Request $request) public function update(WP_REST_Request $request) { $id = (int) $request->get_param('id'); - if (! ArticleCategory::find($id)) { + $existing = ArticleCategory::find($id); + if (! $existing) { return $this->error('escalated_not_found', __('Category not found.', 'escalated'), 404); } @@ -160,6 +167,12 @@ public function update(WP_REST_Request $request) return $this->error('escalated_update_failed', __('Failed to update category.', 'escalated'), 500); } + AuditLog::record('kb_category.updated', 'ArticleCategory', $id, [ + 'name' => $existing->name, + ], [ + 'name' => $validated['name'], + ]); + return $this->success(['category' => $this->format_category(ArticleCategory::find($id))]); } @@ -169,12 +182,18 @@ public function update(WP_REST_Request $request) public function destroy(WP_REST_Request $request) { $id = (int) $request->get_param('id'); - if (! ArticleCategory::find($id)) { + $existing = ArticleCategory::find($id); + if (! $existing) { return $this->error('escalated_not_found', __('Category not found.', 'escalated'), 404); } ArticleCategory::delete($id); + AuditLog::record('kb_category.deleted', 'ArticleCategory', $id, [ + 'name' => $existing->name, + 'slug' => $existing->slug, + ], null); + return $this->success(null, 204); } diff --git a/includes/Api/class-article-controller.php b/includes/Api/class-article-controller.php index f7804fe..dd7740e 100644 --- a/includes/Api/class-article-controller.php +++ b/includes/Api/class-article-controller.php @@ -20,6 +20,7 @@ use Escalated\Models\Article; use Escalated\Models\ArticleCategory; +use Escalated\Models\AuditLog; use WP_Error; use WP_REST_Request; use WP_REST_Server; @@ -201,6 +202,12 @@ public function store(WP_REST_Request $request) return $this->error('escalated_create_failed', __('Failed to create article.', 'escalated'), 500); } + AuditLog::record('kb_article.created', 'Article', (int) $id, null, [ + 'title' => $validated['title'], + 'slug' => $validated['slug'], + 'status' => $validated['status'], + ]); + return $this->success(['id' => (int) $id, 'article' => $this->format_article(Article::find($id))], 201); } @@ -235,6 +242,14 @@ public function update(WP_REST_Request $request) return $this->error('escalated_update_failed', __('Failed to update article.', 'escalated'), 500); } + AuditLog::record('kb_article.updated', 'Article', $id, [ + 'title' => $article->title, + 'status' => $article->status, + ], [ + 'title' => $validated['title'], + 'status' => $validated['status'], + ]); + return $this->success(['article' => $this->format_article(Article::find($id))]); } @@ -244,12 +259,19 @@ public function update(WP_REST_Request $request) public function destroy(WP_REST_Request $request) { $id = (int) $request->get_param('id'); - if (! Article::find($id)) { + $article = Article::find($id); + if (! $article) { return $this->error('escalated_not_found', __('Article not found.', 'escalated'), 404); } Article::delete($id); + AuditLog::record('kb_article.deleted', 'Article', $id, [ + 'title' => $article->title, + 'slug' => $article->slug, + 'status' => $article->status, + ], null); + return $this->success(null, 204); } diff --git a/includes/Api/class-audit-log-controller.php b/includes/Api/class-audit-log-controller.php new file mode 100644 index 0000000..bd5d8d0 --- /dev/null +++ b/includes/Api/class-audit-log-controller.php @@ -0,0 +1,173 @@ +namespace, '/'.$this->rest_base, [ + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [$this, 'index'], + 'permission_callback' => [$this, 'permission_view'], + 'args' => [ + 'user_id' => ['type' => 'integer', 'sanitize_callback' => 'absint'], + 'action' => ['type' => 'string', 'sanitize_callback' => 'sanitize_text_field'], + 'auditable_type' => ['type' => 'string', 'sanitize_callback' => 'sanitize_text_field'], + 'date_from' => ['type' => 'string', 'sanitize_callback' => 'sanitize_text_field'], + 'date_to' => ['type' => 'string', 'sanitize_callback' => 'sanitize_text_field'], + 'page' => ['type' => 'integer', 'default' => 1, 'sanitize_callback' => 'absint'], + 'per_page' => ['type' => 'integer', 'default' => 50, 'sanitize_callback' => 'absint'], + ], + ], + ]); + } + + /** + * Login + capability guard (escalated_audit_view). + * + * @return bool|WP_Error + */ + public function permission_view() + { + if (! is_user_logged_in()) { + return new WP_Error( + 'escalated_unauthorized', + __('You must be logged in.', 'escalated'), + ['status' => 401] + ); + } + + if (! current_user_can('escalated_audit_view')) { + return new WP_Error( + 'escalated_forbidden', + __('You do not have permission to view the audit log.', 'escalated'), + ['status' => 403] + ); + } + + return true; + } + + /** + * GET /admin/audit-logs + * + * @return \WP_REST_Response|WP_Error + */ + public function index(WP_REST_Request $request) + { + $filters = [ + 'user_id' => $request->get_param('user_id') ? (int) $request->get_param('user_id') : null, + 'action' => (string) $request->get_param('action'), + 'auditable_type' => (string) $request->get_param('auditable_type'), + 'date_from' => (string) $request->get_param('date_from'), + 'date_to' => (string) $request->get_param('date_to'), + ]; + + $page = max(1, (int) $request->get_param('page')); + $per_page = (int) $request->get_param('per_page'); + $per_page = $per_page > 0 ? min(200, $per_page) : 50; + $offset = ($page - 1) * $per_page; + + $rows = AuditLog::all($filters, $per_page, $offset); + $total = AuditLog::count($filters); + + return $this->success([ + 'logs' => array_map([$this, 'format_log'], $rows), + 'total' => $total, + 'page' => $page, + 'per_page' => $per_page, + 'total_pages' => (int) ceil($total / $per_page), + 'filters' => [ + 'user_id' => $filters['user_id'], + 'action' => $filters['action'], + 'auditable_type' => $filters['auditable_type'], + 'date_from' => $filters['date_from'], + 'date_to' => $filters['date_to'], + ], + 'actions' => AuditLog::distinct_actions(), + 'resource_types' => AuditLog::distinct_types(), + ]); + } + + /** + * Project an audit row into the response shape. + * + * @param object $row + * @return array + */ + private function format_log($row): array + { + $user = null; + if (! empty($row->user_id)) { + $wp_user = get_userdata((int) $row->user_id); + if ($wp_user) { + $user = [ + 'id' => (int) $wp_user->ID, + 'name' => $wp_user->display_name ?: $wp_user->user_login, + 'email' => $wp_user->user_email, + ]; + } + } + + return [ + 'id' => (int) $row->id, + 'user_id' => $row->user_id !== null ? (int) $row->user_id : null, + 'user' => $user, + 'action' => $row->action, + 'auditable_type' => $row->auditable_type, + 'auditable_id' => $row->auditable_id !== null ? (int) $row->auditable_id : null, + 'old_values' => $this->decode_json($row->old_values), + 'new_values' => $this->decode_json($row->new_values), + 'ip_address' => $row->ip_address, + 'user_agent' => $row->user_agent, + 'created_at' => $row->created_at, + ]; + } + + /** + * Decode a stored JSON column back into an array (or null). + * + * @param string|null $value + * @return array|null + */ + private function decode_json($value) + { + if (empty($value)) { + return null; + } + + $decoded = json_decode((string) $value, true); + + return is_array($decoded) ? $decoded : null; + } +} diff --git a/includes/Api/class-two-factor-controller.php b/includes/Api/class-two-factor-controller.php index 3bf80d6..5d21821 100644 --- a/includes/Api/class-two-factor-controller.php +++ b/includes/Api/class-two-factor-controller.php @@ -11,6 +11,7 @@ namespace Escalated\Api; +use Escalated\Models\AuditLog; use Escalated\Models\TwoFactor; use Escalated\Services\TwoFactorService; use WP_REST_Request; @@ -217,6 +218,8 @@ public function confirm($request) TwoFactor::confirm($record->id); + AuditLog::record('two_factor.enabled', 'User', $user_id, null, null, $user_id); + return $this->success([ 'message' => __('Two-factor authentication enabled.', 'escalated'), 'enabled' => true, @@ -301,8 +304,14 @@ public function disable($request) { $user_id = $this->resolve_user_id($request); + $had_record = TwoFactor::for_user($user_id) !== null; + TwoFactor::delete_for_user($user_id); + if ($had_record) { + AuditLog::record('two_factor.disabled', 'User', $user_id, null, null, $user_id); + } + return $this->success([ 'message' => __('Two-factor authentication disabled.', 'escalated'), ]); diff --git a/includes/Models/AuditLog.php b/includes/Models/AuditLog.php new file mode 100644 index 0000000..231fe3d --- /dev/null +++ b/includes/Models/AuditLog.php @@ -0,0 +1,275 @@ +|null $old_values Prior state snapshot. + * @param array|null $new_values New state snapshot. + * @param int|null $user_id Actor override; falls back to the current user. + * @return int|false Inserted ID or false on failure. + */ + public static function record( + string $action, + ?string $auditable_type = null, + $auditable_id = null, + ?array $old_values = null, + ?array $new_values = null, + ?int $user_id = null + ) { + if ($user_id === null) { + $current = function_exists('get_current_user_id') ? (int) get_current_user_id() : 0; + $user_id = $current > 0 ? $current : null; + } + + return static::create([ + 'user_id' => $user_id, + 'action' => $action, + 'auditable_type' => $auditable_type, + 'auditable_id' => $auditable_id !== null ? (int) $auditable_id : null, + 'old_values' => $old_values, + 'new_values' => $new_values, + 'ip_address' => static::current_ip(), + 'user_agent' => static::current_user_agent(), + ]); + } + + /** + * Insert an audit row. + * + * old_values / new_values arrays are JSON-encoded; created_at is stamped + * when not supplied. + * + * @return int|false Inserted ID or false on failure. + */ + public static function create(array $data) + { + global $wpdb; + $table = static::table(); + + foreach (['old_values', 'new_values'] as $json_key) { + if (! array_key_exists($json_key, $data)) { + continue; + } + $value = $data[$json_key]; + $data[$json_key] = ($value === null || $value === []) + ? null + : wp_json_encode($value); + } + + if (empty($data['created_at'])) { + $data['created_at'] = current_time('mysql'); + } + + $result = $wpdb->insert($table, $data); + + return $result !== false ? $wpdb->insert_id : false; + } + + /** + * Find an audit row by ID. + * + * @param int $id + * @return object|null + */ + public static function find($id) + { + global $wpdb; + $table = static::table(); + + return $wpdb->get_row( + $wpdb->prepare("SELECT * FROM {$table} WHERE id = %d", (int) $id) + ); + } + + /** + * List audit rows, newest first, with optional filters and pagination. + * + * Supported filters: user_id, action, auditable_type, date_from, date_to. + * + * @param array $filters + * @return array + */ + public static function all(array $filters = [], int $limit = 50, int $offset = 0) + { + global $wpdb; + $table = static::table(); + + [$where, $values] = static::build_where($filters); + + $limit = max(1, $limit); + $offset = max(0, $offset); + + $sql = "SELECT * FROM {$table} WHERE {$where} ORDER BY created_at DESC, id DESC LIMIT %d OFFSET %d"; + $values[] = $limit; + $values[] = $offset; + + return $wpdb->get_results($wpdb->prepare($sql, $values)) ?: []; + } + + /** + * Count audit rows matching the given filters. + * + * @param array $filters + */ + public static function count(array $filters = []): int + { + global $wpdb; + $table = static::table(); + + [$where, $values] = static::build_where($filters); + + $sql = "SELECT COUNT(*) FROM {$table} WHERE {$where}"; + + if (! empty($values)) { + $sql = $wpdb->prepare($sql, $values); + } + + return (int) $wpdb->get_var($sql); + } + + /** + * Distinct action verbs present in the log (for the filter dropdown). + * + * @return array + */ + public static function distinct_actions(): array + { + global $wpdb; + $table = static::table(); + + return $wpdb->get_col("SELECT DISTINCT action FROM {$table} ORDER BY action ASC") ?: []; + } + + /** + * Distinct auditable types present in the log (for the filter dropdown). + * + * @return array + */ + public static function distinct_types(): array + { + global $wpdb; + $table = static::table(); + + return $wpdb->get_col( + "SELECT DISTINCT auditable_type FROM {$table} WHERE auditable_type IS NOT NULL AND auditable_type <> '' ORDER BY auditable_type ASC" + ) ?: []; + } + + /** + * Build the shared WHERE clause + prepared values for the given filters. + * + * @param array $filters + * @return array{0: string, 1: array} + */ + protected static function build_where(array $filters): array + { + $where = ['1=1']; + $values = []; + + if (! empty($filters['user_id'])) { + $where[] = 'user_id = %d'; + $values[] = (int) $filters['user_id']; + } + + if (! empty($filters['action'])) { + $where[] = 'action = %s'; + $values[] = (string) $filters['action']; + } + + if (! empty($filters['auditable_type'])) { + $where[] = 'auditable_type = %s'; + $values[] = (string) $filters['auditable_type']; + } + + if (! empty($filters['date_from'])) { + $where[] = 'created_at >= %s'; + $values[] = (string) $filters['date_from']; + } + + if (! empty($filters['date_to'])) { + $where[] = 'created_at <= %s'; + $values[] = (string) $filters['date_to'].' 23:59:59'; + } + + return [implode(' AND ', $where), $values]; + } + + /** + * Best-effort client IP for the current request. + */ + protected static function current_ip(): ?string + { + $candidates = []; + + if (! empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { + $forwarded = explode(',', (string) $_SERVER['HTTP_X_FORWARDED_FOR']); + $candidates[] = trim($forwarded[0]); + } + + if (! empty($_SERVER['REMOTE_ADDR'])) { + $candidates[] = (string) $_SERVER['REMOTE_ADDR']; + } + + foreach ($candidates as $candidate) { + $ip = filter_var($candidate, FILTER_VALIDATE_IP); + if ($ip !== false) { + return substr($ip, 0, 45); + } + } + + return null; + } + + /** + * User agent for the current request, truncated to the column width. + */ + protected static function current_user_agent(): ?string + { + if (empty($_SERVER['HTTP_USER_AGENT'])) { + return null; + } + + $agent = sanitize_text_field((string) $_SERVER['HTTP_USER_AGENT']); + + return $agent !== '' ? substr($agent, 0, 255) : null; + } +} diff --git a/includes/class-activator.php b/includes/class-activator.php index ee60d8b..d9f4a5e 100644 --- a/includes/class-activator.php +++ b/includes/class-activator.php @@ -688,6 +688,31 @@ private static function create_tables(): void KEY status (status) ) $charset_collate;"; dbDelta($sql); + + // 37. escalated_audit_logs — system-wide audit trail for admin / + // configuration / security / user actions that happen outside a single + // ticket (settings + webhooks, role grants, API token + 2FA lifecycle, + // knowledge base CRUD). Mirrors the Laravel reference audit_logs schema; + // auditable_type / auditable_id are nullable here because some system + // events (e.g. settings changes) are not tied to a single model row. + $sql = "CREATE TABLE {$prefix}audit_logs ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NULL, + action VARCHAR(255) NOT NULL, + auditable_type VARCHAR(255) NULL, + auditable_id BIGINT UNSIGNED NULL, + old_values LONGTEXT NULL, + new_values LONGTEXT NULL, + ip_address VARCHAR(45) NULL, + user_agent VARCHAR(255) NULL, + created_at DATETIME, + PRIMARY KEY (id), + KEY auditable (auditable_type, auditable_id), + KEY user_id (user_id), + KEY action (action), + KEY created_at (created_at) + ) $charset_collate;"; + dbDelta($sql); } /** diff --git a/tests/Test_Activator.php b/tests/Test_Activator.php index 490f809..475c132 100644 --- a/tests/Test_Activator.php +++ b/tests/Test_Activator.php @@ -54,6 +54,7 @@ public function test_tables_created(): void 'escalated_skill_routing_departments', 'escalated_agent_skills', 'escalated_ticket_subjects', + 'escalated_audit_logs', ]; $existing_tables = $wpdb->get_col('SHOW TABLES'); diff --git a/tests/Test_Audit_Log.php b/tests/Test_Audit_Log.php new file mode 100644 index 0000000..2481ee5 --- /dev/null +++ b/tests/Test_Audit_Log.php @@ -0,0 +1,462 @@ +admin_id = $this->factory->user->create(['role' => 'escalated_admin']); + + global $wp_rest_server; + $this->server = $wp_rest_server = new WP_REST_Server; + do_action('rest_api_init'); + } + + public function tear_down(): void + { + global $wp_rest_server; + $wp_rest_server = null; + parent::tear_down(); + } + + // ===================================================================== + // Helpers + // ===================================================================== + + private function json_request(string $method, string $route, ?array $body = null, ?string $token = null): WP_REST_Request + { + $request = new WP_REST_Request($method, $route); + if ($token !== null) { + $request->set_header('Authorization', 'Bearer '.$token); + } + if ($body !== null) { + $request->set_header('Content-Type', 'application/json'); + $request->set_body(wp_json_encode($body)); + } + + return $request; + } + + private function count_action(string $action): int + { + return AuditLog::count(['action' => $action]); + } + + private function latest_for(string $action): ?object + { + global $wpdb; + $table = AuditLog::table(); + + return $wpdb->get_row( + $wpdb->prepare("SELECT * FROM {$table} WHERE action = %s ORDER BY id DESC LIMIT 1", $action) + ); + } + + // ===================================================================== + // Schema + model + // ===================================================================== + + public function test_audit_logs_table_created(): void + { + global $wpdb; + $existing = $wpdb->get_col('SHOW TABLES'); + + $this->assertContains($wpdb->prefix.'escalated_audit_logs', $existing); + } + + public function test_record_persists_actor_action_and_request_context(): void + { + wp_set_current_user($this->admin_id); + $_SERVER['REMOTE_ADDR'] = '203.0.113.9'; + $_SERVER['HTTP_USER_AGENT'] = 'PHPUnit Audit Agent'; + + $id = AuditLog::record('demo.action', 'Widget', 42, ['name' => 'old'], ['name' => 'new']); + $this->assertIsInt($id); + + $row = AuditLog::find($id); + $this->assertSame($this->admin_id, (int) $row->user_id); + $this->assertSame('demo.action', $row->action); + $this->assertSame('Widget', $row->auditable_type); + $this->assertSame(42, (int) $row->auditable_id); + $this->assertSame(['name' => 'old'], json_decode($row->old_values, true)); + $this->assertSame(['name' => 'new'], json_decode($row->new_values, true)); + $this->assertSame('203.0.113.9', $row->ip_address); + $this->assertSame('PHPUnit Audit Agent', $row->user_agent); + $this->assertNotEmpty($row->created_at); + + unset($_SERVER['HTTP_USER_AGENT']); + } + + public function test_record_actor_defaults_to_null_for_system_events(): void + { + wp_set_current_user(0); + $id = AuditLog::record('system.event'); + $row = AuditLog::find($id); + + $this->assertNull($row->user_id); + $this->assertNull($row->auditable_type); + $this->assertNull($row->old_values); + $this->assertNull($row->new_values); + } + + public function test_all_filters_and_orders_newest_first(): void + { + wp_set_current_user($this->admin_id); + $other = $this->factory->user->create(['role' => 'escalated_agent']); + + $first = AuditLog::record('alpha.one', 'Widget', 1); + AuditLog::record('beta.two', 'Gadget', 2); + wp_set_current_user($other); + $third = AuditLog::record('alpha.one', 'Widget', 3); + wp_set_current_user($this->admin_id); + + // Newest first. + $all = AuditLog::all(); + $this->assertSame($third, (int) $all[0]->id); + + // Filter by action. + $alphas = AuditLog::all(['action' => 'alpha.one']); + $this->assertCount(2, $alphas); + + // Filter by user. + $by_other = AuditLog::all(['user_id' => $other]); + $this->assertCount(1, $by_other); + $this->assertSame($third, (int) $by_other[0]->id); + + // Filter by auditable type. + $this->assertCount(1, AuditLog::all(['auditable_type' => 'Gadget'])); + + $this->assertSame(3, AuditLog::count()); + $this->assertContains('alpha.one', AuditLog::distinct_actions()); + $this->assertContains('Gadget', AuditLog::distinct_types()); + $this->assertGreaterThan(0, $first); + } + + // ===================================================================== + // Mutation site: settings + webhooks + // ===================================================================== + + public function test_settings_update_writes_audit_row(): void + { + wp_set_current_user($this->admin_id); + + (new Admin_Settings)->persist([ + 'ticket_reference_prefix' => 'HELP', + 'default_priority' => 'high', + ]); + + $row = $this->latest_for('settings.updated'); + $this->assertNotNull($row); + $this->assertSame('Settings', $row->auditable_type); + $this->assertSame($this->admin_id, (int) $row->user_id); + + $new = json_decode($row->new_values, true); + $this->assertSame('HELP', $new['ticket_reference_prefix']); + $this->assertSame('high', $new['default_priority']); + + $old = json_decode($row->old_values, true); + $this->assertSame('ESC', $old['ticket_reference_prefix']); + } + + public function test_webhook_change_is_audited_with_redacted_secret(): void + { + wp_set_current_user($this->admin_id); + + (new Admin_Settings)->persist([ + 'webhook_url' => 'https://example.com/hook', + 'webhook_secret' => 'super-secret-value', + ]); + + $row = $this->latest_for('settings.updated'); + $this->assertNotNull($row); + + $new = json_decode($row->new_values, true); + $this->assertSame('https://example.com/hook', $new['webhook_url']); + // Secret is redacted, never stored in plaintext in the audit trail. + $this->assertSame('********', $new['webhook_secret']); + $this->assertStringNotContainsString('super-secret-value', (string) $row->new_values); + } + + public function test_settings_persist_records_only_on_change(): void + { + wp_set_current_user($this->admin_id); + + // First save settles the form state (e.g. absent checkboxes -> 0) and + // records one entry for what changed. + (new Admin_Settings)->persist(['ticket_reference_prefix' => 'ESC']); + $after_first = $this->count_action('settings.updated'); + $this->assertSame(1, $after_first); + + // Re-submitting the identical payload changes nothing, so no new row. + (new Admin_Settings)->persist(['ticket_reference_prefix' => 'ESC']); + $this->assertSame($after_first, $this->count_action('settings.updated')); + } + + // ===================================================================== + // Mutation site: users / roles + // ===================================================================== + + public function test_role_grant_and_revoke_are_audited(): void + { + $target = $this->factory->user->create(['role' => 'subscriber']); + wp_set_current_user($this->admin_id); + + $granted = Admin_Users::update_role($target, 'agent', true, $this->admin_id); + $this->assertTrue($granted['ok']); + + $grant_row = $this->latest_for('user.role_granted'); + $this->assertNotNull($grant_row); + $this->assertSame('User', $grant_row->auditable_type); + $this->assertSame($target, (int) $grant_row->auditable_id); + $this->assertSame($this->admin_id, (int) $grant_row->user_id); + $this->assertSame(['role' => 'agent', 'value' => true], json_decode($grant_row->new_values, true)); + + $revoked = Admin_Users::update_role($target, 'agent', false, $this->admin_id); + $this->assertTrue($revoked['ok']); + $this->assertSame(1, $this->count_action('user.role_revoked')); + } + + // ===================================================================== + // Mutation site: knowledge base CRUD (REST, cookie auth) + // ===================================================================== + + public function test_kb_article_crud_is_audited(): void + { + wp_set_current_user($this->admin_id); + + $created = $this->server->dispatch($this->json_request('POST', '/escalated/v1/admin/kb/articles', [ + 'title' => 'Audited article', + 'status' => 'published', + ])); + $this->assertSame(201, $created->get_status()); + $id = (int) $created->get_data()['id']; + + $create_row = $this->latest_for('kb_article.created'); + $this->assertNotNull($create_row); + $this->assertSame('Article', $create_row->auditable_type); + $this->assertSame($id, (int) $create_row->auditable_id); + + $updated = $this->server->dispatch($this->json_request('PATCH', '/escalated/v1/admin/kb/articles/'.$id, [ + 'title' => 'Audited article v2', + 'status' => 'published', + ])); + $this->assertSame(200, $updated->get_status()); + $this->assertSame(1, $this->count_action('kb_article.updated')); + + $deleted = $this->server->dispatch(new WP_REST_Request('DELETE', '/escalated/v1/admin/kb/articles/'.$id)); + $this->assertSame(204, $deleted->get_status()); + $this->assertSame(1, $this->count_action('kb_article.deleted')); + } + + public function test_kb_category_crud_is_audited(): void + { + wp_set_current_user($this->admin_id); + + $created = $this->server->dispatch($this->json_request('POST', '/escalated/v1/admin/kb/categories', [ + 'name' => 'Billing', + ])); + $this->assertSame(201, $created->get_status()); + $id = (int) $created->get_data()['id']; + $this->assertSame(1, $this->count_action('kb_category.created')); + + $updated = $this->server->dispatch($this->json_request('PATCH', '/escalated/v1/admin/kb/categories/'.$id, [ + 'name' => 'Billing & Payments', + ])); + $this->assertSame(200, $updated->get_status()); + $this->assertSame(1, $this->count_action('kb_category.updated')); + + $deleted = $this->server->dispatch(new WP_REST_Request('DELETE', '/escalated/v1/admin/kb/categories/'.$id)); + $this->assertSame(204, $deleted->get_status()); + $this->assertSame(1, $this->count_action('kb_category.deleted')); + } + + // ===================================================================== + // Mutation site: API tokens (REST, bearer auth) + // ===================================================================== + + public function test_api_token_create_and_delete_are_audited(): void + { + $bearer = ApiToken::create_token($this->admin_id, 'audit-runner', ['*'])['token']; + + $created = $this->server->dispatch($this->json_request( + 'POST', + '/escalated/v1/admin/api-tokens', + ['name' => 'CI token', 'user_id' => $this->admin_id], + $bearer + )); + $this->assertSame(201, $created->get_status()); + $new_id = (int) $created->get_data()['token']['id']; + + $create_row = $this->latest_for('api_token.created'); + $this->assertNotNull($create_row); + $this->assertSame('ApiToken', $create_row->auditable_type); + $this->assertSame($new_id, (int) $create_row->auditable_id); + // Bearer requests have no cookie user; the acting token user is recorded. + $this->assertSame($this->admin_id, (int) $create_row->user_id); + + $deleted = $this->server->dispatch($this->json_request( + 'DELETE', + '/escalated/v1/admin/api-tokens/'.$new_id, + null, + $bearer + )); + $this->assertSame(200, $deleted->get_status()); + $this->assertSame(1, $this->count_action('api_token.deleted')); + } + + // ===================================================================== + // Mutation site: two-factor authentication (REST, bearer auth) + // ===================================================================== + + public function test_two_factor_enable_and_disable_are_audited(): void + { + $user_id = $this->factory->user->create(['role' => 'escalated_agent']); + $bearer = ApiToken::create_token($user_id, '2fa-runner', ['*'])['token']; + $service = new TwoFactorService; + + $setup = $this->server->dispatch($this->json_request('POST', '/escalated/v1/admin/two-factor/setup', null, $bearer)); + $this->assertSame(201, $setup->get_status()); + $secret = $setup->get_data()['secret']; + + $code = $service->generate_totp($secret, (int) floor(time() / 30)); + $confirm = $this->server->dispatch($this->json_request('POST', '/escalated/v1/admin/two-factor/confirm', ['code' => $code], $bearer)); + $this->assertSame(200, $confirm->get_status()); + + $enable_row = $this->latest_for('two_factor.enabled'); + $this->assertNotNull($enable_row); + $this->assertSame('User', $enable_row->auditable_type); + $this->assertSame($user_id, (int) $enable_row->auditable_id); + $this->assertSame($user_id, (int) $enable_row->user_id); + + $disable = $this->server->dispatch($this->json_request('DELETE', '/escalated/v1/admin/two-factor', null, $bearer)); + $this->assertSame(200, $disable->get_status()); + $this->assertSame(1, $this->count_action('two_factor.disabled')); + } + + // ===================================================================== + // Admin list / filter REST surface + // ===================================================================== + + public function test_index_requires_authentication(): void + { + wp_set_current_user(0); + $response = $this->server->dispatch(new WP_REST_Request('GET', '/escalated/v1/admin/audit-logs')); + $this->assertSame(401, $response->get_status()); + } + + public function test_index_forbidden_without_audit_capability(): void + { + $light = $this->factory->user->create(['role' => 'escalated_light_agent']); + wp_set_current_user($light); + $response = $this->server->dispatch(new WP_REST_Request('GET', '/escalated/v1/admin/audit-logs')); + $this->assertSame(403, $response->get_status()); + } + + public function test_agent_with_audit_view_can_list(): void + { + // The escalated_agent role is seeded with the audit.view capability. + $agent = $this->factory->user->create(['role' => 'escalated_agent']); + wp_set_current_user($agent); + $response = $this->server->dispatch(new WP_REST_Request('GET', '/escalated/v1/admin/audit-logs')); + $this->assertSame(200, $response->get_status()); + } + + public function test_index_returns_seeded_rows_and_metadata(): void + { + wp_set_current_user($this->admin_id); + AuditLog::record('seed.one', 'Widget', 1); + AuditLog::record('seed.two', 'Gadget', 2); + + $response = $this->server->dispatch(new WP_REST_Request('GET', '/escalated/v1/admin/audit-logs')); + $this->assertSame(200, $response->get_status()); + + $data = $response->get_data(); + $this->assertSame(2, $data['total']); + $this->assertCount(2, $data['logs']); + $this->assertArrayHasKey('actions', $data); + $this->assertArrayHasKey('resource_types', $data); + $this->assertContains('seed.one', $data['actions']); + $this->assertContains('Gadget', $data['resource_types']); + + // Newest first, and the actor is expanded. + $this->assertSame('seed.two', $data['logs'][0]['action']); + $this->assertSame($this->admin_id, $data['logs'][0]['user']['id']); + } + + public function test_index_filters_by_action(): void + { + wp_set_current_user($this->admin_id); + AuditLog::record('keep.this', 'Widget', 1); + AuditLog::record('drop.this', 'Widget', 2); + + $request = new WP_REST_Request('GET', '/escalated/v1/admin/audit-logs'); + $request->set_param('action', 'keep.this'); + $response = $this->server->dispatch($request); + + $this->assertSame(200, $response->get_status()); + $data = $response->get_data(); + $this->assertSame(1, $data['total']); + $this->assertSame('keep.this', $data['logs'][0]['action']); + } + + public function test_index_filters_by_user_type_and_date(): void + { + $other = $this->factory->user->create(['role' => 'escalated_agent']); + + wp_set_current_user($this->admin_id); + AuditLog::record('by.admin', 'Widget', 1); + wp_set_current_user($other); + AuditLog::record('by.other', 'Gadget', 2); + wp_set_current_user($this->admin_id); + + // Filter by user. + $req = new WP_REST_Request('GET', '/escalated/v1/admin/audit-logs'); + $req->set_param('user_id', $other); + $data = $this->server->dispatch($req)->get_data(); + $this->assertSame(1, $data['total']); + $this->assertSame('by.other', $data['logs'][0]['action']); + + // Filter by auditable type. + $req = new WP_REST_Request('GET', '/escalated/v1/admin/audit-logs'); + $req->set_param('auditable_type', 'Widget'); + $data = $this->server->dispatch($req)->get_data(); + $this->assertSame(1, $data['total']); + $this->assertSame('by.admin', $data['logs'][0]['action']); + + // Date range that includes today returns rows... + $today = current_time('Y-m-d'); + $req = new WP_REST_Request('GET', '/escalated/v1/admin/audit-logs'); + $req->set_param('date_from', $today); + $req->set_param('date_to', $today); + $data = $this->server->dispatch($req)->get_data(); + $this->assertSame(2, $data['total']); + + // ...a window ending yesterday excludes them. + $yesterday = gmdate('Y-m-d', strtotime($today.' -1 day')); + $req = new WP_REST_Request('GET', '/escalated/v1/admin/audit-logs'); + $req->set_param('date_to', $yesterday); + $data = $this->server->dispatch($req)->get_data(); + $this->assertSame(0, $data['total']); + } +}