Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.96% covered (warning)
86.96%
160 / 184
46.67% covered (danger)
46.67%
7 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
AuditService
86.96% covered (warning)
86.96%
160 / 184
46.67% covered (danger)
46.67%
7 / 15
42.38
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.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
4.00
 logAccountStatusChange
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
5.04
 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 account status change.
335     *
336     * @param int $accountId
337     * @param string $oldStatus
338     * @param string $newStatus
339     * @param int $changedByUserId
340     * @param string $changedByUsername
341     * @param string $ipAddress
342     * @param ?string $reason
343     */
344    public function logAccountStatusChange(
345        int $accountId,
346        string $oldStatus,
347        string $newStatus,
348        int $changedByUserId,
349        string $changedByUsername,
350        string $ipAddress,
351        ?string $reason = null,
352    ): int {
353        $action = match ($newStatus) {
354            'frozen' => AuditAction::ACCOUNT_FROZEN,
355            'active' => AuditAction::ACCOUNT_UNFROZEN,
356            'closed' => AuditAction::ACCOUNT_CLOSED,
357            default => 'ACCOUNT_STATUS_CHANGED',
358        };
359
360        return $this->log(
361            action: $action,
362            tableName: 'accounts',
363            recordId: $accountId,
364            oldValues: ['status' => $oldStatus],
365            newValues: ['status' => $newStatus],
366            userId: $changedByUserId,
367            changedBy: $changedByUsername,
368            ipAddress: $ipAddress,
369            notes: $reason,
370        );
371    }
372
373    /**
374     * Log sensitive data access.
375     *
376     * @param string $dataType
377     * @param ?int $recordId
378     * @param int $userId
379     * @param string $username
380     * @param string $ipAddress
381     * @param ?string $reason
382     */
383    public function logSensitiveDataAccess(
384        string $dataType,
385        ?int $recordId,
386        int $userId,
387        string $username,
388        string $ipAddress,
389        ?string $reason = null,
390    ): int {
391        return $this->log(
392            action: AuditAction::SENSITIVE_DATA_ACCESSED,
393            tableName: $dataType,
394            recordId: $recordId,
395            userId: $userId,
396            changedBy: $username,
397            ipAddress: $ipAddress,
398            notes: $reason,
399        );
400    }
401
402    // ========================================================================
403    // QUERY METHODS
404    // ========================================================================
405
406    /**
407     * Get audit history for a specific record.
408     *
409     * @param string $tableName
410     * @param int $recordId
411     * @param int $limit
412     *
413     * @return list<array<mixed>>
414     */
415    public function getRecordHistory(string $tableName, int $recordId, int $limit = 50): array
416    {
417        $sql = <<<SQL
418                SELECT
419                audit_id, table_name, record_id, action, old_values, new_values,
420                user_id, changed_by, acting_as_user_id, ip_address, created_at, notes
421                FROM audit_log
422                WHERE table_name = :table_name
423                  AND record_id = :record_id
424                ORDER BY created_at DESC
425                LIMIT :limit
426            SQL;
427
428        $stmt = $this->pdo->prepare($sql);
429        if ($stmt === false) {
430            throw new RuntimeException('Failed to prepare record history statement');
431        }
432        $stmt->bindValue('table_name', $tableName);
433        $stmt->bindValue('record_id', $recordId);
434        $stmt->bindValue('limit', $limit, PDO::PARAM_INT);
435        $stmt->execute();
436
437        $rows = [];
438        foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
439            $rows[] = Row::from($row);
440        }
441
442        return $rows;
443    }
444
445    /**
446     * Get audit history for a specific user's actions.
447     *
448     * @param int $userId
449     * @param int $limit
450     *
451     * @return list<array<mixed>>
452     */
453    public function getUserActivity(int $userId, int $limit = 50): array
454    {
455        $sql = <<<SQL
456                SELECT
457                audit_id, table_name, record_id, action, user_id,
458                changed_by, acting_as_user_id, ip_address, created_at, notes
459                FROM audit_log
460                WHERE user_id = :user_id
461                ORDER BY created_at DESC
462                LIMIT :limit
463            SQL;
464
465        $stmt = $this->pdo->prepare($sql);
466        if ($stmt === false) {
467            throw new RuntimeException('Failed to prepare user activity statement');
468        }
469        $stmt->bindValue('user_id', $userId);
470        $stmt->bindValue('limit', $limit, PDO::PARAM_INT);
471        $stmt->execute();
472
473        $rows = [];
474        foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
475            $rows[] = Row::from($row);
476        }
477
478        return $rows;
479    }
480
481    /**
482     * Get recent audit entries (for admin dashboard).
483     *
484     * @param int $limit
485     * @param ?string $action
486     *
487     * @return list<array<mixed>>
488     */
489    public function getRecentActivity(int $limit = 100, ?string $action = null): array
490    {
491        $sql = <<<SQL
492                SELECT
493                audit_id, table_name, record_id, action, changed_by,
494                user_id, acting_as_user_id, ip_address, created_at, endpoint, notes
495                FROM audit_log
496            SQL;
497
498        $params = [];
499
500        if ($action !== null) {
501            $sql .= ' WHERE action = :action';
502            $params['action'] = $action;
503        }
504
505        $sql .= ' ORDER BY created_at DESC LIMIT :limit';
506
507        $stmt = $this->pdo->prepare($sql);
508        if ($stmt === false) {
509            throw new RuntimeException('Failed to prepare recent activity statement');
510        }
511
512        foreach ($params as $key => $value) {
513            $stmt->bindValue($key, $value);
514        }
515        $stmt->bindValue('limit', $limit, PDO::PARAM_INT);
516
517        $stmt->execute();
518
519        $rows = [];
520        foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
521            $rows[] = Row::from($row);
522        }
523
524        return $rows;
525    }
526}