From 4d68a978891aebbfd44f92c9baad120f357eda80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Munsch?= Date: Thu, 20 Aug 2026 12:53:07 +0200 Subject: [PATCH] feat(security): derive client createdAt from UUIDv7 and add HasTimestampIdentifier interface --- database/factories/ApiClientFactory.php | 1 - ..._08_10_145347_create_api_clients_table.php | 1 - database/seeders/ApiClientSeeder.php | 1 - .../Security/Models/Entities/Client.php | 13 ++- .../Models/ValueObjects/Entities/ClientId.php | 5 + .../FindAllClients/PaginatedClient.php | 2 +- .../Repositories/ApiClientRepository.php | 2 - src/Infra/Database/Models/ApiClient.php | 3 - .../DDD/Model/HasTimestampIdentifier.php | 10 ++ src/Shared/Helpers/UuidHelper.php | 28 +++++ .../Repositories/ApiClientRepositoryTest.php | 4 - .../Security/Models/Entities/ClientTest.php | 107 ++++++++++++++++++ .../CreateClient/CreateClientTest.php | 1 - .../FindAllClients/FindAllClientsTest.php | 1 - .../Usecases/FindClient/FindClientTest.php | 2 - .../RegenerateClientSecretTest.php | 1 - .../ToggleClientStatusTest.php | 5 - .../ValueObjects/Entities/ClientIdTest.php | 4 +- .../InMemoryClientRepositoryTest.php | 1 - .../Services/RequestValidatorImplTest.php | 2 - tests/Unit/Shared/Helpers/UuidHelperTest.php | 23 ++++ 21 files changed, 185 insertions(+), 32 deletions(-) create mode 100644 src/Shared/DDD/Model/HasTimestampIdentifier.php create mode 100644 tests/Unit/Application/Security/Models/Entities/ClientTest.php diff --git a/database/factories/ApiClientFactory.php b/database/factories/ApiClientFactory.php index f7ddf39..6ba2a1f 100644 --- a/database/factories/ApiClientFactory.php +++ b/database/factories/ApiClientFactory.php @@ -34,7 +34,6 @@ final class ApiClientFactory extends Factory 'identifier' => $clientIdentifierGenerator->generateIdentifier()->value, 'secret' => $clientIdentifierGenerator->generateSecret()->value, 'is_active' => true, - 'created_at' => now(), 'updated_at' => now(), ]; } diff --git a/database/migrations/2026_08_10_145347_create_api_clients_table.php b/database/migrations/2026_08_10_145347_create_api_clients_table.php index f9642eb..2ec3939 100644 --- a/database/migrations/2026_08_10_145347_create_api_clients_table.php +++ b/database/migrations/2026_08_10_145347_create_api_clients_table.php @@ -25,7 +25,6 @@ return new class () extends Migration $table->boolean('is_active') ->comment('Defines if the client can be used in order to gain access to the system'); - $table->timestamp('created_at'); $table->timestamp('updated_at'); }); } diff --git a/database/seeders/ApiClientSeeder.php b/database/seeders/ApiClientSeeder.php index c7e872e..cd3a651 100644 --- a/database/seeders/ApiClientSeeder.php +++ b/database/seeders/ApiClientSeeder.php @@ -25,7 +25,6 @@ final class ApiClientSeeder extends Seeder 'slug' => Str::slug($devClient['name']), 'secret' => $devClient['secret'], 'is_active' => true, - 'created_at' => $now, 'updated_at' => $now, ] ); diff --git a/src/Application/Security/Models/Entities/Client.php b/src/Application/Security/Models/Entities/Client.php index 25db84c..d258ac1 100644 --- a/src/Application/Security/Models/Entities/Client.php +++ b/src/Application/Security/Models/Entities/Client.php @@ -9,11 +9,12 @@ use Application\Security\Models\ValueObjects\ClientName; use Application\Security\Models\ValueObjects\ClientSecret; use Application\Security\Models\ValueObjects\Entities\ClientId; use Shared\DDD\Model\Entity; +use Shared\DDD\Model\HasTimestampIdentifier; /** * @implements Entity */ -final class Client implements Entity +final class Client implements Entity, HasTimestampIdentifier { private function __construct( public ClientId $id, @@ -21,7 +22,6 @@ final class Client implements Entity public ClientName $name, public ClientSecret $secret, public bool $isActive, - public \DateTimeInterface $createdAt, public \DateTimeInterface $updatedAt, ) { } @@ -32,6 +32,12 @@ final class Client implements Entity return $this->id; } + #[\Override] + public function createdAt(): \DateTimeInterface + { + return $this->id->toDateTime(); + } + public static function create( ClientId $id, ClientName $name, @@ -46,7 +52,6 @@ final class Client implements Entity name: $name, secret: $secret, isActive: true, - createdAt: $now, updatedAt: $now, ); } @@ -57,7 +62,6 @@ final class Client implements Entity ClientIdentifier $identifier, ClientSecret $secret, bool $isActive, - \DateTimeInterface $createdAt, \DateTimeInterface $updatedAt, ): self { return new self( @@ -66,7 +70,6 @@ final class Client implements Entity name: $name, secret: $secret, isActive: $isActive, - createdAt: $createdAt, updatedAt: $updatedAt, ); } diff --git a/src/Application/Security/Models/ValueObjects/Entities/ClientId.php b/src/Application/Security/Models/ValueObjects/Entities/ClientId.php index a755fe5..bf050dd 100644 --- a/src/Application/Security/Models/ValueObjects/Entities/ClientId.php +++ b/src/Application/Security/Models/ValueObjects/Entities/ClientId.php @@ -19,4 +19,9 @@ final readonly class ClientId extends EntityId } parent::__construct($identifier->toString()); } + + public function toDateTime(): \DateTimeInterface + { + return UuidHelper::toDateTime($this->identifier); + } } diff --git a/src/Application/Security/Usecases/FindAllClients/PaginatedClient.php b/src/Application/Security/Usecases/FindAllClients/PaginatedClient.php index 5ee173d..bd13418 100644 --- a/src/Application/Security/Usecases/FindAllClients/PaginatedClient.php +++ b/src/Application/Security/Usecases/FindAllClients/PaginatedClient.php @@ -29,7 +29,7 @@ final readonly class PaginatedClient implements UsecaseOutput identifier: $client->identifier, name: $client->name, isActive: $client->isActive, - createdAt: $client->createdAt, + createdAt: $client->createdAt(), updatedAt: $client->updatedAt, ); } diff --git a/src/Infra/Adapters/Repositories/ApiClientRepository.php b/src/Infra/Adapters/Repositories/ApiClientRepository.php index ba189b8..d463194 100644 --- a/src/Infra/Adapters/Repositories/ApiClientRepository.php +++ b/src/Infra/Adapters/Repositories/ApiClientRepository.php @@ -29,7 +29,6 @@ final class ApiClientRepository implements ClientDirectory, ClientRepository 'identifier' => $client->identifier, 'secret' => $client->secret, 'is_active' => $client->isActive, - 'created_at' => $client->createdAt, 'updated_at' => $client->updatedAt, ] ); @@ -129,7 +128,6 @@ final class ApiClientRepository implements ClientDirectory, ClientRepository identifier: $apiClient->identifier, secret: $apiClient->secret, isActive: $apiClient->is_active, - createdAt: $apiClient->created_at, updatedAt: $apiClient->updated_at, ); } diff --git a/src/Infra/Database/Models/ApiClient.php b/src/Infra/Database/Models/ApiClient.php index 81917f8..942e0e8 100644 --- a/src/Infra/Database/Models/ApiClient.php +++ b/src/Infra/Database/Models/ApiClient.php @@ -21,7 +21,6 @@ use Infra\Casts\UuidCast; * @property \Application\Security\Models\ValueObjects\ClientIdentifier $identifier ENCRYPTED, Unique identifier that should not change * @property \Application\Security\Models\ValueObjects\ClientSecret $secret ENCRYPTED, api secret that can be regenerated * @property bool $is_active Defines if the client can be used in order to gain access to the system - * @property \Illuminate\Support\Carbon $created_at * @property \Illuminate\Support\Carbon $updated_at * @property-read \Illuminate\Notifications\DatabaseNotificationCollection $notifications * @@ -54,7 +53,6 @@ final class ApiClient extends Authenticatable 'identifier', 'secret', 'is_active', - 'created_at', 'updated_at', ]; @@ -73,7 +71,6 @@ final class ApiClient extends Authenticatable 'identifier' => ClientIdentifierCast::class, 'secret' => ClientSecretCast::class, 'is_active' => 'boolean', - 'created_at' => 'datetime', 'updated_at' => 'datetime', ]; } diff --git a/src/Shared/DDD/Model/HasTimestampIdentifier.php b/src/Shared/DDD/Model/HasTimestampIdentifier.php new file mode 100644 index 0000000..d7f6bab --- /dev/null +++ b/src/Shared/DDD/Model/HasTimestampIdentifier.php @@ -0,0 +1,10 @@ +toString()); } } + + public static function toDateTime(UuidInterface $uuid): \DateTimeImmutable + { + if (! self::isV7($uuid)) { + throw new InvalidUuidException("Cannot extract datetime from non-V7 UUID: {$uuid->toString()}"); + } + + $fields = $uuid->getFields(); + + if ($fields instanceof Rfc4122FieldsInterface) { + $timeMsHex = $fields->getTimeLow()->toString() . $fields->getTimeMid()->toString(); + } else { + $hex = str_replace('-', '', $uuid->toString()); + $timeMsHex = substr($hex, 0, 12); + } + + $timeMs = (int) hexdec($timeMsHex); + $sec = intdiv($timeMs, 1000); + $ms = $timeMs % 1000; + + $dateTime = \DateTimeImmutable::createFromFormat('U.u', sprintf('%d.%06d', $sec, $ms * 1000)); + + if ($dateTime === false) { + throw new InvalidUuidException("Failed to extract datetime from UUID: {$uuid->toString()}"); + } + + return $dateTime; + } } diff --git a/tests/Integration/Infra/Adapters/Repositories/ApiClientRepositoryTest.php b/tests/Integration/Infra/Adapters/Repositories/ApiClientRepositoryTest.php index 93cf883..7e89cb2 100644 --- a/tests/Integration/Infra/Adapters/Repositories/ApiClientRepositoryTest.php +++ b/tests/Integration/Infra/Adapters/Repositories/ApiClientRepositoryTest.php @@ -39,7 +39,6 @@ describe('ApiClientRepository Integration Tests', function (): void { identifier: $identifier, secret: $secret, isActive: true, - createdAt: new DateTimeImmutable(), updatedAt: new DateTimeImmutable(), ); @@ -80,7 +79,6 @@ describe('ApiClientRepository Integration Tests', function (): void { identifier: new ClientIdentifier($identifier), secret: new ClientSecret($oldSecret), isActive: true, - createdAt: new DateTimeImmutable(), updatedAt: new DateTimeImmutable(), ); @@ -173,7 +171,6 @@ describe('ApiClientRepository Integration Tests', function (): void { identifier: new ClientIdentifier($identifier), secret: new ClientSecret($secret), isActive: true, - createdAt: new DateTimeImmutable(), updatedAt: new DateTimeImmutable(), ); @@ -195,7 +192,6 @@ describe('ApiClientRepository Integration Tests', function (): void { identifier: new ClientIdentifier('arcadia_id_' . str_repeat('z', 32)), secret: new ClientSecret('arcadia_sec_' . str_repeat('z', 86)), isActive: true, - createdAt: new DateTimeImmutable(), updatedAt: new DateTimeImmutable(), ); diff --git a/tests/Unit/Application/Security/Models/Entities/ClientTest.php b/tests/Unit/Application/Security/Models/Entities/ClientTest.php new file mode 100644 index 0000000..9266dd0 --- /dev/null +++ b/tests/Unit/Application/Security/Models/Entities/ClientTest.php @@ -0,0 +1,107 @@ +toBeInstanceOf(HasTimestampIdentifier::class) + ->and($client->id())->toBe($clientId) + ->and($client->name)->toBe($name) + ->and($client->identifier)->toBe($identifier) + ->and($client->secret)->toBe($secret) + ->and($client->isActive)->toBeTrue() + ->and($client->createdAt())->toBeInstanceOf(\DateTimeInterface::class) + ->and($client->createdAt()->getTimestamp())->toBeLessThanOrEqual(time()); + }); + + it('hydrates a client without stored createdAt', function (): void { + // GIVEN + $uuid = Uuid::uuid7(); + $clientId = new ClientId($uuid); + $name = new ClientName('Hydrated Client'); + $identifier = new ClientIdentifier('arcadia_id_' . str_repeat('c', 32)); + $secret = new ClientSecret('arcadia_sec_' . str_repeat('d', 86)); + $updatedAt = new \DateTimeImmutable('2026-01-01 12:00:00'); + + // WHEN + $client = Client::hydrate( + id: $clientId, + name: $name, + identifier: $identifier, + secret: $secret, + isActive: false, + updatedAt: $updatedAt, + ); + + // THEN + expect($client->id())->toBe($clientId) + ->and($client->isActive)->toBeFalse() + ->and($client->updatedAt)->toBe($updatedAt) + ->and($client->createdAt())->toBeInstanceOf(\DateTimeInterface::class) + ->and($client->createdAt()->getTimestamp())->toBeLessThanOrEqual(time()); + }); + + it('regenerates secret and updates updatedAt', function (): void { + // GIVEN + $uuid = Uuid::uuid7(); + $client = Client::create( + id: new ClientId($uuid), + name: new ClientName('Test Client'), + identifier: new ClientIdentifier('arcadia_id_' . str_repeat('e', 32)), + secret: new ClientSecret('arcadia_sec_' . str_repeat('f', 86)), + ); + $newSecret = new ClientSecret('arcadia_sec_' . str_repeat('g', 86)); + + // WHEN + $client->regenerateSecret($newSecret); + + // THEN + expect($client->secret)->toBe($newSecret); + }); + + it('sets active status and updates updatedAt', function (): void { + // GIVEN + $uuid = Uuid::uuid7(); + $client = Client::create( + id: new ClientId($uuid), + name: new ClientName('Test Client'), + identifier: new ClientIdentifier('arcadia_id_' . str_repeat('h', 32)), + secret: new ClientSecret('arcadia_sec_' . str_repeat('i', 86)), + ); + + // WHEN + $result = $client->setActive(false); + + // THEN + expect($result)->toBeFalse() + ->and($client->isActive)->toBeFalse(); + }); +}); diff --git a/tests/Unit/Application/Security/Usecases/CreateClient/CreateClientTest.php b/tests/Unit/Application/Security/Usecases/CreateClient/CreateClientTest.php index b0690e1..1a54351 100644 --- a/tests/Unit/Application/Security/Usecases/CreateClient/CreateClientTest.php +++ b/tests/Unit/Application/Security/Usecases/CreateClient/CreateClientTest.php @@ -140,7 +140,6 @@ describe('CreateClient Usecase Unit Tests', function (): void { identifier: new ClientIdentifier('arcadia_id_' . str_repeat('e', 32)), secret: new ClientSecret('arcadia_sec_' . str_repeat('f', 86)), isActive: true, - createdAt: now()->toImmutable(), updatedAt: now()->toImmutable(), ); $repository->persist($client); diff --git a/tests/Unit/Application/Security/Usecases/FindAllClients/FindAllClientsTest.php b/tests/Unit/Application/Security/Usecases/FindAllClients/FindAllClientsTest.php index 8808462..bcf7f8f 100644 --- a/tests/Unit/Application/Security/Usecases/FindAllClients/FindAllClientsTest.php +++ b/tests/Unit/Application/Security/Usecases/FindAllClients/FindAllClientsTest.php @@ -39,7 +39,6 @@ describe('FindAllClients Usecase Unit Tests', function (): void { identifier: new ClientIdentifier('arcadia_id_' . str_repeat($identifierSuffix, 32)), secret: new ClientSecret('arcadia_sec_' . str_repeat('a', 86)), isActive: true, - createdAt: new \DateTimeImmutable('2024-01-01'), updatedAt: new \DateTimeImmutable('2024-01-01'), ); } diff --git a/tests/Unit/Application/Security/Usecases/FindClient/FindClientTest.php b/tests/Unit/Application/Security/Usecases/FindClient/FindClientTest.php index ecdaafe..13744f7 100644 --- a/tests/Unit/Application/Security/Usecases/FindClient/FindClientTest.php +++ b/tests/Unit/Application/Security/Usecases/FindClient/FindClientTest.php @@ -38,7 +38,6 @@ describe('FindClient Usecase Unit Tests', function (): void { identifier: new ClientIdentifier('arcadia_id_' . str_repeat('a', 32)), secret: new ClientSecret('arcadia_sec_' . str_repeat('1', 86)), isActive: true, - createdAt: new \DateTimeImmutable(), updatedAt: new \DateTimeImmutable(), ); @@ -48,7 +47,6 @@ describe('FindClient Usecase Unit Tests', function (): void { identifier: new ClientIdentifier('arcadia_id_' . str_repeat('b', 32)), secret: new ClientSecret('arcadia_sec_' . str_repeat('2', 86)), isActive: false, - createdAt: new \DateTimeImmutable(), updatedAt: new \DateTimeImmutable(), ); diff --git a/tests/Unit/Application/Security/Usecases/RegenerateClientSecret/RegenerateClientSecretTest.php b/tests/Unit/Application/Security/Usecases/RegenerateClientSecret/RegenerateClientSecretTest.php index 9fe511c..7fd3bea 100644 --- a/tests/Unit/Application/Security/Usecases/RegenerateClientSecret/RegenerateClientSecretTest.php +++ b/tests/Unit/Application/Security/Usecases/RegenerateClientSecret/RegenerateClientSecretTest.php @@ -55,7 +55,6 @@ describe('RegenerateClientSecret Usecase Unit Tests', function (): void { identifier: $clientIdentifier, secret: $oldSecret, isActive: true, - createdAt: new \DateTimeImmutable(), updatedAt: new \DateTimeImmutable(), ); diff --git a/tests/Unit/Application/Security/Usecases/ToggleClientStatus/ToggleClientStatusTest.php b/tests/Unit/Application/Security/Usecases/ToggleClientStatus/ToggleClientStatusTest.php index 12e3d81..0d0f587 100644 --- a/tests/Unit/Application/Security/Usecases/ToggleClientStatus/ToggleClientStatusTest.php +++ b/tests/Unit/Application/Security/Usecases/ToggleClientStatus/ToggleClientStatusTest.php @@ -48,7 +48,6 @@ describe('SetClientStatus Usecase', function (): void { identifier: $identifier, secret: $secret, isActive: true, - createdAt: new \DateTimeImmutable(), updatedAt: new \DateTimeImmutable(), ); @@ -92,7 +91,6 @@ describe('SetClientStatus Usecase', function (): void { identifier: $identifier, secret: $secret, isActive: false, - createdAt: new \DateTimeImmutable(), updatedAt: new \DateTimeImmutable(), ); @@ -146,7 +144,6 @@ describe('SetClientStatus Usecase', function (): void { identifier: $identifier, secret: $secret, isActive: true, - createdAt: new \DateTimeImmutable(), updatedAt: new \DateTimeImmutable(), ); @@ -183,7 +180,6 @@ describe('SetClientStatus Usecase', function (): void { identifier: $identifier, secret: $secret, isActive: true, - createdAt: new \DateTimeImmutable(), updatedAt: new \DateTimeImmutable(), ); @@ -224,7 +220,6 @@ describe('SetClientStatus Usecase', function (): void { identifier: $identifier, secret: $secret, isActive: false, - createdAt: new \DateTimeImmutable(), updatedAt: new \DateTimeImmutable(), ); diff --git a/tests/Unit/Application/Security/ValueObjects/Entities/ClientIdTest.php b/tests/Unit/Application/Security/ValueObjects/Entities/ClientIdTest.php index 4e01c89..c80e4b4 100644 --- a/tests/Unit/Application/Security/ValueObjects/Entities/ClientIdTest.php +++ b/tests/Unit/Application/Security/ValueObjects/Entities/ClientIdTest.php @@ -21,7 +21,9 @@ describe('ClientId Value Object Instantiation', function (): void { // THEN expect($clientId->identifier)->toBe($uuid7) - ->and($clientId->value)->toBe($uuid7->toString()); + ->and($clientId->value)->toBe($uuid7->toString()) + ->and($clientId->toDateTime())->toBeInstanceOf(\DateTimeInterface::class) + ->and($clientId->toDateTime()->getTimestamp())->toBeLessThanOrEqual(time()); }); }); diff --git a/tests/Unit/Infra/Adapters/Repositories/InMemoryClientRepositoryTest.php b/tests/Unit/Infra/Adapters/Repositories/InMemoryClientRepositoryTest.php index 6836840..70bd688 100644 --- a/tests/Unit/Infra/Adapters/Repositories/InMemoryClientRepositoryTest.php +++ b/tests/Unit/Infra/Adapters/Repositories/InMemoryClientRepositoryTest.php @@ -25,7 +25,6 @@ describe('InMemoryClientRepository Unit Tests', function (): void { identifier: new ClientIdentifier($identifierStr), secret: new ClientSecret('arcadia_sec_' . str_repeat('a', 86)), isActive: true, - createdAt: new \DateTimeImmutable(), updatedAt: new \DateTimeImmutable(), ); } diff --git a/tests/Unit/Infra/Adapters/Services/RequestValidatorImplTest.php b/tests/Unit/Infra/Adapters/Services/RequestValidatorImplTest.php index 3c059b7..38678f5 100644 --- a/tests/Unit/Infra/Adapters/Services/RequestValidatorImplTest.php +++ b/tests/Unit/Infra/Adapters/Services/RequestValidatorImplTest.php @@ -53,7 +53,6 @@ describe('RequestValidatorImpl Unit Tests', function (): void { identifier: $clientIdentifier, secret: $clientSecret, isActive: true, - createdAt: new DateTimeImmutable(), updatedAt: new DateTimeImmutable(), ); @@ -162,7 +161,6 @@ describe('RequestValidatorImpl Unit Tests', function (): void { identifier: $clientIdentifier, secret: $clientSecret, isActive: false, - createdAt: new DateTimeImmutable(), updatedAt: new DateTimeImmutable(), ); diff --git a/tests/Unit/Shared/Helpers/UuidHelperTest.php b/tests/Unit/Shared/Helpers/UuidHelperTest.php index 5ec508c..8728e41 100644 --- a/tests/Unit/Shared/Helpers/UuidHelperTest.php +++ b/tests/Unit/Shared/Helpers/UuidHelperTest.php @@ -8,6 +8,7 @@ use Ramsey\Uuid\Fields\FieldsInterface; use Ramsey\Uuid\Guid\Guid; use Ramsey\Uuid\Nonstandard\Uuid as NonstandardUuid; use Ramsey\Uuid\Uuid; +use Shared\Helpers\Exceptions\InvalidUuidException; use Shared\Helpers\UuidHelper; covers(UuidHelper::class); @@ -106,4 +107,26 @@ describe('Uuid Helper Unit Tests', function (): void { expect(UuidHelper::version($uuidValid))->toBe(7) ->and(UuidHelper::version($uuidShort))->toBe(0); }); + + it('extracts DateTimeImmutable from UUIDv7 correctly', function (): void { + // GIVEN + $uuidV7 = Uuid::uuid7(); + + // WHEN + $dateTime = UuidHelper::toDateTime($uuidV7); + + // THEN + expect($dateTime)->toBeInstanceOf(\DateTimeImmutable::class) + ->and($dateTime->getTimestamp())->toBeLessThanOrEqual(time()) + ->and($dateTime->getTimestamp())->toBeGreaterThan(1700000000); + }); + + it('throws InvalidUuidException when extracting DateTime from non-V7 UUID', function (): void { + // GIVEN + $uuidV4 = Uuid::uuid4(); + + // WHEN & THEN + expect(fn () => UuidHelper::toDateTime($uuidV4)) + ->toThrow(InvalidUuidException::class, 'Cannot extract datetime from non-V7 UUID'); + }); });