Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.35% covered (warning)
88.35%
182 / 206
50.00% covered (danger)
50.00%
8 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
AuditService
88.35% covered (warning)
88.35%
182 / 206
50.00% covered (danger)
50.00%
8 / 16
44.79
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setContext
81.25% covered (warning)
81.25%
13 / 16
0.00% covered (danger)
0.00%
0 / 1
6.24
 getRequestId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 log
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
5
 logLogin
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 logLoginFailed
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 logLogout
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 logImpersonationStart
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
1
 logImpersonationEnd
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 logKycStatusChange
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
4.00
 logConsent
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
3
 logAccountStatusChange
87.50% covered (warning)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
5.05
 logSensitiveDataAccess
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 getRecordHistory
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
3.00
 getUserActivity
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
3.01
 getRecentActivity
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
5.00
1<?php
2
3declare(strict_types=1);
4
5namespace App\Domain\Audit;
6
7use App\Support\Row;
8use PDO;
9use PDOException;
10use RuntimeException;
11
12/**
13 * Service for logging audit events.
14 *
15 * Handles explicit audit logging for events not captured by database triggers,
16 * such as authentication events, admin actions, and business operations.
17 */
18final class AuditService
19{
20    private ?string $requestId = null;
21
22    public function __construct(
23        private readonly PDO $pdo,
24    ) {}
25
26    /**
27     * Set the audit context for the current request.
28     * Call this early in the request lifecycle (middleware).
29     *
30     * If the set_audit_context PostgreSQL function doesn't exist (triggers not installed),
31     * this will generate a request ID but skip setting session variables.
32     *
33     * @param ?int $userId
34     * @param ?string $changedBy
35     * @param ?string $ipAddress
36     * @param ?int $actingAsUserId
37     */
38    public function setContext(
39        ?int $userId = null,
40        ?string $changedBy = null,
41        ?string $ipAddress = null,
42        ?int $actingAsUserId = null,
43    ): void {
44        // Generate request ID if not set (use PostgreSQL for UUID generation)
45        if ($this->requestId === null) {
46            $stmt = $this->pdo->query('SELECT gen_random_uuid()::text');
47
48            if ($stmt === false) {
49                throw new RuntimeException('Failed to generate request ID');
50            }
51
52            $value = $stmt->fetchColumn();
53            $this->requestId = $value !== false ? (string)$value : null;
54        }
55        // Set PostgreSQL session variables for trigger-based auditing
56        // Wrapped in try-catch so service works even if triggers aren't installed
57        try {
58            $stmt = $this->pdo->prepare('SELECT set_audit_context(:user_id, :changed_by, :ip_address, :request_id)');
59            if ($stmt === false) {
60                return;
61            }
62            $stmt->execute([
63                'user_id' => $userId,
64                'changed_by' => $changedBy ?? 'anonymous',
65                'ip_address' => $ipAddress,
66                'request_id' => $this->requestId,
67            ]);
68        } catch (PDOException $e) {
69            // Function doesn't exist - triggers not installed, continue without context
70            // The log() method will still work for explicit audit entries
71        }
72    }
73
74    /**
75     * Get the current request ID.
76     */
77    public function getRequestId(): ?string
78    {
79        return $this->requestId;
80    }
81
82    /**
83     * @param array<string, mixed>|null $oldValues
84     * @param array<string, mixed>|null $newValues
85     * @param string $action
86     * @param ?string $tableName
87     * @param ?int $recordId
88     * @param ?int $userId
89     * @param ?string $changedBy
90     * @param ?int $actingAsUserId
91     * @param ?string $ipAddress
92     * @param ?string $userAgent
93     * @param ?string $endpoint
94     * @param ?string $notes
95     */
96    public function log(
97        string $action,
98        ?string $tableName = null,
99        ?int $recordId = null,
100        ?array $oldValues = null,
101        ?array $newValues = null,
102        ?int $userId = null,
103        ?string $changedBy = null,
104        ?int $actingAsUserId = null,
105        ?string $ipAddress = null,
106        ?string $userAgent = null,
107        ?string $endpoint = null,
108        ?string $notes = null,
109    ): int {
110        $sql = <<<SQL
111                INSERT INTO audit_log (
112                table_name, record_id, action, old_values, new_values,
113                user_id, changed_by, acting_as_user_id, ip_address,
114                user_agent, request_id, endpoint, notes
115                ) VALUES (
116                :table_name, :record_id, :action, :old_values, :new_values,
117                :user_id, :changed_by, :acting_as_user_id, :ip_address,
118                :user_agent, :request_id, :endpoint, :notes
119                )
120                RETURNING audit_id
121            SQL;
122
123        $stmt = $this->pdo->prepare($sql);
124        if ($stmt === false) {
125            throw new RuntimeException('Failed to prepare audit log statement');
126        }
127        $stmt->execute([
128            'table_name' => $tableName,
129            'record_id' => $recordId,
130            'action' => $action,
131            'old_values' => $oldValues !== null ? json_encode($oldValues) : null,
132            'new_values' => $newValues !== null ? json_encode($newValues) : null,
133            'user_id' => $userId,
134            'changed_by' => $changedBy ?? 'system',
135            'acting_as_user_id' => $actingAsUserId,
136            'ip_address' => $ipAddress,
137            'user_agent' => $userAgent !== null ? substr($userAgent, 0, 500) : null,
138            'request_id' => $this->requestId,
139            'endpoint' => $endpoint,
140            'notes' => $notes,
141        ]);
142
143        return (int)$stmt->fetchColumn();
144    }
145
146    // ========================================================================
147    // CONVENIENCE METHODS FOR COMMON EVENTS
148    // ========================================================================
149
150    /**
151     * Log successful login.
152     *
153     * @param int $userId
154     * @param string $username
155     * @param string $ipAddress
156     * @param ?string $userAgent
157     */
158    public function logLogin(
159        int $userId,
160        string $username,
161        string $ipAddress,
162        ?string $userAgent = null,
163    ): int {
164        return $this->log(
165            action: AuditAction::LOGIN_SUCCESS,
166            tableName: 'users',
167            recordId: $userId,
168            userId: $userId,
169            changedBy: $username,
170            ipAddress: $ipAddress,
171            userAgent: $userAgent,
172            endpoint: '/api/auth/login',
173        );
174    }
175
176    /**
177     * Log failed login attempt.
178     *
179     * @param string $username
180     * @param string $ipAddress
181     * @param string $reason
182     * @param ?string $userAgent
183     */
184    public function logLoginFailed(
185        string $username,
186        string $ipAddress,
187        string $reason,
188        ?string $userAgent = null,
189    ): int {
190        return $this->log(
191            action: AuditAction::LOGIN_FAILED,
192            tableName: 'users',
193            changedBy: $username,
194            ipAddress: $ipAddress,
195            userAgent: $userAgent,
196            endpoint: '/api/auth/login',
197            notes: $reason,
198        );
199    }
200
201    /**
202     * Log logout.
203     *
204     * @param int $userId
205     * @param string $username
206     * @param string $ipAddress
207     */
208    public function logLogout(
209        int $userId,
210        string $username,
211        string $ipAddress,
212    ): int {
213        return $this->log(
214            action: AuditAction::LOGOUT,
215            tableName: 'users',
216            recordId: $userId,
217            userId: $userId,
218            changedBy: $username,
219            ipAddress: $ipAddress,
220            endpoint: '/api/auth/logout',
221        );
222    }
223
224    /**
225     * Log impersonation start.
226     *
227     * @param int $adminUserId
228     * @param string $adminUsername
229     * @param int $targetUserId
230     * @param string $targetUsername
231     * @param string $ipAddress
232     * @param ?string $userAgent
233     */
234    public function logImpersonationStart(
235        int $adminUserId,
236        string $adminUsername,
237        int $targetUserId,
238        string $targetUsername,
239        string $ipAddress,
240        ?string $userAgent = null,
241    ): int {
242        return $this->log(
243            action: AuditAction::IMPERSONATE_START,
244            tableName: 'users',
245            recordId: $targetUserId,
246            newValues: [
247                'admin_user_id' => $adminUserId,
248                'admin_username' => $adminUsername,
249                'target_user_id' => $targetUserId,
250                'target_username' => $targetUsername,
251            ],
252            userId: $adminUserId,
253            changedBy: $adminUsername,
254            actingAsUserId: $targetUserId,
255            ipAddress: $ipAddress,
256            userAgent: $userAgent,
257            endpoint: "/api/admin/impersonate/{$targetUserId}",
258            notes: "Admin '{$adminUsername}' started impersonating '{$targetUsername}'",
259        );
260    }
261
262    /**
263     * Log impersonation end.
264     *
265     * @param int $adminUserId
266     * @param string $adminUsername
267     * @param int $targetUserId
268     * @param string $targetUsername
269     * @param string $ipAddress
270     */
271    public function logImpersonationEnd(
272        int $adminUserId,
273        string $adminUsername,
274        int $targetUserId,
275        string $targetUsername,
276        string $ipAddress,
277    ): int {
278        return $this->log(
279            action: AuditAction::IMPERSONATE_END,
280            tableName: 'users',
281            recordId: $targetUserId,
282            newValues: [
283                'admin_user_id' => $adminUserId,
284                'target_user_id' => $targetUserId,
285            ],
286            userId: $adminUserId,
287            changedBy: $adminUsername,
288            ipAddress: $ipAddress,
289            endpoint: '/api/admin/impersonate/end',
290            notes: "Admin '{$adminUsername}' stopped impersonating '{$targetUsername}'",
291        );
292    }
293
294    /**
295     * Log KYC status change.
296     *
297     * @param int $investorId
298     * @param string $oldStatus
299     * @param string $newStatus
300     * @param int $changedByUserId
301     * @param string $changedByUsername
302     * @param string $ipAddress
303     * @param ?string $reason
304     */
305    public function logKycStatusChange(
306        int $investorId,
307        string $oldStatus,
308        string $newStatus,
309        int $changedByUserId,
310        string $changedByUsername,
311        string $ipAddress,
312        ?string $reason = null,
313    ): int {
314        $action = match ($newStatus) {
315            'verified' => AuditAction::KYC_APPROVED,
316            'rejected' => AuditAction::KYC_REJECTED,
317            default => 'KYC_STATUS_CHANGED',
318        };
319
320        return $this->log(
321            action: $action,
322            tableName: 'investors',
323            recordId: $investorId,
324            oldValues: ['kyc_status' => $oldStatus],
325            newValues: ['kyc_status' => $newStatus],
326            userId: $changedByUserId,
327            changedBy: $changedByUsername,
328            ipAddress: $ipAddress,
329            notes: $reason,
330        );
331    }
332
333    /**
334     * Log a TCPA consent grant/revoke (FSC-87).
335     *
336     * Belt-and-suspenders alongside the append-only consent_records ledger:
337     * records who acted, from which IP / user-agent, the copy version shown,
338     * and the source (registration vs settings) in the central audit trail.
339     * Takes a plain bool so the Audit domain stays decoupled from Consent.
340     *
341     * @param int $userId
342     * @param int $consentRecordId consent_records.consent_id of the appended row
343     * @param string $consentType
344     * @param bool $granted
345     * @param string $version
346     * @param string $source
347     * @param string $changedByUsername
348     * @param ?string $ipAddress
349     * @param ?string $userAgent
350     */
351    public function logConsent(
352        int $userId,
353        int $consentRecordId,
354        string $consentType,
355        bool $granted,
356        string $version,
357        string $source,
358        string $changedByUsername,
359        ?string $ipAddress = null,
360        ?string $userAgent = null,
361    ): int {
362        $verb = $granted ? 'granted' : 'revoked';
363
364        return $this->log(
365            action: $granted ? AuditAction::TCPA_CONSENT_GRANTED : AuditAction::TCPA_CONSENT_REVOKED,
366            tableName: 'consent_records',
367            recordId: $consentRecordId,
368            newValues: [
369                'consent_type' => $consentType,
370                'action' => $verb,
371                'version' => $version,
372                'source' => $source,
373            ],
374            userId: $userId,
375            changedBy: $changedByUsername,
376            ipAddress: $ipAddress,
377            userAgent: $userAgent,
378            notes: sprintf(
379                "User %s %s '%s' consent (v%s) via %s",
380                $changedByUsername,
381                $verb,
382                $consentType,
383                $version,
384                $source,
385            ),
386        );
387    }
388
389    /**
390     * Log account status change.
391     *
392     * @param int $accountId
393     * @param string $oldStatus
394     * @param string $newStatus
395     * @param int $changedByUserId
396     * @param string $changedByUsername
397     * @param string $ipAddress
398     * @param ?string $reason
399     */
400    public function logAccountStatusChange(
401        int $accountId,
402        string $oldStatus,
403        string $newStatus,
404        int $changedByUserId,
405        string $changedByUsername,
406        string $ipAddress,
407        ?string $reason = null,
408    ): int {
409        $action = match ($newStatus) {
410            'frozen' => AuditAction::ACCOUNT_FROZEN,
411            'active' => AuditAction::ACCOUNT_UNFROZEN,
412            'closed' => AuditAction::ACCOUNT_CLOSED,
413            default => 'ACCOUNT_STATUS_CHANGED',
414        };
415
416        return $this->log(
417            action: $action,
418            tableName: 'accounts',
419            recordId: $accountId,
420            oldValues: ['status' => $oldStatus],
421            newValues: ['status' => $newStatus],
422            userId: $changedByUserId,
423            changedBy: $changedByUsername,
424            ipAddress: $ipAddress,
425            notes: $reason,
426        );
427    }
428
429    /**
430     * Log sensitive data access.
431     *
432     * @param string $dataType
433     * @param ?int $recordId
434     * @param int $userId
435     * @param string $username
436     * @param string $ipAddress
437     * @param ?string $reason
438     */
439    public function logSensitiveDataAccess(
440        string $dataType,
441        ?int $recordId,
442        int $userId,
443        string $username,
444        string $ipAddress,
445        ?string $reason = null,
446    ): int {
447        return $this->log(
448            action: AuditAction::SENSITIVE_DATA_ACCESSED,
449            tableName: $dataType,
450            recordId: $recordId,
451            userId: $userId,
452            changedBy: $username,
453            ipAddress: $ipAddress,
454            notes: $reason,
455        );
456    }
457
458    // ========================================================================
459    // QUERY METHODS
460    // ========================================================================
461
462    /**
463     * Get audit history for a specific record.
464     *
465     * @param string $tableName
466     * @param int $recordId
467     * @param int $limit
468     *
469     * @return list<array<mixed>>
470     */
471    public function getRecordHistory(string $tableName, int $recordId, int $limit = 50): array
472    {
473        $sql = <<<SQL
474                SELECT
475                audit_id, table_name, record_id, action, old_values, new_values,
476                user_id, changed_by, acting_as_user_id, ip_address, created_at, notes
477                FROM audit_log
478                WHERE table_name = :table_name
479                  AND record_id = :record_id
480                ORDER BY created_at DESC
481                LIMIT :limit
482            SQL;
483
484        $stmt = $this->pdo->prepare($sql);
485        if ($stmt === false) {
486            throw new RuntimeException('Failed to prepare record history statement');
487        }
488        $stmt->bindValue('table_name', $tableName);
489        $stmt->bindValue('record_id', $recordId);
490        $stmt->bindValue('limit', $limit, PDO::PARAM_INT);
491        $stmt->execute();
492
493        $rows = [];
494        foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
495            $rows[] = Row::from($row);
496        }
497
498        return $rows;
499    }
500
501    /**
502     * Get audit history for a specific user's actions.
503     *
504     * @param int $userId
505     * @param int $limit
506     *
507     * @return list<array<mixed>>
508     */
509    public function getUserActivity(int $userId, int $limit = 50): array
510    {
511        $sql = <<<SQL
512                SELECT
513                audit_id, table_name, record_id, action, user_id,
514                changed_by, acting_as_user_id, ip_address, created_at, notes
515                FROM audit_log
516                WHERE user_id = :user_id
517                ORDER BY created_at DESC
518                LIMIT :limit
519            SQL;
520
521        $stmt = $this->pdo->prepare($sql);
522        if ($stmt === false) {
523            throw new RuntimeException('Failed to prepare user activity statement');
524        }
525        $stmt->bindValue('user_id', $userId);
526        $stmt->bindValue('limit', $limit, PDO::PARAM_INT);
527        $stmt->execute();
528
529        $rows = [];
530        foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
531            $rows[] = Row::from($row);
532        }
533
534        return $rows;
535    }
536
537    /**
538     * Get recent audit entries (for admin dashboard).
539     *
540     * @param int $limit
541     * @param ?string $action
542     *
543     * @return list<array<mixed>>
544     */
545    public function getRecentActivity(int $limit = 100, ?string $action = null): array
546    {
547        $sql = <<<SQL
548                SELECT
549                audit_id, table_name, record_id, action, changed_by,
550                user_id, acting_as_user_id, ip_address, created_at, endpoint, notes
551                FROM audit_log
552            SQL;
553
554        $params = [];
555
556        if ($action !== null) {
557            $sql .= ' WHERE action = :action';
558            $params['action'] = $action;
559        }
560
561        $sql .= ' ORDER BY created_at DESC LIMIT :limit';
562
563        $stmt = $this->pdo->prepare($sql);
564        if ($stmt === false) {
565            throw new RuntimeException('Failed to prepare recent activity statement');
566        }
567
568        foreach ($params as $key => $value) {
569            $stmt->bindValue($key, $value);
570        }
571        $stmt->bindValue('limit', $limit, PDO::PARAM_INT);
572
573        $stmt->execute();
574
575        $rows = [];
576        foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
577            $rows[] = Row::from($row);
578        }
579
580        return $rows;
581    }
582}