From a4aad60c6a7428441329f0671c7744efc51f94ac Mon Sep 17 00:00:00 2001 From: David Badura Date: Fri, 3 Apr 2026 18:46:16 +0200 Subject: [PATCH] add doctrine cipher key store for hydrator extension --- phpstan-baseline.neon | 42 ++++- .../ExtensionDoctrineCipherKeyStore.php | 127 ++++++++++++++ .../PersonalData/Events/NameChanged.php | 6 +- .../PersonalData/Events/ProfileCreated.php | 6 +- .../PersonalData/PersonalDataTest.php | 164 ++++++++++++++++++ 5 files changed, 340 insertions(+), 5 deletions(-) create mode 100644 src/Cryptography/ExtensionDoctrineCipherKeyStore.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index b84c95a16..8f4bd55e6 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -18,6 +18,42 @@ parameters: count: 1 path: src/Cryptography/DoctrineCipherKeyStore.php + - + message: '#^Offset ''crypto_iv'' does not exist on array\{id\: non\-empty\-string, subject_id\: non\-empty\-string, crypto_key\: non\-empty\-string, crypto_method\: non\-empty\-string, created_at\: non\-empty\-string\}\.$#' + identifier: offsetAccess.notFound + count: 1 + path: src/Cryptography/ExtensionDoctrineCipherKeyStore.php + + - + message: '#^Parameter \#1 \$string of function base64_decode expects string, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Cryptography/ExtensionDoctrineCipherKeyStore.php + + - + message: '#^Parameter \#2 \$subjectId of class Patchlevel\\Hydrator\\Extension\\Cryptography\\Cipher\\CipherKey constructor expects non\-empty\-string, string given\.$#' + identifier: argument.type + count: 1 + path: src/Cryptography/ExtensionDoctrineCipherKeyStore.php + + - + message: '#^Parameter \#3 \$key of class Patchlevel\\Hydrator\\Extension\\Cryptography\\Cipher\\CipherKey constructor expects non\-empty\-string, string given\.$#' + identifier: argument.type + count: 1 + path: src/Cryptography/ExtensionDoctrineCipherKeyStore.php + + - + message: '#^Parameter \#4 \$method of class Patchlevel\\Hydrator\\Extension\\Cryptography\\Cipher\\CipherKey constructor expects non\-empty\-string, string given\.$#' + identifier: argument.type + count: 1 + path: src/Cryptography/ExtensionDoctrineCipherKeyStore.php + + - + message: '#^Parameter \#5 \$createdAt of class Patchlevel\\Hydrator\\Extension\\Cryptography\\Cipher\\CipherKey constructor expects DateTimeImmutable, mixed given\.$#' + identifier: argument.type + count: 2 + path: src/Cryptography/ExtensionDoctrineCipherKeyStore.php + - message: '#^Call to function method_exists\(\) with ReflectionFunction and ''isAnonymous'' will always evaluate to true\.$#' identifier: function.alreadyNarrowedType @@ -285,19 +321,19 @@ parameters: - message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertArrayHasKey\(\) with 0 and array\{array\\} will always evaluate to true\.$#' identifier: staticMethod.alreadyNarrowedType - count: 1 + count: 4 path: tests/Integration/PersonalData/PersonalDataTest.php - message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertInstanceOf\(\) with ''Patchlevel\\\\EventSourcing\\\\Tests\\\\Integration\\\\PersonalData\\\\Profile'' and Patchlevel\\EventSourcing\\Tests\\Integration\\PersonalData\\Profile will always evaluate to true\.$#' identifier: staticMethod.alreadyNarrowedType - count: 6 + count: 8 path: tests/Integration/PersonalData/PersonalDataTest.php - message: '#^Parameter \#2 \$haystack of static method PHPUnit\\Framework\\Assert\:\:assertStringNotContainsString\(\) expects string, mixed given\.$#' identifier: argument.type - count: 1 + count: 3 path: tests/Integration/PersonalData/PersonalDataTest.php - diff --git a/src/Cryptography/ExtensionDoctrineCipherKeyStore.php b/src/Cryptography/ExtensionDoctrineCipherKeyStore.php new file mode 100644 index 000000000..8bb321319 --- /dev/null +++ b/src/Cryptography/ExtensionDoctrineCipherKeyStore.php @@ -0,0 +1,127 @@ +dateTimeType = Type::getType(Types::DATETIMETZ_IMMUTABLE); + } + + public function get(string $id): CipherKey + { + /** @var Row|false $result */ + $result = $this->connection->fetchAssociative( + "SELECT * FROM {$this->tableName} WHERE id = :id", + ['id' => $id], + ); + + if ($result === false) { + throw CipherKeyNotExists::forKeyId($id); + } + + return new CipherKey( + $result['id'], + $result['subject_id'], + base64_decode($result['crypto_key']), + $result['crypto_method'], + $this->dateTimeType->convertToPHPValue($result['created_at'], $this->connection->getDatabasePlatform()), + ); + } + + public function currentKeyFor(string $subjectId): CipherKey + { + /** @var Row|false $result */ + $result = $this->connection->fetchAssociative( + "SELECT * FROM {$this->tableName} WHERE subject_id = :subject_id", + ['subject_id' => $subjectId], + ); + + if ($result === false) { + throw CipherKeyNotExists::forSubjectId($subjectId); + } + + return new CipherKey( + $result['id'], + base64_decode($result['crypto_key']), + $result['crypto_method'], + base64_decode($result['crypto_iv']), + $this->dateTimeType->convertToPHPValue($result['created_at'], $this->connection->getDatabasePlatform()), + ); + } + + public function store(CipherKey $key): void + { + $this->connection->insert($this->tableName, [ + 'id' => $key->id, + 'subject_id' => $key->subjectId, + 'crypto_key' => base64_encode($key->key), + 'crypto_method' => $key->method, + 'created_at' => $this->dateTimeType->convertToDatabaseValue($key->createdAt, $this->connection->getDatabasePlatform()), + ]); + } + + public function remove(string $id): void + { + $this->connection->delete($this->tableName, ['id' => $id]); + } + + public function removeWithSubjectId(string $subjectId): void + { + $this->connection->delete($this->tableName, ['subject_id' => $subjectId]); + } + + public function configureSchema(Schema $schema, Connection $connection): void + { + if (!DoctrineHelper::sameDatabase($this->connection, $connection)) { + return; + } + + $table = $schema->createTable($this->tableName); + $table->addColumn('id', 'string') + ->setNotnull(true) + ->setLength(255); + $table->addColumn('subject_id', 'string') + ->setNotnull(true) + ->setLength(255); + $table->addColumn('crypto_key', 'string') + ->setNotnull(true) + ->setLength(255); + $table->addColumn('crypto_method', 'string') + ->setNotnull(true) + ->setLength(255); + $table->addColumn('created_at', 'datetimetz_immutable') + ->setNotnull(true); + $table->setPrimaryKey(['id']); + $table->addIndex(['subject_id']); + } +} diff --git a/tests/Integration/PersonalData/Events/NameChanged.php b/tests/Integration/PersonalData/Events/NameChanged.php index 22c18482d..bc6c1edd0 100644 --- a/tests/Integration/PersonalData/Events/NameChanged.php +++ b/tests/Integration/PersonalData/Events/NameChanged.php @@ -6,15 +6,19 @@ use Patchlevel\EventSourcing\Attribute\Event; use Patchlevel\EventSourcing\Tests\Integration\PersonalData\ProfileId; -use Patchlevel\Hydrator\Attribute\DataSubjectId; +use Patchlevel\Hydrator\Attribute\DataSubjectId as LegacyDataSubjectId; use Patchlevel\Hydrator\Attribute\PersonalData; +use Patchlevel\Hydrator\Extension\Cryptography\Attribute\DataSubjectId; +use Patchlevel\Hydrator\Extension\Cryptography\Attribute\SensitiveData; #[Event('profile.name_changed')] final class NameChanged { public function __construct( #[DataSubjectId] + #[LegacyDataSubjectId] public readonly ProfileId $aggregateId, + #[SensitiveData(fallback: 'unknown')] #[PersonalData(fallback: 'unknown')] public readonly string $name, ) { diff --git a/tests/Integration/PersonalData/Events/ProfileCreated.php b/tests/Integration/PersonalData/Events/ProfileCreated.php index 82b1dd0b8..bb9e775f3 100644 --- a/tests/Integration/PersonalData/Events/ProfileCreated.php +++ b/tests/Integration/PersonalData/Events/ProfileCreated.php @@ -6,15 +6,19 @@ use Patchlevel\EventSourcing\Attribute\Event; use Patchlevel\EventSourcing\Tests\Integration\PersonalData\ProfileId; -use Patchlevel\Hydrator\Attribute\DataSubjectId; +use Patchlevel\Hydrator\Attribute\DataSubjectId as LegacyDataSubjectId; use Patchlevel\Hydrator\Attribute\PersonalData; +use Patchlevel\Hydrator\Extension\Cryptography\Attribute\DataSubjectId; +use Patchlevel\Hydrator\Extension\Cryptography\Attribute\SensitiveData; #[Event('profile.created')] final class ProfileCreated { public function __construct( #[DataSubjectId] + #[LegacyDataSubjectId] public ProfileId $profileId, + #[SensitiveData(fallback: 'unknown')] #[PersonalData(fallback: 'unknown')] public string $name, ) { diff --git a/tests/Integration/PersonalData/PersonalDataTest.php b/tests/Integration/PersonalData/PersonalDataTest.php index eb2b4a7fb..a4782506e 100644 --- a/tests/Integration/PersonalData/PersonalDataTest.php +++ b/tests/Integration/PersonalData/PersonalDataTest.php @@ -6,7 +6,9 @@ use Doctrine\DBAL\Connection; use Patchlevel\EventSourcing\Cryptography\DoctrineCipherKeyStore; +use Patchlevel\EventSourcing\Cryptography\ExtensionDoctrineCipherKeyStore; use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootRegistry; +use Patchlevel\EventSourcing\Metadata\Event\AttributeEventRegistryFactory; use Patchlevel\EventSourcing\Repository\DefaultRepositoryManager; use Patchlevel\EventSourcing\Schema\ChainDoctrineSchemaConfigurator; use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector; @@ -19,7 +21,11 @@ use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessorRepository; use Patchlevel\EventSourcing\Tests\DbalManager; use Patchlevel\EventSourcing\Tests\Integration\PersonalData\Processor\DeletePersonalDataProcessor; +use Patchlevel\Hydrator\CoreExtension; use Patchlevel\Hydrator\Cryptography\PersonalDataPayloadCryptographer; +use Patchlevel\Hydrator\Extension\Cryptography\BaseCryptographer; +use Patchlevel\Hydrator\Extension\Cryptography\CryptographyExtension; +use Patchlevel\Hydrator\StackHydratorBuilder; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\TestCase; @@ -232,4 +238,162 @@ public function testRemoveKeyWithEventAndSnapshot(): void self::assertSame(2, $profile->playhead()); self::assertSame('unknown', $profile->name()); } + + public function testWithStackHydrator(): void + { + $cipherKeyStore = new ExtensionDoctrineCipherKeyStore($this->connection); + $extension = new CryptographyExtension( + BaseCryptographer::createWithOpenssl($cipherKeyStore), + ); + + $eventSerializer = new DefaultEventSerializer( + (new AttributeEventRegistryFactory())->create([__DIR__ . '/Events']), + (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->useExtension($extension) + ->build(), + ); + + $store = new DoctrineDbalStore( + $this->connection, + $eventSerializer, + ); + + $manager = new DefaultRepositoryManager( + new AggregateRootRegistry(['profile' => Profile::class]), + $store, + ); + + $repository = $manager->get(Profile::class); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + new ChainDoctrineSchemaConfigurator([ + $store, + $cipherKeyStore, + ]), + ); + + $schemaDirector->create(); + + $profileId = ProfileId::generate(); + $profile = Profile::create($profileId, 'John'); + + $repository->save($profile); + + $profile = $repository->load($profileId); + + self::assertInstanceOf(Profile::class, $profile); + self::assertEquals($profileId, $profile->aggregateRootId()); + self::assertSame(1, $profile->playhead()); + self::assertSame('John', $profile->name()); + + $result = $this->connection->fetchAllAssociative('SELECT * FROM eventstore'); + + self::assertCount(1, $result); + self::assertArrayHasKey(0, $result); + + $row = $result[0]; + + self::assertStringNotContainsString('John', $row['payload']); + } + + public function testWithStackHydratorWithLegacyFallback(): void + { + $extensionCipherKeyStore = new ExtensionDoctrineCipherKeyStore($this->connection); + $legacyCipherKeyStore = new DoctrineCipherKeyStore($this->connection); + + $cryptographer = PersonalDataPayloadCryptographer::createWithOpenssl($legacyCipherKeyStore); + + $store = new DoctrineDbalStore( + $this->connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events'], cryptographer: $cryptographer), + ); + + $manager = new DefaultRepositoryManager( + new AggregateRootRegistry(['profile' => Profile::class]), + $store, + ); + + $repository = $manager->get(Profile::class); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + new ChainDoctrineSchemaConfigurator([ + $store, + $legacyCipherKeyStore, + $extensionCipherKeyStore, + ]), + ); + + $schemaDirector->create(); + + $profileId = ProfileId::generate(); + $profile = Profile::create($profileId, 'John'); + + $repository->save($profile); + + // switch to new hydrator + + $extension = new CryptographyExtension( + BaseCryptographer::createWithOpenssl($extensionCipherKeyStore), + PersonalDataPayloadCryptographer::createWithOpenssl($legacyCipherKeyStore), + ); + + $eventSerializer = new DefaultEventSerializer( + (new AttributeEventRegistryFactory())->create([__DIR__ . '/Events']), + (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->useExtension($extension) + ->build(), + ); + + $store = new DoctrineDbalStore( + $this->connection, + $eventSerializer, + ); + + $manager = new DefaultRepositoryManager( + new AggregateRootRegistry(['profile' => Profile::class]), + $store, + ); + + $repository = $manager->get(Profile::class); + $profile = $repository->load($profileId); + + self::assertInstanceOf(Profile::class, $profile); + self::assertEquals($profileId, $profile->aggregateRootId()); + self::assertSame(1, $profile->playhead()); + self::assertSame('John', $profile->name()); + + $result = $this->connection->fetchAllAssociative('SELECT * FROM eventstore'); + + self::assertCount(1, $result); + self::assertArrayHasKey(0, $result); + + $row = $result[0]; + + self::assertStringNotContainsString('John', $row['payload']); + + $result = $this->connection->fetchAllAssociative('SELECT * FROM crypto_keys'); + + self::assertCount(1, $result); + self::assertArrayHasKey(0, $result); + + $row = $result[0]; + + self::assertEquals($profileId->toString(), $row['subject_id']); + + $result = $this->connection->fetchAllAssociative('SELECT * FROM cryptography_keys'); + + self::assertCount(0, $result); + + $profile->changeName('John 2'); + $repository->save($profile); + + $result = $this->connection->fetchAllAssociative('SELECT * FROM cryptography_keys'); + + self::assertCount(1, $result); + self::assertEquals($profileId->toString(), $row['subject_id']); + } }