From 61073f4fcdbdc6cbc9f3e5872b39597a0bf04390 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Munsch?= Date: Fri, 21 Aug 2026 12:07:13 +0200 Subject: [PATCH] feat(security): add database audit logging and asynchronous markdown email alerts for api client actions --- .env.example | 1 + .env.testing | 2 +- config/mail.php | 11 ++ ..._120000_create_client_audit_logs_table.php | 33 ++++++ src/Infra/Database/Models/ClientAuditLog.php | 50 ++++++++ .../Listeners/RecordClientSecurityAudit.php | 45 ++++++++ .../Listeners/SendClientSecurityAlertMail.php | 84 ++++++++++++++ src/Infra/Mail/ClientSecurityAlertMail.php | 53 +++++++++ src/Infra/Mail/SecurityAlertDetails.php | 27 +++++ src/Infra/Providers/EventServiceProvider.php | 32 ++++++ .../views/mails/security-alert.blade.php | 21 ++++ .../RecordClientSecurityAuditTest.php | 108 ++++++++++++++++++ .../SendClientSecurityAlertMailTest.php | 103 +++++++++++++++++ .../Mail/ClientSecurityAlertMailTest.php | 57 +++++++++ .../Infra/Mail/SecurityAlertDetailsTest.php | 38 ++++++ 15 files changed, 664 insertions(+), 1 deletion(-) create mode 100644 database/migrations/2026_08_21_120000_create_client_audit_logs_table.php create mode 100644 src/Infra/Database/Models/ClientAuditLog.php create mode 100644 src/Infra/Listeners/RecordClientSecurityAudit.php create mode 100644 src/Infra/Listeners/SendClientSecurityAlertMail.php create mode 100644 src/Infra/Mail/ClientSecurityAlertMail.php create mode 100644 src/Infra/Mail/SecurityAlertDetails.php create mode 100644 src/Presentation/Web/resources/views/mails/security-alert.blade.php create mode 100644 tests/Integration/Infra/Listeners/RecordClientSecurityAuditTest.php create mode 100644 tests/Unit/Infra/Listeners/SendClientSecurityAlertMailTest.php create mode 100644 tests/Unit/Infra/Mail/ClientSecurityAlertMailTest.php create mode 100644 tests/Unit/Infra/Mail/SecurityAlertDetailsTest.php diff --git a/.env.example b/.env.example index 76f1d2a..dc35773 100644 --- a/.env.example +++ b/.env.example @@ -73,6 +73,7 @@ MAIL_USERNAME=null MAIL_PASSWORD=null MAIL_FROM_ADDRESS="hello@example.com" MAIL_FROM_NAME="${APP_NAME}" +SECURITY_ALERT_EMAIL="security@example.com" AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= diff --git a/.env.testing b/.env.testing index 807a73a..d39ebbb 100644 --- a/.env.testing +++ b/.env.testing @@ -29,7 +29,7 @@ BROADCAST_CONNECTION=null FILESYSTEM_DISK=local QUEUE_CONNECTION=sync CACHE_STORE=array -MAIL_MAILER= +MAIL_MAILER=array ARCADIA_CACHE_STORE=array diff --git a/config/mail.php b/config/mail.php index 36da1fb..6b51b7f 100644 --- a/config/mail.php +++ b/config/mail.php @@ -117,4 +117,15 @@ return [ 'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')), ], + /* + |-------------------------------------------------------------------------- + | Security Alerts Recipient + |-------------------------------------------------------------------------- + | + | The email address that receives administrative and critical security alerts. + | + */ + + 'security_recipient' => env('SECURITY_ALERT_EMAIL', 'security@example.com'), + ]; diff --git a/database/migrations/2026_08_21_120000_create_client_audit_logs_table.php b/database/migrations/2026_08_21_120000_create_client_audit_logs_table.php new file mode 100644 index 0000000..559f46b --- /dev/null +++ b/database/migrations/2026_08_21_120000_create_client_audit_logs_table.php @@ -0,0 +1,33 @@ +uuid('id')->primary(); + $table->uuid('client_id')->index(); + $table->string('client_identifier'); + $table->string('event_type'); + $table->json('details')->nullable(); + $table->timestamp('created_at')->useCurrent(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('client_audit_logs'); + } +}; diff --git a/src/Infra/Database/Models/ClientAuditLog.php b/src/Infra/Database/Models/ClientAuditLog.php new file mode 100644 index 0000000..5cf5a67 --- /dev/null +++ b/src/Infra/Database/Models/ClientAuditLog.php @@ -0,0 +1,50 @@ +|null $details + * @property \DateTimeInterface $created_at + * + * @mixin \Eloquent + */ +final class ClientAuditLog extends Model +{ + use HasUuids; + + protected $table = 'client_audit_logs'; + + public $timestamps = false; + + /** @var list */ + protected $fillable = [ + 'id', + 'client_id', + 'client_identifier', + 'event_type', + 'details', + 'created_at', + ]; + + /** @return array */ + #[\Override] + protected function casts(): array + { + return [ + 'id' => UuidCast::class, + 'client_id' => UuidCast::class, + 'details' => 'array', + 'created_at' => 'datetime', + ]; + } +} diff --git a/src/Infra/Listeners/RecordClientSecurityAudit.php b/src/Infra/Listeners/RecordClientSecurityAudit.php new file mode 100644 index 0000000..ed5708c --- /dev/null +++ b/src/Infra/Listeners/RecordClientSecurityAudit.php @@ -0,0 +1,45 @@ + 'client_secret_generated', + $event instanceof ClientSecretRegenerated => 'client_secret_regenerated', + $event instanceof ClientStatusToggled => 'client_status_toggled', + }; + + $details = match (true) { + $event instanceof ClientStatusToggled => [ + 'is_active' => $event->isActive, + ], + default => null, + }; + + $occurredAt = match (true) { + $event instanceof ClientSecretGenerated => $event->generatedAt, + $event instanceof ClientSecretRegenerated => $event->generatedAt, + $event instanceof ClientStatusToggled => $event->toggledAt, + }; + + ClientAuditLog::query()->create([ + 'id' => Uuid::uuid7()->toString(), + 'client_id' => $event->clientId->value, + 'client_identifier' => $event->clientIdentifier->value, + 'event_type' => $eventType, + 'details' => $details, + 'created_at' => $occurredAt, + ]); + } +} diff --git a/src/Infra/Listeners/SendClientSecurityAlertMail.php b/src/Infra/Listeners/SendClientSecurityAlertMail.php new file mode 100644 index 0000000..50f838f --- /dev/null +++ b/src/Infra/Listeners/SendClientSecurityAlertMail.php @@ -0,0 +1,84 @@ +getEventRecipient(); + $eventType = $this->getEventType($event); + $details = $this->getEventDetails($event); + $occurredAt = $this->getEventOccurredAt($event); + + $mailable = new ClientSecurityAlertMail( + eventType: $eventType, + clientId: $event->clientId->identifier, + clientIdentifier: $event->clientIdentifier->value, + occurredAt: $occurredAt, + details: $details, + ); + + $this->mailer->to($recipient) + ->send($mailable); + } + + private function getEventRecipient(): Address + { + $recipientValue = $this->config->get('mail.security_recipient', 'security@example.com'); + + return new Address(is_string($recipientValue) ? $recipientValue : 'security@example.com'); + } + + private function getEventType(ClientSecretGenerated | ClientSecretRegenerated | ClientStatusToggled $event): string + { + return match (true) { + $event instanceof ClientSecretGenerated => 'Client Secret Generated', + $event instanceof ClientSecretRegenerated => 'Client Secret Regenerated', + $event instanceof ClientStatusToggled => sprintf( + 'Client Status Changed (%s)', + $event->isActive ? 'Activated' : 'Deactivated' + ), + }; + } + + private function getEventDetails(ClientSecretGenerated | ClientSecretRegenerated | ClientStatusToggled $event): SecurityAlertDetails + { + return match (true) { + $event instanceof ClientStatusToggled => SecurityAlertDetails::forStatusToggled($event->isActive), + default => SecurityAlertDetails::empty(), + }; + } + + private function getEventOccurredAt(ClientSecretGenerated | ClientSecretRegenerated | ClientStatusToggled $event): \DateTimeInterface + { + return match (true) { + $event instanceof ClientSecretGenerated => $event->generatedAt, + $event instanceof ClientSecretRegenerated => $event->generatedAt, + $event instanceof ClientStatusToggled => $event->toggledAt, + }; + } +} diff --git a/src/Infra/Mail/ClientSecurityAlertMail.php b/src/Infra/Mail/ClientSecurityAlertMail.php new file mode 100644 index 0000000..1ae73b1 --- /dev/null +++ b/src/Infra/Mail/ClientSecurityAlertMail.php @@ -0,0 +1,53 @@ +eventType, + $this->clientIdentifier + ), + ); + } + + public function content(): Content + { + return new Content( + markdown: 'mails.security-alert', + with: [ + 'eventType' => $this->eventType, + 'clientId' => $this->clientId, + 'clientIdentifier' => $this->clientIdentifier, + 'occurredAt' => $this->occurredAt, + 'details' => $this->details, + ], + ); + } +} diff --git a/src/Infra/Mail/SecurityAlertDetails.php b/src/Infra/Mail/SecurityAlertDetails.php new file mode 100644 index 0000000..6a58c57 --- /dev/null +++ b/src/Infra/Mail/SecurityAlertDetails.php @@ -0,0 +1,27 @@ + + # ⚠️ Alerte de Sécurité - Arcadia API + + Une action d'écriture sensible a été effectuée sur un client API. + + + **Événement :** {{ $eventType }} + **Identifiant Client :** `{{ $clientIdentifier }}` + **Client ID :** `{{ $clientId->toString() }}` + **Date & Heure :** {{ $occurredAt->format('d/m/Y H:i:s') }} + @if ($details->isActive !== null) + **Statut du Client :** {{ $details->isActive ? '🟢 Actif' : '🔴 Inactif' }} + @endif + @if ($details->description !== null) + **Détail :** +
{{ $details->description }}
+ @endif +
+ + Ce message est généré automatiquement par le système Arcadia API. + diff --git a/tests/Integration/Infra/Listeners/RecordClientSecurityAuditTest.php b/tests/Integration/Infra/Listeners/RecordClientSecurityAuditTest.php new file mode 100644 index 0000000..e5c09cb --- /dev/null +++ b/tests/Integration/Infra/Listeners/RecordClientSecurityAuditTest.php @@ -0,0 +1,108 @@ +delete(); + }); + + it('records audit log when ClientSecretGenerated event is handled', function (): void { + // GIVEN + $clientId = new ClientId(Uuid::uuid7()); + $clientIdentifier = new ClientIdentifier('arcadia_id_11111111111111111111111111111111'); + $generatedAt = new \DateTimeImmutable('2026-08-21 10:00:00'); + + $event = new ClientSecretGenerated( + clientId: $clientId, + clientIdentifier: $clientIdentifier, + generatedAt: $generatedAt, + ); + + $listener = new RecordClientSecurityAudit(); + + // WHEN + $listener->handle($event); + + // THEN + $log = ClientAuditLog::query()->where('client_id', $clientId->value)->first(); + expect($log)->not->toBeNull() + ->and($log->id)->toBeInstanceOf(UuidInterface::class) + ->and($log->client_id)->toBeInstanceOf(UuidInterface::class) + ->and($log->client_id->toString())->toBe($clientId->value) + ->and($log->client_identifier)->toBe($clientIdentifier->value) + ->and($log->event_type)->toBe('client_secret_generated') + ->and($log->details)->toBeNull() + ->and($log->created_at->format('Y-m-d H:i:s'))->toBe('2026-08-21 10:00:00'); + }); + + it('records audit log when ClientSecretRegenerated event is handled', function (): void { + // GIVEN + $clientId = new ClientId(Uuid::uuid7()); + $clientIdentifier = new ClientIdentifier('arcadia_id_22222222222222222222222222222222'); + $regeneratedAt = new \DateTimeImmutable('2026-08-21 11:30:00'); + + $event = new ClientSecretRegenerated( + clientId: $clientId, + clientIdentifier: $clientIdentifier, + generatedAt: $regeneratedAt, + ); + + $listener = new RecordClientSecurityAudit(); + + // WHEN + $listener->handle($event); + + // THEN + $log = ClientAuditLog::query()->where('client_id', $clientId->value)->first(); + expect($log)->not->toBeNull() + ->and($log->client_identifier)->toBe($clientIdentifier->value) + ->and($log->event_type)->toBe('client_secret_regenerated') + ->and($log->details)->toBeNull() + ->and($log->created_at->format('Y-m-d H:i:s'))->toBe('2026-08-21 11:30:00'); + }); + + it('records audit log when ClientStatusToggled event is handled', function (): void { + // GIVEN + $clientId = new ClientId(Uuid::uuid7()); + $clientIdentifier = new ClientIdentifier('arcadia_id_33333333333333333333333333333333'); + $toggledAt = new \DateTimeImmutable('2026-08-21 12:00:00'); + + $event = new ClientStatusToggled( + clientId: $clientId, + clientIdentifier: $clientIdentifier, + isActive: false, + toggledAt: $toggledAt, + ); + + $listener = new RecordClientSecurityAudit(); + + // WHEN + $listener->handle($event); + + // THEN + $log = ClientAuditLog::query()->where('client_id', $clientId->value)->first(); + expect($log)->not->toBeNull() + ->and($log->client_identifier)->toBe($clientIdentifier->value) + ->and($log->event_type)->toBe('client_status_toggled') + ->and($log->details)->toBe(['is_active' => false]) + ->and($log->created_at->format('Y-m-d H:i:s'))->toBe('2026-08-21 12:00:00'); + }); +}); diff --git a/tests/Unit/Infra/Listeners/SendClientSecurityAlertMailTest.php b/tests/Unit/Infra/Listeners/SendClientSecurityAlertMailTest.php new file mode 100644 index 0000000..3711074 --- /dev/null +++ b/tests/Unit/Infra/Listeners/SendClientSecurityAlertMailTest.php @@ -0,0 +1,103 @@ +handle($event); + + // THEN + Mail::assertQueued(ClientSecurityAlertMail::class, function (ClientSecurityAlertMail $mail) use ($clientIdentifier): bool { + return $mail->eventType === 'Client Secret Generated' + && $mail->clientIdentifier === $clientIdentifier->value; + }); + }); + + it('sends email alert when ClientSecretRegenerated is handled', function (): void { + // GIVEN + $clientId = new ClientId(Uuid::uuid7()); + $clientIdentifier = new ClientIdentifier('arcadia_id_22222222222222222222222222222222'); + $regeneratedAt = new \DateTimeImmutable('2026-08-21 11:00:00'); + + $event = new ClientSecretRegenerated( + clientId: $clientId, + clientIdentifier: $clientIdentifier, + generatedAt: $regeneratedAt, + ); + + $configMock = resolve(ConfigRepository::class); + $listener = new SendClientSecurityAlertMail(Mail::mailer(), $configMock); + + // WHEN + $listener->handle($event); + + // THEN + Mail::assertQueued(ClientSecurityAlertMail::class, function (ClientSecurityAlertMail $mail) use ($clientIdentifier): bool { + return $mail->eventType === 'Client Secret Regenerated' + && $mail->clientIdentifier === $clientIdentifier->value; + }); + }); + + it('sends email alert when ClientStatusToggled is handled', function (): void { + // GIVEN + $clientId = new ClientId(Uuid::uuid7()); + $clientIdentifier = new ClientIdentifier('arcadia_id_33333333333333333333333333333333'); + $toggledAt = new \DateTimeImmutable('2026-08-21 12:00:00'); + + $event = new ClientStatusToggled( + clientId: $clientId, + clientIdentifier: $clientIdentifier, + isActive: false, + toggledAt: $toggledAt, + ); + + $configMock = resolve(ConfigRepository::class); + $listener = new SendClientSecurityAlertMail(Mail::mailer(), $configMock); + + // WHEN + $listener->handle($event); + + // THEN + Mail::assertQueued(ClientSecurityAlertMail::class, function (ClientSecurityAlertMail $mail) use ($clientIdentifier): bool { + return $mail->eventType === 'Client Status Changed (Deactivated)' + && $mail->clientIdentifier === $clientIdentifier->value + && $mail->details->isActive === false; + }); + }); +}); diff --git a/tests/Unit/Infra/Mail/ClientSecurityAlertMailTest.php b/tests/Unit/Infra/Mail/ClientSecurityAlertMailTest.php new file mode 100644 index 0000000..9ba84a5 --- /dev/null +++ b/tests/Unit/Infra/Mail/ClientSecurityAlertMailTest.php @@ -0,0 +1,57 @@ +envelope(); + + // THEN + expect($envelope->subject)->toBe('[Arcadia Security] Client Secret Generated for arcadia_id_testclient'); + }); + + it('builds email content with correct markdown view and typed details', function (): void { + // GIVEN + $clientId = Uuid::fromString('01917b2f-7f41-7654-8c88-66280be01234'); + $details = SecurityAlertDetails::forStatusToggled(false); + $occuredAt = new \DateTimeImmutable('2026-08-21 10:00:00 UTC'); + $mail = new ClientSecurityAlertMail( + eventType: 'Client Status Changed (Deactivated)', + clientId: $clientId, + clientIdentifier: 'arcadia_id_testclient', + occurredAt: $occuredAt, + details: $details, + ); + + // WHEN + $content = $mail->content(); + + // THEN + expect($content->markdown)->toBe('mails.security-alert') + ->and($content->with['eventType'])->toBe('Client Status Changed (Deactivated)') + ->and($content->with['clientIdentifier'])->toBe('arcadia_id_testclient') + ->and($content->with['clientId'])->toBe($clientId) + ->and($content->with['occurredAt'])->toBe($occuredAt) + ->and($content->with['details'])->toBe($details) + ->and($content->with['details']->isActive)->toBeFalse() + ->and($content->with['details']->description)->toBe('Client account suspended'); + }); +}); diff --git a/tests/Unit/Infra/Mail/SecurityAlertDetailsTest.php b/tests/Unit/Infra/Mail/SecurityAlertDetailsTest.php new file mode 100644 index 0000000..dc1652c --- /dev/null +++ b/tests/Unit/Infra/Mail/SecurityAlertDetailsTest.php @@ -0,0 +1,38 @@ +isActive)->toBeNull() + ->and($details->description)->toBeNull(); + }); + + it('creates details for activated status toggle', function (): void { + // WHEN + $details = SecurityAlertDetails::forStatusToggled(true); + + // THEN + expect($details->isActive)->toBeTrue() + ->and($details->description)->toBe('Client account activated'); + }); + + it('creates details for deactivated status toggle', function (): void { + // WHEN + $details = SecurityAlertDetails::forStatusToggled(false); + + // THEN + expect($details->isActive)->toBeFalse() + ->and($details->description)->toBe('Client account suspended'); + }); +});