conn = $conn; } /** * Validate and sanitize pagination limit * * @param int $limit Requested limit * @return int Validated limit */ private function validateLimit(int $limit): int { if ($limit < 1) { return self::DEFAULT_LIMIT; } return min($limit, self::MAX_LIMIT); } /** * Validate and sanitize pagination offset * * @param int $offset Requested offset * @return int Validated offset (non-negative) */ private function validateOffset(int $offset): int { return max(0, $offset); } /** * Validate date format (YYYY-MM-DD) * * @param string $date Date string * @return string|null Validated date or null if invalid */ private function validateDate(string $date): ?string { // Check format if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { return null; } // Verify it's a valid date $parts = explode('-', $date); if (!checkdate((int)$parts[1], (int)$parts[2], (int)$parts[0])) { return null; } return $date; } /** * Validate action type * * @param string $actionType Action type to validate * @return bool True if valid */ private function isValidActionType(string $actionType): bool { return in_array($actionType, self::VALID_ACTION_TYPES, true); } /** * Validate entity type * * @param string $entityType Entity type to validate * @return bool True if valid */ private function isValidEntityType(string $entityType): bool { return in_array($entityType, self::VALID_ENTITY_TYPES, true); } /** * Log an action to the audit trail * * @param int $userId User ID performing the action * @param string $actionType Type of action (e.g., 'create', 'update', 'delete', 'view') * @param string $entityType Type of entity (e.g., 'ticket', 'comment', 'api_key') * @param string|null $entityId ID of the entity affected * @param array|null $details Additional details as associative array * @param string|null $ipAddress IP address of the user * @return bool Success status */ public function log($userId, $actionType, $entityType, $entityId = null, $details = null, $ipAddress = null) { // Convert details array to JSON $detailsJson = null; if ($details !== null) { $detailsJson = json_encode($details); } // Get IP address if not provided if ($ipAddress === null) { $ipAddress = $this->getClientIP(); } $stmt = $this->conn->prepare( "INSERT INTO audit_log (user_id, action_type, entity_type, entity_id, details, ip_address) VALUES (?, ?, ?, ?, ?, ?)" ); $stmt->bind_param("isssss", $userId, $actionType, $entityType, $entityId, $detailsJson, $ipAddress); $success = $stmt->execute(); $stmt->close(); return $success; } /** * Get audit logs for a specific entity * * @param string $entityType Type of entity * @param string $entityId ID of the entity * @param int $limit Maximum number of logs to return * @return array Array of audit log records */ public function getLogsByEntity($entityType, $entityId, $limit = 100) { $limit = $this->validateLimit((int)$limit); $stmt = $this->conn->prepare( "SELECT al.*, u.username, u.display_name FROM audit_log al LEFT JOIN users u ON al.user_id = u.user_id WHERE al.entity_type = ? AND al.entity_id = ? ORDER BY al.created_at DESC LIMIT ?" ); $stmt->bind_param("ssi", $entityType, $entityId, $limit); $stmt->execute(); $result = $stmt->get_result(); $logs = []; while ($row = $result->fetch_assoc()) { // Decode JSON details if ($row['details']) { $row['details'] = json_decode($row['details'], true); } $logs[] = $row; } $stmt->close(); return $logs; } /** * Get audit logs for a specific user * * @param int $userId User ID * @param int $limit Maximum number of logs to return * @return array Array of audit log records */ public function getLogsByUser($userId, $limit = 100) { $limit = $this->validateLimit((int)$limit); $userId = max(0, (int)$userId); $stmt = $this->conn->prepare( "SELECT al.*, u.username, u.display_name FROM audit_log al LEFT JOIN users u ON al.user_id = u.user_id WHERE al.user_id = ? ORDER BY al.created_at DESC LIMIT ?" ); $stmt->bind_param("ii", $userId, $limit); $stmt->execute(); $result = $stmt->get_result(); $logs = []; while ($row = $result->fetch_assoc()) { // Decode JSON details if ($row['details']) { $row['details'] = json_decode($row['details'], true); } $logs[] = $row; } $stmt->close(); return $logs; } /** * Get recent audit logs (for admin panel) * * @param int $limit Maximum number of logs to return * @param int $offset Offset for pagination * @return array Array of audit log records */ public function getRecentLogs($limit = 50, $offset = 0) { $limit = $this->validateLimit((int)$limit); $offset = $this->validateOffset((int)$offset); $stmt = $this->conn->prepare( "SELECT al.*, u.username, u.display_name FROM audit_log al LEFT JOIN users u ON al.user_id = u.user_id ORDER BY al.created_at DESC LIMIT ? OFFSET ?" ); $stmt->bind_param("ii", $limit, $offset); $stmt->execute(); $result = $stmt->get_result(); $logs = []; while ($row = $result->fetch_assoc()) { // Decode JSON details if ($row['details']) { $row['details'] = json_decode($row['details'], true); } $logs[] = $row; } $stmt->close(); return $logs; } /** * Get audit logs filtered by action type * * @param string $actionType Action type to filter by * @param int $limit Maximum number of logs to return * @return array Array of audit log records */ public function getLogsByAction($actionType, $limit = 100) { $limit = $this->validateLimit((int)$limit); // Validate action type to prevent unexpected queries if (!$this->isValidActionType($actionType)) { return []; } $stmt = $this->conn->prepare( "SELECT al.*, u.username, u.display_name FROM audit_log al LEFT JOIN users u ON al.user_id = u.user_id WHERE al.action_type = ? ORDER BY al.created_at DESC LIMIT ?" ); $stmt->bind_param("si", $actionType, $limit); $stmt->execute(); $result = $stmt->get_result(); $logs = []; while ($row = $result->fetch_assoc()) { // Decode JSON details if ($row['details']) { $row['details'] = json_decode($row['details'], true); } $logs[] = $row; } $stmt->close(); return $logs; } /** * Get total count of audit logs * * @return int Total count */ public function getTotalCount() { $result = $this->conn->query("SELECT COUNT(*) as count FROM audit_log"); $row = $result->fetch_assoc(); return (int)$row['count']; } /** * Delete old audit logs (for maintenance) * * @param int $daysToKeep Number of days of logs to keep * @return int Number of deleted records */ public function deleteOldLogs($daysToKeep = 90) { $stmt = $this->conn->prepare( "DELETE FROM audit_log WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)" ); $stmt->bind_param("i", $daysToKeep); $stmt->execute(); $affectedRows = $stmt->affected_rows; $stmt->close(); return $affectedRows; } /** * Get client IP address (handles proxies) * * @return string Client IP address */ private function getClientIP() { $ipAddress = ''; // Check for proxy headers if (!empty($_SERVER['HTTP_CF_CONNECTING_IP'])) { // Cloudflare $ipAddress = $_SERVER['HTTP_CF_CONNECTING_IP']; } elseif (!empty($_SERVER['HTTP_X_REAL_IP'])) { // Nginx proxy $ipAddress = $_SERVER['HTTP_X_REAL_IP']; } elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { // Standard proxy header $ipAddress = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])[0]; } elseif (!empty($_SERVER['REMOTE_ADDR'])) { // Direct connection $ipAddress = $_SERVER['REMOTE_ADDR']; } return trim($ipAddress); } /** * Helper: Log ticket creation * * @param int $userId User ID * @param string $ticketId Ticket ID * @param array $ticketData Ticket data * @return bool Success status */ public function logTicketCreate($userId, $ticketId, $ticketData) { return $this->log( $userId, 'create', 'ticket', $ticketId, ['title' => $ticketData['title'], 'priority' => $ticketData['priority'] ?? null] ); } /** * Helper: Log ticket update * * @param int $userId User ID * @param string $ticketId Ticket ID * @param array $changes Array of changed fields * @return bool Success status */ public function logTicketUpdate($userId, $ticketId, $changes) { return $this->log($userId, 'update', 'ticket', $ticketId, $changes); } /** * Helper: Log comment creation * * @param int $userId User ID * @param int $commentId Comment ID * @param string $ticketId Associated ticket ID * @return bool Success status */ public function logCommentCreate($userId, $commentId, $ticketId) { return $this->log( $userId, 'create', 'comment', (string)$commentId, ['ticket_id' => $ticketId] ); } /** * Helper: Log ticket view * * @param int $userId User ID * @param string $ticketId Ticket ID * @return bool Success status */ public function logTicketView($userId, $ticketId) { return $this->log($userId, 'view', 'ticket', $ticketId); } // ======================================== // Security Event Logging Methods // ======================================== /** * Log a security event * * @param string $eventType Type of security event * @param array $details Additional details * @param int|null $userId User ID if known * @return bool Success status */ public function logSecurityEvent($eventType, $details = [], $userId = null) { $details['event_type'] = $eventType; $details['user_agent'] = $_SERVER['HTTP_USER_AGENT'] ?? 'Unknown'; return $this->log($userId, 'security_event', 'security', null, $details); } /** * Log a failed authentication attempt * * @param string $username Username attempted * @param string $reason Reason for failure * @return bool Success status */ public function logFailedAuth($username, $reason = 'Invalid credentials') { return $this->logSecurityEvent('failed_auth', [ 'username' => $username, 'reason' => $reason ]); } /** * Log a CSRF token failure * * @param string $endpoint The endpoint that was accessed * @param int|null $userId User ID if session exists * @return bool Success status */ public function logCsrfFailure($endpoint, $userId = null) { return $this->logSecurityEvent('csrf_failure', [ 'endpoint' => $endpoint, 'method' => $_SERVER['REQUEST_METHOD'] ?? 'Unknown' ], $userId); } /** * Log a rate limit exceeded event * * @param string $endpoint The endpoint that was rate limited * @param int|null $userId User ID if session exists * @return bool Success status */ public function logRateLimitExceeded($endpoint, $userId = null) { return $this->logSecurityEvent('rate_limit_exceeded', [ 'endpoint' => $endpoint ], $userId); } /** * Log an unauthorized access attempt * * @param string $resource The resource that was accessed * @param int|null $userId User ID if session exists * @return bool Success status */ public function logUnauthorizedAccess($resource, $userId = null) { return $this->logSecurityEvent('unauthorized_access', [ 'resource' => $resource ], $userId); } /** * Get security events (for admin review) * * @param int $limit Maximum number of events * @param int $offset Offset for pagination * @return array Security events */ public function getSecurityEvents($limit = 100, $offset = 0) { $limit = $this->validateLimit((int)$limit); $offset = $this->validateOffset((int)$offset); $stmt = $this->conn->prepare( "SELECT al.*, u.username, u.display_name FROM audit_log al LEFT JOIN users u ON al.user_id = u.user_id WHERE al.action_type = 'security_event' ORDER BY al.created_at DESC LIMIT ? OFFSET ?" ); $stmt->bind_param("ii", $limit, $offset); $stmt->execute(); $result = $stmt->get_result(); $events = []; while ($row = $result->fetch_assoc()) { if ($row['details']) { $row['details'] = json_decode($row['details'], true); } $events[] = $row; } $stmt->close(); return $events; } /** * Get formatted timeline for a specific ticket * Includes all ticket updates and comments * * @param string $ticketId Ticket ID * @return array Timeline events */ public function getTicketTimeline($ticketId) { $stmt = $this->conn->prepare( "SELECT al.*, u.username, u.display_name FROM audit_log al LEFT JOIN users u ON al.user_id = u.user_id WHERE (al.entity_type = 'ticket' AND al.entity_id = ?) OR (al.entity_type = 'comment' AND JSON_EXTRACT(al.details, '$.ticket_id') = ?) ORDER BY al.created_at DESC" ); $stmt->bind_param("ss", $ticketId, $ticketId); $stmt->execute(); $result = $stmt->get_result(); $timeline = []; while ($row = $result->fetch_assoc()) { if ($row['details']) { $row['details'] = json_decode($row['details'], true); } $timeline[] = $row; } $stmt->close(); return $timeline; } /** * Get filtered audit logs with advanced search * * @param array $filters Associative array of filter criteria * @param int $limit Maximum number of logs to return * @param int $offset Offset for pagination * @return array Array containing logs and total count */ public function getFilteredLogs($filters = [], $limit = 50, $offset = 0) { // Validate pagination parameters $limit = $this->validateLimit((int)$limit); $offset = $this->validateOffset((int)$offset); $whereConditions = []; $params = []; $paramTypes = ''; // Action type filter - validate each action type if (!empty($filters['action_type'])) { $actions = array_filter( array_map('trim', explode(',', $filters['action_type'])), fn($action) => $this->isValidActionType($action) ); if (!empty($actions)) { $placeholders = str_repeat('?,', count($actions) - 1) . '?'; $whereConditions[] = "al.action_type IN ($placeholders)"; $params = array_merge($params, array_values($actions)); $paramTypes .= str_repeat('s', count($actions)); } } // Entity type filter - validate each entity type if (!empty($filters['entity_type'])) { $entities = array_filter( array_map('trim', explode(',', $filters['entity_type'])), fn($entity) => $this->isValidEntityType($entity) ); if (!empty($entities)) { $placeholders = str_repeat('?,', count($entities) - 1) . '?'; $whereConditions[] = "al.entity_type IN ($placeholders)"; $params = array_merge($params, array_values($entities)); $paramTypes .= str_repeat('s', count($entities)); } } // User filter - validate as positive integer if (!empty($filters['user_id'])) { $userId = (int)$filters['user_id']; if ($userId > 0) { $whereConditions[] = "al.user_id = ?"; $params[] = $userId; $paramTypes .= 'i'; } } // Entity ID filter - sanitize (alphanumeric and dashes only) if (!empty($filters['entity_id'])) { $entityId = preg_replace('/[^a-zA-Z0-9_-]/', '', $filters['entity_id']); if (!empty($entityId)) { $whereConditions[] = "al.entity_id = ?"; $params[] = $entityId; $paramTypes .= 's'; } } // Date range filters - validate format if (!empty($filters['date_from'])) { $dateFrom = $this->validateDate($filters['date_from']); if ($dateFrom !== null) { $whereConditions[] = "DATE(al.created_at) >= ?"; $params[] = $dateFrom; $paramTypes .= 's'; } } if (!empty($filters['date_to'])) { $dateTo = $this->validateDate($filters['date_to']); if ($dateTo !== null) { $whereConditions[] = "DATE(al.created_at) <= ?"; $params[] = $dateTo; $paramTypes .= 's'; } } // IP address filter - validate format (basic IP pattern) if (!empty($filters['ip_address'])) { // Allow partial IP matching but sanitize input $ipAddress = preg_replace('/[^0-9.:a-fA-F]/', '', $filters['ip_address']); if (!empty($ipAddress) && strlen($ipAddress) <= 45) { // Max IPv6 length $whereConditions[] = "al.ip_address LIKE ?"; $params[] = '%' . $ipAddress . '%'; $paramTypes .= 's'; } } // Build WHERE clause $whereClause = ''; if (!empty($whereConditions)) { $whereClause = 'WHERE ' . implode(' AND ', $whereConditions); } // Get total count for pagination $countSql = "SELECT COUNT(*) as total FROM audit_log al $whereClause"; $countStmt = $this->conn->prepare($countSql); if (!empty($params)) { $countStmt->bind_param($paramTypes, ...$params); } $countStmt->execute(); $totalResult = $countStmt->get_result(); $totalCount = $totalResult->fetch_assoc()['total']; $countStmt->close(); // Get filtered logs $sql = "SELECT al.*, u.username, u.display_name FROM audit_log al LEFT JOIN users u ON al.user_id = u.user_id $whereClause ORDER BY al.created_at DESC LIMIT ? OFFSET ?"; $stmt = $this->conn->prepare($sql); // Add limit and offset parameters $params[] = $limit; $params[] = $offset; $paramTypes .= 'ii'; if (!empty($params)) { $stmt->bind_param($paramTypes, ...$params); } $stmt->execute(); $result = $stmt->get_result(); $logs = []; while ($row = $result->fetch_assoc()) { if ($row['details']) { $row['details'] = json_decode($row['details'], true); } $logs[] = $row; } $stmt->close(); return [ 'logs' => $logs, 'total' => $totalCount, 'pages' => ceil($totalCount / $limit) ]; } }