Files
tinker_tickets/models/AuditLogModel.php

678 lines
21 KiB
PHP
Raw Permalink Normal View History

2026-01-01 15:40:32 -05:00
<?php
/**
* AuditLogModel - Handles audit trail logging for all user actions
*/
class AuditLogModel {
private $conn;
/** @var int Maximum allowed limit for pagination */
private const MAX_LIMIT = 1000;
/** @var int Default limit for pagination */
private const DEFAULT_LIMIT = 100;
/** @var array Allowed action types for filtering */
private const VALID_ACTION_TYPES = [
'create', 'update', 'delete', 'view', 'security_event',
'login', 'logout', 'assign', 'comment', 'bulk_update'
];
/** @var array Allowed entity types for filtering */
private const VALID_ENTITY_TYPES = [
'ticket', 'comment', 'user', 'api_key', 'security',
'template', 'attachment', 'group'
];
2026-01-01 15:40:32 -05:00
public function __construct($conn) {
$this->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);
}
2026-01-01 15:40:32 -05:00
/**
* 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);
2026-01-01 15:40:32 -05:00
$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);
2026-01-01 15:40:32 -05:00
$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);
2026-01-01 15:40:32 -05:00
$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 [];
}
2026-01-01 15:40:32 -05:00
$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);
}
Implement comprehensive improvement plan (Phases 1-6) Security (Phase 1-2): - Add SecurityHeadersMiddleware with CSP, X-Frame-Options, etc. - Add RateLimitMiddleware for API rate limiting - Add security event logging to AuditLogModel - Add ResponseHelper for standardized API responses - Update config.php with security constants Database (Phase 3): - Add migration 014 for additional indexes - Add migration 015 for ticket dependencies - Add migration 016 for ticket attachments - Add migration 017 for recurring tickets - Add migration 018 for custom fields Features (Phase 4-5): - Add ticket dependencies with DependencyModel and API - Add duplicate detection with check_duplicates API - Add file attachments with AttachmentModel and upload/download APIs - Add @mentions with autocomplete and highlighting - Add quick actions on dashboard rows Collaboration (Phase 5): - Add mention extraction in CommentModel - Add mention autocomplete dropdown in ticket.js - Add mention highlighting CSS styles Admin & Export (Phase 6): - Add StatsModel for dashboard widgets - Add dashboard stats cards (open, critical, unassigned, etc.) - Add CSV/JSON export via export_tickets API - Add rich text editor toolbar in markdown.js - Add RecurringTicketModel with cron job - Add CustomFieldModel for per-category fields - Add admin views: RecurringTickets, CustomFields, Workflow, Templates, AuditLog, UserActivity - Add admin APIs: manage_workflows, manage_templates, manage_recurring, custom_fields, get_users - Add admin routes in index.php Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 09:55:01 -05:00
// ========================================
// 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);
Implement comprehensive improvement plan (Phases 1-6) Security (Phase 1-2): - Add SecurityHeadersMiddleware with CSP, X-Frame-Options, etc. - Add RateLimitMiddleware for API rate limiting - Add security event logging to AuditLogModel - Add ResponseHelper for standardized API responses - Update config.php with security constants Database (Phase 3): - Add migration 014 for additional indexes - Add migration 015 for ticket dependencies - Add migration 016 for ticket attachments - Add migration 017 for recurring tickets - Add migration 018 for custom fields Features (Phase 4-5): - Add ticket dependencies with DependencyModel and API - Add duplicate detection with check_duplicates API - Add file attachments with AttachmentModel and upload/download APIs - Add @mentions with autocomplete and highlighting - Add quick actions on dashboard rows Collaboration (Phase 5): - Add mention extraction in CommentModel - Add mention autocomplete dropdown in ticket.js - Add mention highlighting CSS styles Admin & Export (Phase 6): - Add StatsModel for dashboard widgets - Add dashboard stats cards (open, critical, unassigned, etc.) - Add CSV/JSON export via export_tickets API - Add rich text editor toolbar in markdown.js - Add RecurringTicketModel with cron job - Add CustomFieldModel for per-category fields - Add admin views: RecurringTickets, CustomFields, Workflow, Templates, AuditLog, UserActivity - Add admin APIs: manage_workflows, manage_templates, manage_recurring, custom_fields, get_users - Add admin routes in index.php Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 09:55:01 -05:00
$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;
}
2026-01-09 11:20:27 -05:00
/**
* 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);
2026-01-09 11:20:27 -05:00
$whereConditions = [];
$params = [];
$paramTypes = '';
// Action type filter - validate each action type
2026-01-09 11:20:27 -05:00
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));
}
2026-01-09 11:20:27 -05:00
}
// Entity type filter - validate each entity type
2026-01-09 11:20:27 -05:00
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));
}
2026-01-09 11:20:27 -05:00
}
// User filter - validate as positive integer
2026-01-09 11:20:27 -05:00
if (!empty($filters['user_id'])) {
$userId = (int)$filters['user_id'];
if ($userId > 0) {
$whereConditions[] = "al.user_id = ?";
$params[] = $userId;
$paramTypes .= 'i';
}
2026-01-09 11:20:27 -05:00
}
// Entity ID filter - sanitize (alphanumeric and dashes only)
2026-01-09 11:20:27 -05:00
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';
}
2026-01-09 11:20:27 -05:00
}
// Date range filters - validate format
2026-01-09 11:20:27 -05:00
if (!empty($filters['date_from'])) {
$dateFrom = $this->validateDate($filters['date_from']);
if ($dateFrom !== null) {
$whereConditions[] = "DATE(al.created_at) >= ?";
$params[] = $dateFrom;
$paramTypes .= 's';
}
2026-01-09 11:20:27 -05:00
}
if (!empty($filters['date_to'])) {
$dateTo = $this->validateDate($filters['date_to']);
if ($dateTo !== null) {
$whereConditions[] = "DATE(al.created_at) <= ?";
$params[] = $dateTo;
$paramTypes .= 's';
}
2026-01-09 11:20:27 -05:00
}
// IP address filter - validate format (basic IP pattern)
2026-01-09 11:20:27 -05:00
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';
}
2026-01-09 11:20:27 -05:00
}
// 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)
];
}
2026-01-01 15:40:32 -05:00
}