feat(security): add database audit logging and asynchronous markdown email alerts for api client actions

This commit is contained in:
Jérémy Munsch 2026-08-21 12:07:13 +02:00
parent 709568f1ed
commit 61073f4fcd
15 changed files with 664 additions and 1 deletions

View file

@ -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=

View file

@ -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

View file

@ -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'),
];

View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class () extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('client_audit_logs', function (Blueprint $table) {
$table->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');
}
};

View file

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Infra\Database\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Infra\Casts\UuidCast;
/**
* @property \Ramsey\Uuid\UuidInterface $id
* @property \Ramsey\Uuid\UuidInterface $client_id
* @property string $client_identifier
* @property string $event_type
* @property array<string, mixed>|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<string> */
protected $fillable = [
'id',
'client_id',
'client_identifier',
'event_type',
'details',
'created_at',
];
/** @return array<string, string> */
#[\Override]
protected function casts(): array
{
return [
'id' => UuidCast::class,
'client_id' => UuidCast::class,
'details' => 'array',
'created_at' => 'datetime',
];
}
}

View file

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Infra\Listeners;
use Application\Security\Events\ClientSecretGenerated;
use Application\Security\Events\ClientSecretRegenerated;
use Application\Security\Events\ClientStatusToggled;
use Infra\Database\Models\ClientAuditLog;
use Ramsey\Uuid\Uuid;
final readonly class RecordClientSecurityAudit
{
public function handle(ClientSecretGenerated | ClientSecretRegenerated | ClientStatusToggled $event): void
{
$eventType = match (true) {
$event instanceof ClientSecretGenerated => '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,
]);
}
}

View file

@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace Infra\Listeners;
use Application\Security\Events\ClientSecretGenerated;
use Application\Security\Events\ClientSecretRegenerated;
use Application\Security\Events\ClientStatusToggled;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Config\Repository as ConfigRepository;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Queue\InteractsWithQueue;
use Infra\Mail\ClientSecurityAlertMail;
use Infra\Mail\SecurityAlertDetails;
final class SendClientSecurityAlertMail implements ShouldQueue
{
use InteractsWithQueue;
use Queueable;
public function __construct(
private readonly Mailer $mailer,
private readonly ConfigRepository $config,
) {
}
public function handle(ClientSecretGenerated | ClientSecretRegenerated | ClientStatusToggled $event): void
{
$recipient = $this->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,
};
}
}

View file

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Infra\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
use Ramsey\Uuid\UuidInterface;
final class ClientSecurityAlertMail extends Mailable implements ShouldQueue
{
use Queueable;
use SerializesModels;
public function __construct(
public readonly string $eventType,
public readonly UuidInterface $clientId,
public readonly string $clientIdentifier,
public readonly \DateTimeInterface $occurredAt,
public readonly SecurityAlertDetails $details,
) {
}
public function envelope(): Envelope
{
return new Envelope(
subject: sprintf(
'[Arcadia Security] %s for %s',
$this->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,
],
);
}
}

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Infra\Mail;
final readonly class SecurityAlertDetails
{
public function __construct(
public ?bool $isActive = null,
public ?string $description = null,
) {
}
public static function forStatusToggled(bool $isActive): self
{
return new self(
isActive: $isActive,
description: $isActive ? 'Client account activated' : 'Client account suspended',
);
}
public static function empty(): self
{
return new self();
}
}

View file

@ -16,6 +16,8 @@ use Infra\Listeners\LogBrandCreated;
use Infra\Listeners\LogBrandRenamed;
use Infra\Listeners\LogClientSecretRotated;
use Infra\Listeners\LogClientStatusChanged;
use Infra\Listeners\RecordClientSecurityAudit;
use Infra\Listeners\SendClientSecurityAlertMail;
class EventServiceProvider extends ServiceProvider
{
@ -46,14 +48,44 @@ class EventServiceProvider extends ServiceProvider
LogClientSecretRotated::class,
);
Event::listen(
ClientSecretGenerated::class,
RecordClientSecurityAudit::class,
);
Event::listen(
ClientSecretGenerated::class,
SendClientSecurityAlertMail::class,
);
Event::listen(
ClientSecretRegenerated::class,
LogClientSecretRotated::class,
);
Event::listen(
ClientSecretRegenerated::class,
RecordClientSecurityAudit::class,
);
Event::listen(
ClientSecretRegenerated::class,
SendClientSecurityAlertMail::class,
);
Event::listen(
ClientStatusToggled::class,
LogClientStatusChanged::class,
);
Event::listen(
ClientStatusToggled::class,
RecordClientSecurityAudit::class,
);
Event::listen(
ClientStatusToggled::class,
SendClientSecurityAlertMail::class,
);
}
}

View file

@ -0,0 +1,21 @@
<x-mail::message>
# ⚠️ Alerte de Sécurité - Arcadia API
Une action d'écriture sensible a été effectuée sur un client API.
<x-mail::panel>
**É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 :**
<pre>{{ $details->description }}</pre>
@endif
</x-mail::panel>
Ce message est généré automatiquement par le système Arcadia API.
</x-mail::message>

View file

@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace Tests\Integration\Infra\Listeners;
use Application\Security\Events\ClientSecretGenerated;
use Application\Security\Events\ClientSecretRegenerated;
use Application\Security\Events\ClientStatusToggled;
use Application\Security\Models\ValueObjects\ClientIdentifier;
use Application\Security\Models\ValueObjects\Entities\ClientId;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Infra\Database\Models\ClientAuditLog;
use Infra\Listeners\RecordClientSecurityAudit;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
covers(RecordClientSecurityAudit::class);
describe('RecordClientSecurityAudit Listener Integration Tests', function (): void {
beforeEach(function (): void {
ClientAuditLog::query()->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');
});
});

View file

@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Infra\Listeners;
use Application\Security\Events\ClientSecretGenerated;
use Application\Security\Events\ClientSecretRegenerated;
use Application\Security\Events\ClientStatusToggled;
use Application\Security\Models\ValueObjects\ClientIdentifier;
use Application\Security\Models\ValueObjects\Entities\ClientId;
use Illuminate\Contracts\Config\Repository as ConfigRepository;
use Illuminate\Support\Facades\Mail;
use Infra\Listeners\SendClientSecurityAlertMail;
use Infra\Mail\ClientSecurityAlertMail;
use Ramsey\Uuid\Uuid;
use Tests\TestCase;
uses(TestCase::class);
covers(SendClientSecurityAlertMail::class);
describe('SendClientSecurityAlertMail Listener Unit Tests', function (): void {
beforeEach(function (): void {
Mail::fake();
});
it('sends email alert when ClientSecretGenerated 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,
);
$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 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;
});
});
});

View file

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Infra\Mail;
use Infra\Mail\ClientSecurityAlertMail;
use Infra\Mail\SecurityAlertDetails;
use Ramsey\Uuid\Uuid;
covers(ClientSecurityAlertMail::class);
describe('ClientSecurityAlertMail Unit Tests', function (): void {
it('builds email envelope with correct subject', function (): void {
// GIVEN
$mail = new ClientSecurityAlertMail(
eventType: 'Client Secret Generated',
clientId: Uuid::fromString('01917b2f-7f41-7654-8c88-66280be01234'),
clientIdentifier: 'arcadia_id_testclient',
occurredAt: new \DateTimeImmutable('2026-08-21 10:00:00 UTC'),
details: SecurityAlertDetails::empty(),
);
// WHEN
$envelope = $mail->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');
});
});

View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Infra\Mail;
use Infra\Mail\SecurityAlertDetails;
covers(SecurityAlertDetails::class);
describe('SecurityAlertDetails DTO Unit Tests', function (): void {
it('creates empty details correctly', function (): void {
// WHEN
$details = SecurityAlertDetails::empty();
// THEN
expect($details->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');
});
});