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>
548 lines
17 KiB
PHP
548 lines
17 KiB
PHP
<?php
|
|
/**
|
|
* AuditLogModel - Handles audit trail logging for all user actions
|
|
*/
|
|
class AuditLogModel {
|
|
private $conn;
|
|
|
|
public function __construct($conn) {
|
|
$this->conn = $conn;
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
$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) {
|
|
$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) {
|
|
$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) {
|
|
$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) {
|
|
$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) {
|
|
$whereConditions = [];
|
|
$params = [];
|
|
$paramTypes = '';
|
|
|
|
// Action type filter
|
|
if (!empty($filters['action_type'])) {
|
|
$actions = explode(',', $filters['action_type']);
|
|
$placeholders = str_repeat('?,', count($actions) - 1) . '?';
|
|
$whereConditions[] = "al.action_type IN ($placeholders)";
|
|
$params = array_merge($params, $actions);
|
|
$paramTypes .= str_repeat('s', count($actions));
|
|
}
|
|
|
|
// Entity type filter
|
|
if (!empty($filters['entity_type'])) {
|
|
$entities = explode(',', $filters['entity_type']);
|
|
$placeholders = str_repeat('?,', count($entities) - 1) . '?';
|
|
$whereConditions[] = "al.entity_type IN ($placeholders)";
|
|
$params = array_merge($params, $entities);
|
|
$paramTypes .= str_repeat('s', count($entities));
|
|
}
|
|
|
|
// User filter
|
|
if (!empty($filters['user_id'])) {
|
|
$whereConditions[] = "al.user_id = ?";
|
|
$params[] = (int)$filters['user_id'];
|
|
$paramTypes .= 'i';
|
|
}
|
|
|
|
// Entity ID filter (for specific ticket/comment)
|
|
if (!empty($filters['entity_id'])) {
|
|
$whereConditions[] = "al.entity_id = ?";
|
|
$params[] = $filters['entity_id'];
|
|
$paramTypes .= 's';
|
|
}
|
|
|
|
// Date range filters
|
|
if (!empty($filters['date_from'])) {
|
|
$whereConditions[] = "DATE(al.created_at) >= ?";
|
|
$params[] = $filters['date_from'];
|
|
$paramTypes .= 's';
|
|
}
|
|
if (!empty($filters['date_to'])) {
|
|
$whereConditions[] = "DATE(al.created_at) <= ?";
|
|
$params[] = $filters['date_to'];
|
|
$paramTypes .= 's';
|
|
}
|
|
|
|
// IP address filter
|
|
if (!empty($filters['ip_address'])) {
|
|
$whereConditions[] = "al.ip_address LIKE ?";
|
|
$params[] = '%' . $filters['ip_address'] . '%';
|
|
$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)
|
|
];
|
|
}
|
|
}
|