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