Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
81 / 81
100.00% covered (success)
100.00%
6 / 6
CRAP
100.00% covered (success)
100.00%
1 / 1
SqlSecurityAuditRepository
100.00% covered (success)
100.00%
80 / 80
100.00% covered (success)
100.00%
6 / 6
20
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 log
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
4
 find
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
5
 count
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 pruneOlderThan
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 buildWhereClause
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
8
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Audit\Infrastructure\Repository;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Audit\Domain\Model\SecurityEvent;
12use App\Core\Audit\Domain\Model\SecurityEventSeverity;
13use App\Core\Audit\Domain\Model\SecurityEventType;
14use App\Core\Audit\Domain\Repository\SecurityAuditRepositoryInterface;
15use 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 */
24final 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}