Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
100.00% |
81 / 81 |
|
100.00% |
6 / 6 |
CRAP | |
100.00% |
1 / 1 |
| SqlSecurityAuditRepository | |
100.00% |
80 / 80 |
|
100.00% |
6 / 6 |
20 | |
100.00% |
1 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| log | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
4 | |||
| find | |
100.00% |
29 / 29 |
|
100.00% |
1 / 1 |
5 | |||
| count | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| pruneOlderThan | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
| buildWhereClause | |
100.00% |
20 / 20 |
|
100.00% |
1 / 1 |
8 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | /** @license For full copyright and license information, please see the LICENSE.md file. */ |
| 6 | |
| 7 | namespace App\Core\Audit\Infrastructure\Repository; |
| 8 | |
| 9 | defined('AMMONLY_APP') || exit('Direct script access is forbidden.'); |
| 10 | |
| 11 | use App\Core\Audit\Domain\Model\SecurityEvent; |
| 12 | use App\Core\Audit\Domain\Model\SecurityEventSeverity; |
| 13 | use App\Core\Audit\Domain\Model\SecurityEventType; |
| 14 | use App\Core\Audit\Domain\Repository\SecurityAuditRepositoryInterface; |
| 15 | use PDO; |
| 16 | |
| 17 | /** |
| 18 | * SQL Security Audit Log Repository. |
| 19 | * |
| 20 | * Implements persistent storage and querying for security incidents and compliance audit logs. |
| 21 | * |
| 22 | * @package App\Core\Audit\Infrastructure\Repository |
| 23 | */ |
| 24 | final readonly class SqlSecurityAuditRepository implements SecurityAuditRepositoryInterface |
| 25 | { |
| 26 | /** |
| 27 | * SqlSecurityAuditRepository constructor. |
| 28 | * |
| 29 | * @param PDO $pdo Database connection. |
| 30 | * @param string $prefix Database table prefix. |
| 31 | */ |
| 32 | public function __construct( |
| 33 | private PDO $pdo, |
| 34 | private string $prefix = 'a_', |
| 35 | ) { |
| 36 | } |
| 37 | |
| 38 | /** {@inheritdoc} */ |
| 39 | public function log(SecurityEvent $event): int |
| 40 | { |
| 41 | try { |
| 42 | $table = $this->prefix . 'logs_security_records'; |
| 43 | $sql = "INSERT INTO `{$table}` |
| 44 | (`event_type`, `severity`, `message`, `context`, `actor_id`, |
| 45 | `ip_address`, `user_agent`, `request_uri`, `request_method`, `request_id`) |
| 46 | VALUES (:event_type, :severity, :message, :context, :actor_id, |
| 47 | :ip_address, :user_agent, :request_uri, :request_method, :request_id)"; |
| 48 | |
| 49 | $stmt = $this->pdo->prepare($sql); |
| 50 | $stmt->execute([ |
| 51 | ':event_type' => $event->eventType->value, |
| 52 | ':severity' => $event->severity->value, |
| 53 | ':message' => mb_substr($event->message, 0, 500), |
| 54 | ':context' => json_encode($event->context, JSON_UNESCAPED_UNICODE), |
| 55 | ':actor_id' => $event->actorId, |
| 56 | ':ip_address' => $event->ipAddress, |
| 57 | ':user_agent' => $event->userAgent !== null ? mb_substr($event->userAgent, 0, 512) : null, |
| 58 | ':request_uri' => $event->requestUri !== null ? mb_substr($event->requestUri, 0, 255) : null, |
| 59 | ':request_method' => $event->requestMethod, |
| 60 | ':request_id' => $event->requestId, |
| 61 | ]); |
| 62 | |
| 63 | return (int) $this->pdo->lastInsertId(); |
| 64 | } catch (\Throwable) { |
| 65 | return 0; |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | /** {@inheritdoc} */ |
| 70 | public function find(array $criteria = [], int $limit = 50, int $offset = 0): array |
| 71 | { |
| 72 | $table = $this->prefix . 'logs_security_records'; |
| 73 | [$whereSql, $params] = $this->buildWhereClause($criteria); |
| 74 | |
| 75 | $sql = "SELECT `id`, `event_type`, `severity`, `message`, `context`, `actor_id`," |
| 76 | . " `ip_address`, `user_agent`, `request_uri`, `request_method`, `request_id`, `created_at`" |
| 77 | . " FROM `{$table}` {$whereSql} ORDER BY `id` DESC LIMIT :limit OFFSET :offset"; |
| 78 | |
| 79 | $stmt = $this->pdo->prepare($sql); |
| 80 | foreach ($params as $k => $v) { |
| 81 | $stmt->bindValue($k, $v); |
| 82 | } |
| 83 | $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); |
| 84 | $stmt->bindValue(':offset', $offset, PDO::PARAM_INT); |
| 85 | $stmt->execute(); |
| 86 | |
| 87 | $events = []; |
| 88 | while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { |
| 89 | $context = is_string($row['context']) ? (json_decode($row['context'], true) ?? []) : []; |
| 90 | $events[] = new SecurityEvent( |
| 91 | eventType: SecurityEventType::from((string) $row['event_type']), |
| 92 | severity: SecurityEventSeverity::from((string) $row['severity']), |
| 93 | message: (string) $row['message'], |
| 94 | context: $context, |
| 95 | ipAddress: (string) $row['ip_address'], |
| 96 | actorId: $row['actor_id'] !== null ? (int) $row['actor_id'] : null, |
| 97 | userAgent: $row['user_agent'], |
| 98 | requestUri: $row['request_uri'], |
| 99 | requestMethod: $row['request_method'], |
| 100 | requestId: $row['request_id'], |
| 101 | id: (int) $row['id'], |
| 102 | createdAt: (string) $row['created_at'], |
| 103 | ); |
| 104 | } |
| 105 | |
| 106 | return $events; |
| 107 | } |
| 108 | |
| 109 | /** {@inheritdoc} */ |
| 110 | public function count(array $criteria = []): int |
| 111 | { |
| 112 | $table = $this->prefix . 'logs_security_records'; |
| 113 | [$whereSql, $params] = $this->buildWhereClause($criteria); |
| 114 | |
| 115 | $sql = "SELECT COUNT(*) FROM `{$table}` {$whereSql}"; |
| 116 | $stmt = $this->pdo->prepare($sql); |
| 117 | $stmt->execute($params); |
| 118 | |
| 119 | return (int) $stmt->fetchColumn(); |
| 120 | } |
| 121 | |
| 122 | /** {@inheritdoc} */ |
| 123 | public function pruneOlderThan(string $cutoffDate): int |
| 124 | { |
| 125 | $table = $this->prefix . 'logs_security_records'; |
| 126 | $sql = "DELETE FROM `{$table}` WHERE `created_at` < :cutoff"; |
| 127 | $stmt = $this->pdo->prepare($sql); |
| 128 | $stmt->execute([':cutoff' => $cutoffDate]); |
| 129 | |
| 130 | return $stmt->rowCount(); |
| 131 | } |
| 132 | |
| 133 | /** |
| 134 | * Builds WHERE clause and parameterized bindings for filter criteria. |
| 135 | * |
| 136 | * @param array<string, mixed> $criteria Filter map. |
| 137 | * @return array{0: string, 1: array<string, mixed>} SQL WHERE string and parameter map. |
| 138 | */ |
| 139 | private function buildWhereClause(array $criteria): array |
| 140 | { |
| 141 | $clauses = []; |
| 142 | $params = []; |
| 143 | |
| 144 | if (!empty($criteria['event_type'])) { |
| 145 | $clauses[] = '`event_type` = :event_type'; |
| 146 | $params[':event_type'] = $criteria['event_type'] instanceof SecurityEventType |
| 147 | ? $criteria['event_type']->value |
| 148 | : (string) $criteria['event_type']; |
| 149 | } |
| 150 | |
| 151 | if (!empty($criteria['severity'])) { |
| 152 | $clauses[] = '`severity` = :severity'; |
| 153 | $params[':severity'] = $criteria['severity'] instanceof SecurityEventSeverity |
| 154 | ? $criteria['severity']->value |
| 155 | : (string) $criteria['severity']; |
| 156 | } |
| 157 | |
| 158 | if (isset($criteria['actor_id'])) { |
| 159 | $clauses[] = '`actor_id` = :actor_id'; |
| 160 | $params[':actor_id'] = (int) $criteria['actor_id']; |
| 161 | } |
| 162 | |
| 163 | if (!empty($criteria['request_id'])) { |
| 164 | $clauses[] = '`request_id` = :request_id'; |
| 165 | $params[':request_id'] = (string) $criteria['request_id']; |
| 166 | } |
| 167 | |
| 168 | $whereSql = $clauses !== [] ? 'WHERE ' . implode(' AND ', $clauses) : ''; |
| 169 | |
| 170 | return [$whereSql, $params]; |
| 171 | } |
| 172 | } |