Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
3 / 3
CRAP
100.00% covered (success)
100.00%
1 / 1
AuditRetentionService
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
3 / 3
7
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
 pruneExpiredLogs
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 pruneTable
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
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\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use PDO;
12
13/**
14 * Audit Log Retention and Pruning Service.
15 *
16 * Implements NIST SP 800-53 AU-4 (Audit Storage Capacity and Retention Management)
17 * by securely purging expired audit logs in non-locking batches.
18 *
19 * @package App\Core\Audit\Application\Service
20 */
21readonly class AuditRetentionService
22{
23    /**
24     * Date format for SQL timestamp comparisons.
25     */
26    private const string DATE_FORMAT = 'Y-m-d H:i:s';
27
28    /**
29     * AuditRetentionService constructor.
30     *
31     * @param PDO    $pdo    Database connection.
32     * @param string $prefix Table prefix.
33     */
34    public function __construct(
35        private PDO $pdo,
36        private string $prefix = 'a_',
37    ) {
38    }
39
40    /**
41     * Prunes expired audit, security, and auth logs according to retention policies.
42     *
43     * @param int $auditRetentionDays    Retention days for CRUD audit tables.
44     * @param int $securityRetentionDays Retention days for security alert logs.
45     * @param int $authRetentionDays     Retention days for user auth logs.
46     * @return array<string, int> Map of table name to number of pruned records.
47     */
48    public function pruneExpiredLogs(
49        int $auditRetentionDays = 180,
50        int $securityRetentionDays = 365,
51        int $authRetentionDays = 90
52    ): array {
53        $auditCutoff = date(self::DATE_FORMAT, strtotime("-{$auditRetentionDays} days"));
54        $securityCutoff = date(self::DATE_FORMAT, strtotime("-{$securityRetentionDays} days"));
55        $authCutoff = date(self::DATE_FORMAT, strtotime("-{$authRetentionDays} days"));
56
57        $pruned = [];
58
59        $auditTables = [
60            'logs_audit_create_records',
61            'logs_audit_read_records',
62            'logs_audit_update_records',
63            'logs_audit_delete_records',
64        ];
65
66        foreach ($auditTables as $table) {
67            $pruned[$table] = $this->pruneTable($table, $auditCutoff);
68        }
69
70        $pruned['logs_security_records'] = $this->pruneTable('logs_security_records', $securityCutoff);
71        $pruned['logs_user_auth_records'] = $this->pruneTable('logs_user_auth_records', $authCutoff);
72
73        return $pruned;
74    }
75
76    /**
77     * Prunes rows in a given table older than cutoff date.
78     *
79     * @param string $table      Unprefixed table name.
80     * @param string $cutoffDate ISO-8601 / MySQL datetime string.
81     * @return int Total deleted rows.
82     */
83    private function pruneTable(string $table, string $cutoffDate): int
84    {
85        $fullTable = $this->prefix . $table;
86        $totalDeleted = 0;
87        $batchSize = 1000;
88
89        try {
90            $isSqlite = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite';
91            $sql = $isSqlite
92                ? "DELETE FROM `{$fullTable}` WHERE `created_at` < :cutoff"
93                : "DELETE FROM `{$fullTable}` WHERE `created_at` < :cutoff LIMIT {$batchSize}";
94
95            do {
96                $stmt = $this->pdo->prepare($sql);
97                $stmt->execute([':cutoff' => $cutoffDate]);
98                $deleted = $stmt->rowCount();
99                $totalDeleted += $deleted;
100            } while (!$isSqlite && $deleted === $batchSize);
101        } catch (\Throwable) {
102            // Failsafe execution: do not interrupt runtime if table does not exist
103        }
104
105        return $totalDeleted;
106    }
107}