feat(security): derive client createdAt from UUIDv7 and add HasTimestampIdentifier interface

This commit is contained in:
Jérémy Munsch 2026-08-20 12:53:07 +02:00
parent 9c28cf2903
commit 4d68a97889
Signed by: forge-joniko
GPG key ID: D7EFE2F5CB6CC0E4
21 changed files with 185 additions and 32 deletions

View file

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

View file

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

View file

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

View file

@ -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<ClientId>
*/
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,
);
}

View file

@ -19,4 +19,9 @@ final readonly class ClientId extends EntityId
}
parent::__construct($identifier->toString());
}
public function toDateTime(): \DateTimeInterface
{
return UuidHelper::toDateTime($this->identifier);
}
}

View file

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

View file

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

View file

@ -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<int, \Illuminate\Notifications\DatabaseNotification> $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',
];
}

View file

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Shared\DDD\Model;
interface HasTimestampIdentifier
{
public function createdAt(): \DateTimeInterface;
}

View file

@ -53,4 +53,32 @@ final readonly class UuidHelper
throw InvalidUuidException::forValue($uuid->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;
}
}

View file

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

View file

@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Application\Security\Models\Entities;
use Application\Security\Models\Entities\Client;
use Application\Security\Models\ValueObjects\ClientIdentifier;
use Application\Security\Models\ValueObjects\ClientName;
use Application\Security\Models\ValueObjects\ClientSecret;
use Application\Security\Models\ValueObjects\Entities\ClientId;
use Ramsey\Uuid\Uuid;
use Shared\DDD\Model\HasTimestampIdentifier;
covers(Client::class);
describe('Client Entity', function (): void {
it('creates a new client and derives createdAt from ClientId', function (): void {
// GIVEN
$uuid = Uuid::uuid7();
$clientId = new ClientId($uuid);
$name = new ClientName('Test Client');
$identifier = new ClientIdentifier('arcadia_id_' . str_repeat('a', 32));
$secret = new ClientSecret('arcadia_sec_' . str_repeat('b', 86));
// WHEN
$client = Client::create(
id: $clientId,
name: $name,
identifier: $identifier,
secret: $secret,
);
// THEN
expect($client)->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();
});
});

View file

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

View file

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

View file

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

View file

@ -55,7 +55,6 @@ describe('RegenerateClientSecret Usecase Unit Tests', function (): void {
identifier: $clientIdentifier,
secret: $oldSecret,
isActive: true,
createdAt: new \DateTimeImmutable(),
updatedAt: new \DateTimeImmutable(),
);

View file

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

View file

@ -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());
});
});

View file

@ -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(),
);
}

View file

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

View file

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