Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.47% covered (success)
98.47%
258 / 262
75.00% covered (warning)
75.00%
12 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
SqlAuditRepository
98.47% covered (success)
98.47%
257 / 261
75.00% covered (warning)
75.00%
12 / 16
58
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 resolveTablePrefix
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 queryModuleTablePrefix
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 resetPrefixCache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 logCreate
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
1
 logRead
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 logUpdate
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
5
 buildUpdateAuditRow
100.00% covered (success)
100.00%
47 / 47
100.00% covered (success)
100.00%
1 / 1
8
 logDelete
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
1
 getTimeline
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 fetchCreateTimelineEvents
96.15% covered (success)
96.15%
25 / 26
0.00% covered (danger)
0.00%
0 / 1
5
 fetchUpdateTimelineEvents
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
2.00
 groupUpdateRows
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
12
 fetchDeleteTimelineEvents
96.15% covered (success)
96.15%
25 / 26
0.00% covered (danger)
0.00%
0 / 1
5
 execute
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 buildContextAuditParams
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
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\Engine\Infrastructure\Repository;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Audit\Application\Service\AuditDataSanitizer;
12use App\Core\Audit\Application\Service\AuditIntegrityService;
13use App\Core\Engine\Domain\Model\PermissionContext;
14use App\Core\Engine\Domain\Repository\AuditRepositoryInterface;
15use App\Core\Engine\Infrastructure\Settings\EngineSettings;
16use PDO;
17
18/**
19 * SQL Audit Repository.
20 *
21 * Concrete PDO implementation of AuditRepositoryInterface with NIST SP 800-53 AU-3/AU-9
22 * hash chaining and OWASP ASVS V7.1 sensitive data masking.
23 *
24 * @package App\Core\Engine\Infrastructure\Repository
25 */
26final class SqlAuditRepository implements AuditRepositoryInterface
27{
28    private readonly AuditDataSanitizer $sanitizer;
29    private readonly AuditIntegrityService $integrityService;
30
31    /** @var array<string> List of operational client modules using c_logs_audit_* tables. */
32    public const array CLIENT_MODULES = [
33        'contacts',
34        'companies',
35        'partners',
36        'opportunities',
37        'quotes',
38        'orders',
39        'projects',
40        'project_stages',
41        'project_tasks',
42        'tickets',
43        'contracts',
44        'products',
45        'services',
46        'sold_products',
47        'sold_services',
48        'emails',
49        'mail_messages',
50        'documents',
51        'calendar',
52        'work_time',
53        'comments',
54        'map',
55    ];
56
57    /**
58     * SqlAuditRepository constructor.
59     *
60     * @param PDO                        $pdo              Database connection.
61     * @param string                     $prefix           Database table prefix.
62     * @param AuditDataSanitizer|null    $sanitizer        Sanitizer for redacting secrets.
63     * @param AuditIntegrityService|null $integrityService Integrity service for cryptographic hash chaining.
64     */
65    public function __construct(
66        private readonly PDO            $pdo,
67        private readonly string         $prefix = 'a_',
68        ?AuditDataSanitizer             $sanitizer = null,
69        ?AuditIntegrityService          $integrityService = null,
70        private readonly ?EngineSettings $settings = null,
71    ) {
72        $this->sanitizer = $sanitizer ?? new AuditDataSanitizer();
73        $this->integrityService = $integrityService ?? new AuditIntegrityService($pdo, prefix: $prefix);
74    }
75
76    /** @var array<string, string> In-memory cache of resolved module table prefixes. */
77    private static array $modulePrefixCache = [];
78
79    /**
80     * Resolves the appropriate database table prefix ('a_' or 'c_') for target module.
81     *
82     * Dynamically detects whether the module is client or admin by checking its
83     * database table_name metadata with static memory caching.
84     *
85     * @param string $moduleName Machine name of target module.
86     * @return string Resolved table prefix ('a_' or 'c_').
87     */
88    public function resolveTablePrefix(string $moduleName): string
89    {
90        if ($this->prefix === 'c_') {
91            return 'c_';
92        }
93
94        $cleanName = strtolower(trim($moduleName));
95        if (isset(self::$modulePrefixCache[$cleanName])) {
96            return self::$modulePrefixCache[$cleanName];
97        }
98
99        $resolved = $this->queryModuleTablePrefix($cleanName);
100        self::$modulePrefixCache[$cleanName] = $resolved;
101        return $resolved;
102    }
103
104    /**
105     * Queries database metadata to detect module table prefix ('c_' or 'a_').
106     *
107     * @param string $cleanName Lowercase module machine name.
108     * @return string Resolved prefix.
109     */
110    private function queryModuleTablePrefix(string $cleanName): string
111    {
112        try {
113            $stmt = $this->pdo->prepare(
114                'SELECT `table_name` FROM `a_core_module_records` WHERE `name` = :name LIMIT 1'
115            );
116            $stmt->execute([':name' => $cleanName]);
117            $tableName = $stmt->fetchColumn();
118
119            if (is_string($tableName) && $tableName !== '') {
120                return str_starts_with($tableName, 'c_') ? 'c_' : 'a_';
121            }
122        } catch (\Throwable) {
123            // Fallback when metadata table does not exist
124        }
125
126        return in_array($cleanName, self::CLIENT_MODULES, true) ? 'c_' : 'a_';
127    }
128
129    /**
130     * Clears in-memory module prefix cache (useful for testing or cache invalidation).
131     */
132    public static function resetPrefixCache(): void
133    {
134        self::$modulePrefixCache = [];
135    }
136
137    /** {@inheritdoc} */
138    public function logCreate(string $moduleName, int $recordId, array $payload, PermissionContext $context): void
139    {
140        $pfx = $this->resolveTablePrefix($moduleName);
141        $cleanPayload = $this->sanitizer->sanitize($payload);
142        $jsonPayload = (string) json_encode($cleanPayload, JSON_UNESCAPED_UNICODE);
143        $prevHash = $this->integrityService->getLatestHash('logs_audit_create_records', $pfx);
144        $recordHash = $this->integrityService->computeRecordHash(
145            $prevHash,
146            $moduleName,
147            $recordId,
148            $jsonPayload,
149            $context->getAuditActorUserId(),
150            $context->actorIp,
151            $context->requestId
152        );
153
154        $sql = "INSERT INTO `{$pfx}logs_audit_create_records`
155                (`module_name`, `record_id`, `payload`, `actor_id`, `ip_address`,
156                 `request_id`, `prev_hash`, `record_hash`)
157                VALUES (:module_name, :record_id, :payload, :actor_id, :ip_address,
158                        :request_id, :prev_hash, :record_hash)";
159
160        $params = $this->buildContextAuditParams($moduleName, $recordId, $context, $prevHash, $recordHash);
161        $params[':payload'] = $jsonPayload;
162        $this->execute($sql, $params);
163    }
164
165    /** {@inheritdoc} */
166    public function logRead(string $moduleName, ?int $recordId, ?array $filters, PermissionContext $context): void
167    {
168        if ($this->settings !== null && !$this->settings->isAuditReadEnabled()) {
169            return;
170        }
171
172        $pfx = $this->resolveTablePrefix($moduleName);
173        $sql = "INSERT INTO `{$pfx}logs_audit_read_records`
174                (`module_name`, `record_id`, `filter_json`, `actor_id`, `ip_address`, `request_id`)
175                VALUES (:module_name, :record_id, :filter_json, :actor_id, :ip_address, :request_id)";
176
177        $this->execute($sql, [
178            ':module_name' => $moduleName,
179            ':record_id'   => $recordId,
180            ':filter_json' => $filters !== null ? (string) json_encode($filters, JSON_UNESCAPED_UNICODE) : null,
181            ':actor_id'    => $context->getAuditActorUserId(),
182            ':ip_address'  => $context->actorIp,
183            ':request_id'  => $context->requestId,
184        ]);
185    }
186
187    /** {@inheritdoc} */
188    public function logUpdate(string $moduleName, int $recordId, array $diff, PermissionContext $context): void
189    {
190        if ($diff === []) {
191            return;
192        }
193
194        $pfx = $this->resolveTablePrefix($moduleName);
195        $rowPlaceholders = [];
196        $params = [];
197        $i = 0;
198
199        foreach ($diff as $fieldKey => $change) {
200            $row = $this->buildUpdateAuditRow($moduleName, $recordId, $fieldKey, $change, $context, $i);
201            if ($row === null) {
202                continue;
203            }
204            $rowPlaceholders[] = $row[0];
205            $params = array_merge($params, $row[1]);
206            $i++;
207        }
208
209        if ($rowPlaceholders === []) {
210            return;
211        }
212
213        $sql = "INSERT INTO `{$pfx}logs_audit_update_records` " .
214            '(`module_name`, `record_id`, `field_key`, `old_value`, `new_value`, `actor_id`,'
215            . ' `ip_address`, `request_id`, `prev_hash`, `record_hash`) VALUES ' .
216            implode(', ', $rowPlaceholders);
217
218        $this->execute($sql, $params);
219    }
220
221    /**
222     * Builds parameter array and placeholder for a single updated field in audit log.
223     *
224     * @param array<string, mixed> $change
225     * @return array{0: string, 1: array<string, mixed>}|null
226     */
227    private function buildUpdateAuditRow(
228        string $moduleName,
229        int $recordId,
230        string|int $fieldKey,
231        array $change,
232        PermissionContext $context,
233        int $index
234    ): ?array {
235        $rawOld = $change['old'] ?? null;
236        $rawNew = $change['new'] ?? null;
237        $rawOldStr = $rawOld !== null ? (string) $rawOld : null;
238        $rawNewStr = $rawNew !== null ? (string) $rawNew : null;
239
240        if ($rawOldStr === $rawNewStr) {
241            return null;
242        }
243
244        $isSensitive = $this->sanitizer->isSensitiveKey((string) $fieldKey);
245        $oldVal = $isSensitive ? AuditDataSanitizer::REDACTED_PLACEHOLDER : $rawOld;
246        $newVal = $isSensitive ? AuditDataSanitizer::REDACTED_PLACEHOLDER : $rawNew;
247
248        $oldStr = $oldVal !== null ? (string) $oldVal : null;
249        $newStr = $newVal !== null ? (string) $newVal : null;
250        $payloadStr = $fieldKey . ':' . ($oldStr ?? '') . '->' . ($newStr ?? '');
251
252        $pfx = $this->resolveTablePrefix($moduleName);
253        $prevHash = $this->integrityService->getLatestHash('logs_audit_update_records', $pfx);
254        $recordHash = $this->integrityService->computeRecordHash(
255            $prevHash,
256            $moduleName,
257            $recordId,
258            $payloadStr,
259            $context->getAuditActorUserId(),
260            $context->actorIp,
261            $context->requestId
262        );
263
264        $mKey = ':m_' . $index;
265        $rKey = ':r_' . $index;
266        $fKey = ':f_' . $index;
267        $oKey = ':o_' . $index;
268        $nKey = ':n_' . $index;
269        $aKey = ':a_' . $index;
270        $ipKey = ':ip_' . $index;
271        $reqKey = ':rq_' . $index;
272        $pvKey = ':pv_' . $index;
273        $rhKey = ':rh_' . $index;
274
275        $placeholder = "({$mKey}{$rKey}{$fKey}{$oKey}{$nKey}"
276            . "{$aKey}{$ipKey}{$reqKey}{$pvKey}{$rhKey})";
277
278        return [$placeholder, [
279            $mKey => $moduleName,
280            $rKey => $recordId,
281            $fKey => $fieldKey,
282            $oKey => $oldStr,
283            $nKey => $newStr,
284            $aKey => $context->getAuditActorUserId(),
285            $ipKey => $context->actorIp,
286            $reqKey => $context->requestId,
287            $pvKey => $prevHash,
288            $rhKey => $recordHash,
289        ]];
290    }
291
292    /** {@inheritdoc} */
293    public function logDelete(string $moduleName, int $recordId, array $snapshot, PermissionContext $context): void
294    {
295        $pfx = $this->resolveTablePrefix($moduleName);
296        $cleanSnapshot = $this->sanitizer->sanitize($snapshot);
297        $jsonSnapshot = (string) json_encode($cleanSnapshot, JSON_UNESCAPED_UNICODE);
298        $prevHash = $this->integrityService->getLatestHash('logs_audit_delete_records', $pfx);
299        $recordHash = $this->integrityService->computeRecordHash(
300            $prevHash,
301            $moduleName,
302            $recordId,
303            $jsonSnapshot,
304            $context->getAuditActorUserId(),
305            $context->actorIp,
306            $context->requestId
307        );
308
309        $sql = "INSERT INTO `{$pfx}logs_audit_delete_records`
310                (`module_name`, `record_id`, `snapshot`, `actor_id`, `ip_address`,
311                 `request_id`, `prev_hash`, `record_hash`)
312                VALUES (:module_name, :record_id, :snapshot, :actor_id, :ip_address,
313                        :request_id, :prev_hash, :record_hash)";
314
315        $params = $this->buildContextAuditParams($moduleName, $recordId, $context, $prevHash, $recordHash);
316        $params[':snapshot'] = $jsonSnapshot;
317        $this->execute($sql, $params);
318    }
319
320    /** {@inheritdoc} */
321    public function getTimeline(string $moduleName, int $recordId): array
322    {
323        $createEvents = $this->fetchCreateTimelineEvents($moduleName, $recordId);
324        $updateEvents = $this->fetchUpdateTimelineEvents($moduleName, $recordId);
325        $deleteEvents = $this->fetchDeleteTimelineEvents($moduleName, $recordId);
326
327        $events = array_merge($createEvents, $updateEvents, $deleteEvents);
328
329        usort(
330            $events,
331            static fn(array $a, array $b): int => strcmp((string) $b['created_at'], (string) $a['created_at'])
332        );
333
334        return $events;
335    }
336
337    /**
338     * Fetches creation audit timeline events.
339     *
340     * @param string $moduleName Module machine name.
341     * @param int    $recordId   Record primary key.
342     * @return array<int, array<string, mixed>> List of creation events.
343     */
344    private function fetchCreateTimelineEvents(string $moduleName, int $recordId): array
345    {
346        $pfx = $this->resolveTablePrefix($moduleName);
347        $events = [];
348        $sql = 'SELECT c.`id`, c.`module_name`, c.`record_id`, c.`payload`, c.`actor_id`, c.`ip_address`,'
349            . ' c.`created_at`, u.`username` AS `actor_username`'
350            . " FROM `{$pfx}logs_audit_create_records` c"
351            . " LEFT JOIN `{$pfx}mod_users_records` u ON u.`id` = c.`actor_id`"
352            . ' WHERE c.`module_name` = :module_name AND c.`record_id` = :record_id'
353            . ' ORDER BY c.`id` DESC';
354
355        try {
356            $stmt = $this->pdo->prepare($sql);
357            $stmt->execute([':module_name' => $moduleName, ':record_id' => $recordId]);
358
359            while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
360                $payload = is_string($row['payload']) ? (json_decode($row['payload'], true) ?? []) : [];
361                $events[] = [
362                    'id'             => 'create_' . $row['id'],
363                    'action'         => 'create',
364                    'module_name'    => (string) $row['module_name'],
365                    'record_id'      => (int) $row['record_id'],
366                    'actor_id'       => $row['actor_id'] !== null ? (int) $row['actor_id'] : null,
367                    'actor_username' => $row['actor_username'] ?? 'System',
368                    'actor_ip'       => (string) $row['ip_address'],
369                    'created_at'     => (string) $row['created_at'],
370                    'payload'        => $payload,
371                    'changes'        => [],
372                ];
373            }
374        } catch (\Throwable) {
375            // Failsafe execution if audit table does not exist
376        }
377
378        return $events;
379    }
380
381    /**
382     * Fetches and groups field modification audit timeline events.
383     *
384     * @param string $moduleName Module machine name.
385     * @param int    $recordId   Record primary key.
386     * @return array<int, array<string, mixed>> List of grouped update events.
387     */
388    private function fetchUpdateTimelineEvents(string $moduleName, int $recordId): array
389    {
390        $pfx = $this->resolveTablePrefix($moduleName);
391        $events = [];
392        $sql = 'SELECT u.`id`, u.`module_name`, u.`record_id`, u.`field_key`, u.`old_value`, u.`new_value`,'
393            . ' u.`actor_id`, u.`ip_address`, u.`created_at`, usr.`username` AS `actor_username`'
394            . " FROM `{$pfx}logs_audit_update_records` u"
395            . " LEFT JOIN `{$pfx}mod_users_records` usr ON usr.`id` = u.`actor_id`"
396            . ' WHERE u.`module_name` = :module_name AND u.`record_id` = :record_id'
397            . ' AND (u.`old_value` IS NULL OR u.`new_value` IS NULL'
398            . ' OR u.`old_value` != u.`new_value` OR u.`old_value` = \'[REDACTED]\')'
399            . ' ORDER BY u.`id` DESC';
400
401        try {
402            $stmt = $this->pdo->prepare($sql);
403            $stmt->execute([':module_name' => $moduleName, ':record_id' => $recordId]);
404            $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
405            $events = $this->groupUpdateRows($rows);
406        } catch (\Throwable) {
407            // Failsafe execution if audit table does not exist
408        }
409
410        return $events;
411    }
412
413    /**
414     * Groups raw update audit rows into time-clustered composite events.
415     *
416     * @param list<array<string, mixed>> $rows Raw database records.
417     * @return array<int, array<string, mixed>> Grouped timeline events.
418     */
419    private function groupUpdateRows(array $rows): array
420    {
421        $events = [];
422        $currentGroup = null;
423        $lastTimestamp = null;
424        $lastActorId = null;
425
426        foreach ($rows as $row) {
427            $isSensitive = $this->sanitizer->isSensitiveKey((string) $row['field_key']);
428            if (!$isSensitive && (string) $row['old_value'] === (string) $row['new_value']) {
429                continue;
430            }
431
432            $actorId = $row['actor_id'] !== null ? (int) $row['actor_id'] : null;
433            $createdAt = (string) $row['created_at'];
434            $createdTs = (int) strtotime($createdAt);
435
436            $shouldMerge = $currentGroup !== null
437                && $actorId === $lastActorId
438                && abs($createdTs - (int) $lastTimestamp) <= 3;
439
440            if (!$shouldMerge) {
441                if ($currentGroup !== null && $currentGroup['changes'] !== []) {
442                    $events[] = $currentGroup;
443                }
444                $currentGroup = [
445                    'id'             => 'update_' . $row['id'],
446                    'action'         => 'update',
447                    'module_name'    => (string) $row['module_name'],
448                    'record_id'      => (int) $row['record_id'],
449                    'actor_id'       => $actorId,
450                    'actor_username' => $row['actor_username'] ?? 'System',
451                    'actor_ip'       => (string) $row['ip_address'],
452                    'created_at'     => $createdAt,
453                    'changes'        => [],
454                ];
455                $lastActorId = $actorId;
456                $lastTimestamp = $createdTs;
457            }
458
459            $currentGroup['changes'][] = [
460                'field_key' => (string) $row['field_key'],
461                'old_value' => $row['old_value'],
462                'new_value' => $row['new_value'],
463            ];
464        }
465
466        if ($currentGroup !== null && $currentGroup['changes'] !== []) {
467            $events[] = $currentGroup;
468        }
469
470        return $events;
471    }
472
473    /**
474     * Fetches deletion audit timeline events.
475     *
476     * @param string $moduleName Module machine name.
477     * @param int    $recordId   Record primary key.
478     * @return array<int, array<string, mixed>> List of deletion events.
479     */
480    private function fetchDeleteTimelineEvents(string $moduleName, int $recordId): array
481    {
482        $pfx = $this->resolveTablePrefix($moduleName);
483        $events = [];
484        $sql = 'SELECT d.`id`, d.`module_name`, d.`record_id`, d.`snapshot`, d.`actor_id`, d.`ip_address`,'
485            . ' d.`created_at`, u.`username` AS `actor_username`'
486            . " FROM `{$pfx}logs_audit_delete_records` d"
487            . " LEFT JOIN `{$pfx}mod_users_records` u ON u.`id` = d.`actor_id`"
488            . ' WHERE d.`module_name` = :module_name AND d.`record_id` = :record_id'
489            . ' ORDER BY d.`id` DESC';
490
491        try {
492            $stmt = $this->pdo->prepare($sql);
493            $stmt->execute([':module_name' => $moduleName, ':record_id' => $recordId]);
494
495            while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
496                $snapshot = is_string($row['snapshot']) ? (json_decode($row['snapshot'], true) ?? []) : [];
497                $events[] = [
498                    'id'             => 'delete_' . $row['id'],
499                    'action'         => 'delete',
500                    'module_name'    => (string) $row['module_name'],
501                    'record_id'      => (int) $row['record_id'],
502                    'actor_id'       => $row['actor_id'] !== null ? (int) $row['actor_id'] : null,
503                    'actor_username' => $row['actor_username'] ?? 'System',
504                    'actor_ip'       => (string) $row['ip_address'],
505                    'created_at'     => (string) $row['created_at'],
506                    'snapshot'       => $snapshot,
507                    'changes'        => [],
508                ];
509            }
510        } catch (\Throwable) {
511            // Failsafe execution if audit table does not exist
512        }
513
514        return $events;
515    }
516
517    /**
518     * Executes a parameterized SQL audit statement, silently catching failures.
519     *
520     * @param string               $sql    The SQL query to execute.
521     * @param array<string, mixed> $params The bound parameter values.
522     */
523    private function execute(string $sql, array $params): void
524    {
525        try {
526            $stmt = $this->pdo->prepare($sql);
527            $stmt->execute($params);
528        } catch (\PDOException) {
529            // Audit failures MUST NOT interrupt the main operation flow.
530        }
531    }
532
533    /**
534     * Builds standard context audit parameters for query binding.
535     *
536     * @param string            $moduleName Target module name.
537     * @param int|null          $recordId   Target record ID.
538     * @param PermissionContext $context    Actor and request context.
539     * @param string|null       $prevHash   Previous cryptographic hash.
540     * @param string|null       $recordHash Current cryptographic hash.
541     * @return array<string, mixed> Bind parameters.
542     */
543    private function buildContextAuditParams(
544        string $moduleName,
545        ?int $recordId,
546        PermissionContext $context,
547        ?string $prevHash = null,
548        ?string $recordHash = null
549    ): array {
550        return [
551            ':module_name' => $moduleName,
552            ':record_id'   => $recordId,
553            ':actor_id'    => $context->getAuditActorUserId(),
554            ':ip_address'  => $context->actorIp,
555            ':request_id'  => $context->requestId,
556            ':prev_hash'   => $prevHash,
557            ':record_hash' => $recordHash,
558        ];
559    }
560}