Skip to content

Commit 12c6b34

Browse files
committed
add doctrine cipher key store for hydrator extension
1 parent ab29c25 commit 12c6b34

4 files changed

Lines changed: 297 additions & 2 deletions

File tree

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\EventSourcing\Cryptography;
6+
7+
use DateTimeImmutable;
8+
use Doctrine\DBAL\Connection;
9+
use Doctrine\DBAL\Schema\Schema;
10+
use Patchlevel\EventSourcing\Schema\DoctrineHelper;
11+
use Patchlevel\EventSourcing\Schema\DoctrineSchemaConfigurator;
12+
use Patchlevel\Hydrator\Extension\Cryptography\Cipher\CipherKey;
13+
use Patchlevel\Hydrator\Extension\Cryptography\Store\CipherKeyNotExists;
14+
use Patchlevel\Hydrator\Extension\Cryptography\Store\CipherKeyStore;
15+
16+
use function base64_decode;
17+
use function base64_encode;
18+
19+
/**
20+
* @phpstan-type Row = array{
21+
* id: non-empty-string,
22+
* subject_id: non-empty-string,
23+
* crypto_key: non-empty-string,
24+
* crypto_method: non-empty-string,
25+
* created_at: non-empty-string
26+
* }
27+
*/
28+
final class ExtensionDoctrineCipherKeyStore implements CipherKeyStore, DoctrineSchemaConfigurator
29+
{
30+
public function __construct(
31+
private readonly Connection $connection,
32+
private readonly string $tableName = 'cryptography_keys',
33+
) {
34+
}
35+
36+
public function get(string $id): CipherKey
37+
{
38+
/** @var Row|false $result */
39+
$result = $this->connection->fetchAssociative(
40+
"SELECT * FROM {$this->tableName} WHERE id = :id",
41+
['id' => $id],
42+
);
43+
44+
if ($result === false) {
45+
throw CipherKeyNotExists::forKeyId($id);
46+
}
47+
48+
return new CipherKey(
49+
$result['id'],
50+
$result['subject_id'],
51+
base64_decode($result['crypto_key']),
52+
$result['crypto_method'],
53+
new DateTimeImmutable($result['created_at']),
54+
);
55+
}
56+
57+
public function currentKeyFor(string $subjectId): CipherKey
58+
{
59+
/** @var Row|false $result */
60+
$result = $this->connection->fetchAssociative(
61+
"SELECT * FROM {$this->tableName} WHERE subject_id = :subject_id",
62+
['subject_id' => $subjectId],
63+
);
64+
65+
if ($result === false) {
66+
throw CipherKeyNotExists::forSubjectId($subjectId);
67+
}
68+
69+
return new CipherKey(
70+
$result['id'],
71+
base64_decode($result['crypto_key']),
72+
$result['crypto_method'],
73+
base64_decode($result['crypto_iv']),
74+
new DateTimeImmutable($result['created_at']),
75+
);
76+
}
77+
78+
public function store(CipherKey $key): void
79+
{
80+
$this->connection->insert($this->tableName, [
81+
'id' => $key->id,
82+
'subject_id' => $key->subjectId,
83+
'crypto_key' => base64_encode($key->key),
84+
'crypto_method' => $key->method,
85+
'created_at' => $key->createdAt->format(DateTimeImmutable::ATOM),
86+
]);
87+
}
88+
89+
public function remove(string $id): void
90+
{
91+
$this->connection->delete($this->tableName, ['id' => $id]);
92+
}
93+
94+
public function removeWithSubjectId(string $subjectId): void
95+
{
96+
$this->connection->delete($this->tableName, ['subject_id' => $subjectId]);
97+
}
98+
99+
public function configureSchema(Schema $schema, Connection $connection): void
100+
{
101+
if (!DoctrineHelper::sameDatabase($this->connection, $connection)) {
102+
return;
103+
}
104+
105+
$table = $schema->createTable($this->tableName);
106+
$table->addColumn('id', 'string')
107+
->setNotnull(true)
108+
->setLength(255);
109+
$table->addColumn('subject_id', 'string')
110+
->setNotnull(true)
111+
->setLength(255);
112+
$table->addColumn('crypto_key', 'string')
113+
->setNotnull(true)
114+
->setLength(255);
115+
$table->addColumn('crypto_method', 'string')
116+
->setNotnull(true)
117+
->setLength(255);
118+
$table->addColumn('created_at', 'datetimetz_immutable')
119+
->setNotnull(true);
120+
$table->setPrimaryKey(['id']);
121+
$table->addIndex(['subject_id']);
122+
}
123+
}

tests/Integration/PersonalData/Events/NameChanged.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,19 @@
66

77
use Patchlevel\EventSourcing\Attribute\Event;
88
use Patchlevel\EventSourcing\Tests\Integration\PersonalData\ProfileId;
9-
use Patchlevel\Hydrator\Attribute\DataSubjectId;
9+
use Patchlevel\Hydrator\Attribute\DataSubjectId as LegacyDataSubjectId;
1010
use Patchlevel\Hydrator\Attribute\PersonalData;
11+
use Patchlevel\Hydrator\Extension\Cryptography\Attribute\DataSubjectId;
12+
use Patchlevel\Hydrator\Extension\Cryptography\Attribute\SensitiveData;
1113

1214
#[Event('profile.name_changed')]
1315
final class NameChanged
1416
{
1517
public function __construct(
1618
#[DataSubjectId]
19+
#[LegacyDataSubjectId]
1720
public readonly ProfileId $aggregateId,
21+
#[SensitiveData(fallback: 'unknown')]
1822
#[PersonalData(fallback: 'unknown')]
1923
public readonly string $name,
2024
) {

tests/Integration/PersonalData/Events/ProfileCreated.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,19 @@
66

77
use Patchlevel\EventSourcing\Attribute\Event;
88
use Patchlevel\EventSourcing\Tests\Integration\PersonalData\ProfileId;
9-
use Patchlevel\Hydrator\Attribute\DataSubjectId;
9+
use Patchlevel\Hydrator\Attribute\DataSubjectId as LegacyDataSubjectId;
1010
use Patchlevel\Hydrator\Attribute\PersonalData;
11+
use Patchlevel\Hydrator\Extension\Cryptography\Attribute\DataSubjectId;
12+
use Patchlevel\Hydrator\Extension\Cryptography\Attribute\SensitiveData;
1113

1214
#[Event('profile.created')]
1315
final class ProfileCreated
1416
{
1517
public function __construct(
1618
#[DataSubjectId]
19+
#[LegacyDataSubjectId]
1720
public ProfileId $profileId,
21+
#[SensitiveData(fallback: 'unknown')]
1822
#[PersonalData(fallback: 'unknown')]
1923
public string $name,
2024
) {

tests/Integration/PersonalData/PersonalDataTest.php

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66

77
use Doctrine\DBAL\Connection;
88
use Patchlevel\EventSourcing\Cryptography\DoctrineCipherKeyStore;
9+
use Patchlevel\EventSourcing\Cryptography\ExtensionDoctrineCipherKeyStore;
910
use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootRegistry;
11+
use Patchlevel\EventSourcing\Metadata\Event\AttributeEventRegistryFactory;
1012
use Patchlevel\EventSourcing\Repository\DefaultRepositoryManager;
1113
use Patchlevel\EventSourcing\Schema\ChainDoctrineSchemaConfigurator;
1214
use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector;
@@ -19,7 +21,11 @@
1921
use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessorRepository;
2022
use Patchlevel\EventSourcing\Tests\DbalManager;
2123
use Patchlevel\EventSourcing\Tests\Integration\PersonalData\Processor\DeletePersonalDataProcessor;
24+
use Patchlevel\Hydrator\CoreExtension;
2225
use Patchlevel\Hydrator\Cryptography\PersonalDataPayloadCryptographer;
26+
use Patchlevel\Hydrator\Extension\Cryptography\BaseCryptographer;
27+
use Patchlevel\Hydrator\Extension\Cryptography\CryptographyExtension;
28+
use Patchlevel\Hydrator\StackHydratorBuilder;
2329
use PHPUnit\Framework\Attributes\CoversNothing;
2430
use PHPUnit\Framework\TestCase;
2531

@@ -232,4 +238,162 @@ public function testRemoveKeyWithEventAndSnapshot(): void
232238
self::assertSame(2, $profile->playhead());
233239
self::assertSame('unknown', $profile->name());
234240
}
241+
242+
public function testWithStackHydrator(): void
243+
{
244+
$cipherKeyStore = new ExtensionDoctrineCipherKeyStore($this->connection);
245+
$extension = new CryptographyExtension(
246+
BaseCryptographer::createWithOpenssl($cipherKeyStore),
247+
);
248+
249+
$eventSerializer = new DefaultEventSerializer(
250+
(new AttributeEventRegistryFactory())->create([__DIR__ . '/Events']),
251+
(new StackHydratorBuilder())
252+
->useExtension(new CoreExtension())
253+
->useExtension($extension)
254+
->build(),
255+
);
256+
257+
$store = new DoctrineDbalStore(
258+
$this->connection,
259+
$eventSerializer,
260+
);
261+
262+
$manager = new DefaultRepositoryManager(
263+
new AggregateRootRegistry(['profile' => Profile::class]),
264+
$store,
265+
);
266+
267+
$repository = $manager->get(Profile::class);
268+
269+
$schemaDirector = new DoctrineSchemaDirector(
270+
$this->connection,
271+
new ChainDoctrineSchemaConfigurator([
272+
$store,
273+
$cipherKeyStore,
274+
]),
275+
);
276+
277+
$schemaDirector->create();
278+
279+
$profileId = ProfileId::generate();
280+
$profile = Profile::create($profileId, 'John');
281+
282+
$repository->save($profile);
283+
284+
$profile = $repository->load($profileId);
285+
286+
self::assertInstanceOf(Profile::class, $profile);
287+
self::assertEquals($profileId, $profile->aggregateRootId());
288+
self::assertSame(1, $profile->playhead());
289+
self::assertSame('John', $profile->name());
290+
291+
$result = $this->connection->fetchAllAssociative('SELECT * FROM eventstore');
292+
293+
self::assertCount(1, $result);
294+
self::assertArrayHasKey(0, $result);
295+
296+
$row = $result[0];
297+
298+
self::assertStringNotContainsString('John', $row['payload']);
299+
}
300+
301+
public function testWithStackHydratorWithLegacyFallback(): void
302+
{
303+
$extensionCipherKeyStore = new ExtensionDoctrineCipherKeyStore($this->connection);
304+
$legacyCipherKeyStore = new DoctrineCipherKeyStore($this->connection);
305+
306+
$cryptographer = PersonalDataPayloadCryptographer::createWithOpenssl($legacyCipherKeyStore);
307+
308+
$store = new DoctrineDbalStore(
309+
$this->connection,
310+
DefaultEventSerializer::createFromPaths([__DIR__ . '/Events'], cryptographer: $cryptographer),
311+
);
312+
313+
$manager = new DefaultRepositoryManager(
314+
new AggregateRootRegistry(['profile' => Profile::class]),
315+
$store,
316+
);
317+
318+
$repository = $manager->get(Profile::class);
319+
320+
$schemaDirector = new DoctrineSchemaDirector(
321+
$this->connection,
322+
new ChainDoctrineSchemaConfigurator([
323+
$store,
324+
$legacyCipherKeyStore,
325+
$extensionCipherKeyStore,
326+
]),
327+
);
328+
329+
$schemaDirector->create();
330+
331+
$profileId = ProfileId::generate();
332+
$profile = Profile::create($profileId, 'John');
333+
334+
$repository->save($profile);
335+
336+
// switch to new hydrator
337+
338+
$extension = new CryptographyExtension(
339+
BaseCryptographer::createWithOpenssl($extensionCipherKeyStore),
340+
PersonalDataPayloadCryptographer::createWithOpenssl($legacyCipherKeyStore),
341+
);
342+
343+
$eventSerializer = new DefaultEventSerializer(
344+
(new AttributeEventRegistryFactory())->create([__DIR__ . '/Events']),
345+
(new StackHydratorBuilder())
346+
->useExtension(new CoreExtension())
347+
->useExtension($extension)
348+
->build(),
349+
);
350+
351+
$store = new DoctrineDbalStore(
352+
$this->connection,
353+
$eventSerializer,
354+
);
355+
356+
$manager = new DefaultRepositoryManager(
357+
new AggregateRootRegistry(['profile' => Profile::class]),
358+
$store,
359+
);
360+
361+
$repository = $manager->get(Profile::class);
362+
$profile = $repository->load($profileId);
363+
364+
self::assertInstanceOf(Profile::class, $profile);
365+
self::assertEquals($profileId, $profile->aggregateRootId());
366+
self::assertSame(1, $profile->playhead());
367+
self::assertSame('John', $profile->name());
368+
369+
$result = $this->connection->fetchAllAssociative('SELECT * FROM eventstore');
370+
371+
self::assertCount(1, $result);
372+
self::assertArrayHasKey(0, $result);
373+
374+
$row = $result[0];
375+
376+
self::assertStringNotContainsString('John', $row['payload']);
377+
378+
$result = $this->connection->fetchAllAssociative('SELECT * FROM crypto_keys');
379+
380+
self::assertCount(1, $result);
381+
self::assertArrayHasKey(0, $result);
382+
383+
$row = $result[0];
384+
385+
self::assertEquals($profileId->toString(), $row['subject_id']);
386+
387+
$result = $this->connection->fetchAllAssociative('SELECT * FROM cryptography_keys');
388+
389+
self::assertCount(0, $result);
390+
391+
$profile->changeName('John 2');
392+
$repository->save($profile);
393+
394+
$result = $this->connection->fetchAllAssociative('SELECT * FROM cryptography_keys');
395+
396+
self::assertCount(1, $result);
397+
self::assertEquals($profileId->toString(), $row['subject_id']);
398+
}
235399
}

0 commit comments

Comments
 (0)