Skip to content

Commit

Permalink
add payload cryptographer for snapshots too
Browse files Browse the repository at this point in the history
  • Loading branch information
DavidBadura committed Apr 9, 2024
1 parent 39d04c0 commit 94002a7
Show file tree
Hide file tree
Showing 25 changed files with 891 additions and 64 deletions.
18 changes: 11 additions & 7 deletions baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,17 @@
<code><![CDATA[list<string>]]></code>
</MoreSpecificReturnType>
</file>
<file src="src/Cryptography/DefaultEventPayloadCryptographer.php">
<file src="src/Cryptography/EventPayloadCryptographer.php">
<MixedArgument>
<code><![CDATA[$data[$propertyMetadata->fieldName]]]></code>
</MixedArgument>
<MixedAssignment>
<code><![CDATA[$data[$propertyMetadata->fieldName]]]></code>
<code><![CDATA[$data[$propertyMetadata->fieldName]]]></code>
<code><![CDATA[$data[$propertyMetadata->fieldName]]]></code>
</MixedAssignment>
</file>
<file src="src/Cryptography/SnapshotPayloadCryptographer.php">
<MixedArgument>
<code><![CDATA[$data[$propertyMetadata->fieldName]]]></code>
</MixedArgument>
Expand Down Expand Up @@ -101,12 +111,6 @@
<code><![CDATA[$index]]></code>
</InvalidPropertyAssignmentValue>
</file>
<file src="src/Store/DoctrineHelper.php">
<MixedReturnTypeCoercion>
<code><![CDATA[$normalizedCustomHeaders]]></code>
<code><![CDATA[array<string, string>]]></code>
</MixedReturnTypeCoercion>
</file>
<file src="src/Subscription/Store/DoctrineSubscriptionStore.php">
<MixedArgument>
<code><![CDATA[$context]]></code>
Expand Down
107 changes: 100 additions & 7 deletions docs/pages/personal_data.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ you can simply delete the key and the personal data can no longer be decrypted.

Encrypting and decrypting is handled by the library.
You just have to configure the events accordingly.
And if you use snapshots, you have to configure your aggregates too.

### PersonalData

First of all, we have to mark the fields that contain personal data.
For our example, we use events, but you can do the same with aggregates.

```php
use Patchlevel\EventSourcing\Attribute\PersonalData;
Expand All @@ -35,6 +37,10 @@ final class EmailChanged
}
}
```
!!! tip

You can use the `PersonalData` in aggregates for snapshots too.

If the information could not be decrypted, then a fallback value is inserted.
The default fallback value is `null`.
You can change this by setting the `fallback` parameter.
Expand Down Expand Up @@ -84,6 +90,10 @@ final class EmailChanged
}
}
```
!!! tip

You can use the `DataSubjectId` in aggregates for snapshots too.

!!! warning

A subject ID can not be a personal data.
Expand All @@ -94,7 +104,7 @@ In order for the system to work, a few things have to be done.

!!! tip

You can use named constructor `DefaultEventPayloadCryptographer::createWithOpenssl` to skip some necessary setups.
You can use named constructor `EventPayloadCryptographer::createWithOpenssl` and `SnapshotPayloadCryptographer::createWithOpenssl` to skip some necessary setups.

### Cipher Key Factory

Expand Down Expand Up @@ -135,6 +145,16 @@ To use the `DoctrineCipherKeyStore` you need to register this service in Doctrin
Then the table will be added automatically.

```php
use Doctrine\DBAL\Connection;
use Patchlevel\EventSourcing\Cryptography\Store\DoctrineCipherKeyStore;
use Patchlevel\EventSourcing\Schema\ChainDoctrineSchemaConfigurator;
use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector;

/**
* @var Connection $dbalConnection
* @var DoctrineCipherKeyStore $cipherKeyStore
* @var Store $store
*/
$schemaDirector = new DoctrineSchemaDirector(
$dbalConnection,
new ChainDoctrineSchemaConfigurator([
Expand Down Expand Up @@ -165,22 +185,60 @@ $value = $cipher->decrypt($cipherKey, $encrypted);
Now we have to put the whole thing together in an Event Payload Cryptographer.

```php
use Patchlevel\EventSourcing\Cryptography\DefaultEventPayloadCryptographer;

$cryptographer = new DefaultEventPayloadCryptographer(
```
You can also use the shortcut with openssl.

```php
use Patchlevel\EventSourcing\Cryptography\EventPayloadCryptographer;
use Patchlevel\EventSourcing\Cryptography\Store\CipherKeyStore;

/**
* @var EventMetadataFactory $aggregateRootMetadataFactory
* @var CipherKeyStore $cipherKeyStore
*/
$cryptographer = EventPayloadCryptographer::createWithOpenssl(
$eventMetadataFactory,
$cipherKeyStore,
);
```
### Snapshot Payload Cryptographer

You can also use the cryptographer for snapshots.

```php
use Patchlevel\EventSourcing\Cryptography\Cipher\OpensslCipher;
use Patchlevel\EventSourcing\Cryptography\Cipher\OpensslCipherKeyFactory;
use Patchlevel\EventSourcing\Cryptography\SnapshotPayloadCryptographer;
use Patchlevel\EventSourcing\Cryptography\Store\CipherKeyStore;
use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootMetadataFactory;

/**
* @var AggregateRootMetadataFactory $aggregateRootMetadataFactory
* @var CipherKeyStore $cipherKeyStore
* @var OpensslCipherKeyFactory $cipherKeyFactory
* @var OpensslCipher $cipher
*/
$cryptographer = new SnapshotPayloadCryptographer(
$aggregateRootMetadataFactory,
$cipherKeyStore,
$cipherKeyFactory,
$cipher,
);
```
You can also use the shortcut with openssl.

```php
use Patchlevel\EventSourcing\Cryptography\DefaultEventPayloadCryptographer;

$cryptographer = DefaultEventPayloadCryptographer::createWithOpenssl(
$eventMetadataFactory,
use Patchlevel\EventSourcing\Cryptography\SnapshotPayloadCryptographer;
use Patchlevel\EventSourcing\Cryptography\Store\CipherKeyStore;
use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootMetadataFactory;

/**
* @var AggregateRootMetadataFactory $aggregateRootMetadataFactory
* @var CipherKeyStore $cipherKeyStore
*/
$cryptographer = SnapshotPayloadCryptographer::createWithOpenssl(
$aggregateRootMetadataFactory,
$cipherKeyStore,
);
```
Expand All @@ -189,13 +247,48 @@ $cryptographer = DefaultEventPayloadCryptographer::createWithOpenssl(
The last step is to integrate the cryptographer into the event store.

```php
use Patchlevel\EventSourcing\Cryptography\EventPayloadCryptographer;
use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer;

/**
* @var Hydrator $hydrator
* @var EventPayloadCryptographer $cryptographer
*/
DefaultEventSerializer::createFromPaths(
[__DIR__ . '/Events'],
cryptographer: $cryptographer,
);
```
!!! note

More information about the events can be found [here](./events.md).

And for the snapshot store.

```php
use Patchlevel\EventSourcing\Cryptography\CryptographicHydrator;
use Patchlevel\EventSourcing\Cryptography\SnapshotPayloadCryptographer;
use Patchlevel\EventSourcing\Snapshot\DefaultSnapshotStore;
use Patchlevel\Hydrator\Hydrator;

/**
* @var Hydrator $hydrator
* @var SnapshotPayloadCryptographer $cryptographer
*/
$snapshotStore = new DefaultSnapshotStore(
[
/* adapters... */
],
new CryptographicHydrator(
$hydrator,
$cryptographer,
),
);
```
!!! note

More information about the snapshot store can be found [here](./snapshots.md).

!!! success

Now you can save and read events with personal data.
Expand Down
7 changes: 6 additions & 1 deletion phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@ parameters:
-
message: "#^Parameter \\#2 \\$data of method Patchlevel\\\\EventSourcing\\\\Cryptography\\\\Cipher\\\\Cipher\\:\\:decrypt\\(\\) expects string, mixed given\\.$#"
count: 1
path: src/Cryptography/DefaultEventPayloadCryptographer.php
path: src/Cryptography/EventPayloadCryptographer.php

-
message: "#^Parameter \\#2 \\$data of method Patchlevel\\\\EventSourcing\\\\Cryptography\\\\Cipher\\\\Cipher\\:\\:decrypt\\(\\) expects string, mixed given\\.$#"
count: 1
path: src/Cryptography/SnapshotPayloadCryptographer.php

-
message: "#^Parameter \\#1 \\$key of class Patchlevel\\\\EventSourcing\\\\Cryptography\\\\Cipher\\\\CipherKey constructor expects non\\-empty\\-string, string given\\.$#"
Expand Down
2 changes: 1 addition & 1 deletion src/Cryptography/CryptographicHydrator.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ final class CryptographicHydrator implements Hydrator
{
public function __construct(
private readonly Hydrator $hydrator,
private readonly EventPayloadCryptographer $cryptographer,
private readonly PayloadCryptographer $cryptographer,
) {
}

Expand Down
127 changes: 124 additions & 3 deletions src/Cryptography/EventPayloadCryptographer.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,142 @@

namespace Patchlevel\EventSourcing\Cryptography;

interface EventPayloadCryptographer
use Patchlevel\EventSourcing\Cryptography\Cipher\Cipher;
use Patchlevel\EventSourcing\Cryptography\Cipher\CipherKeyFactory;
use Patchlevel\EventSourcing\Cryptography\Cipher\DecryptionFailed;
use Patchlevel\EventSourcing\Cryptography\Cipher\OpensslCipher;
use Patchlevel\EventSourcing\Cryptography\Cipher\OpensslCipherKeyFactory;
use Patchlevel\EventSourcing\Cryptography\Store\CipherKeyNotExists;
use Patchlevel\EventSourcing\Cryptography\Store\CipherKeyStore;
use Patchlevel\EventSourcing\Metadata\Event\EventMetadataFactory;

use function array_key_exists;
use function is_string;

final class EventPayloadCryptographer implements PayloadCryptographer
{
public function __construct(
private readonly EventMetadataFactory $metadataFactory,
private readonly CipherKeyStore $cipherKeyStore,
private readonly CipherKeyFactory $cipherKeyFactory,
private readonly Cipher $cipher,
) {
}

/**
* @param class-string $class
* @param array<string, mixed> $data
*
* @return array<string, mixed>
*/
public function encrypt(string $class, array $data): array;
public function encrypt(string $class, array $data): array
{
$subjectId = $this->subjectId($class, $data);

if ($subjectId === null) {
return $data;
}

try {
$cipherKey = $this->cipherKeyStore->get($subjectId);
} catch (CipherKeyNotExists) {
$cipherKey = ($this->cipherKeyFactory)();
$this->cipherKeyStore->store($subjectId, $cipherKey);
}

$metadata = $this->metadataFactory->metadata($class);

foreach ($metadata->propertyMetadata as $propertyMetadata) {
if (!$propertyMetadata->isPersonalData) {
continue;
}

$data[$propertyMetadata->fieldName] = $this->cipher->encrypt(
$cipherKey,
$data[$propertyMetadata->fieldName],
);
}

return $data;
}

/**
* @param class-string $class
* @param array<string, mixed> $data
*
* @return array<string, mixed>
*/
public function decrypt(string $class, array $data): array;
public function decrypt(string $class, array $data): array
{
$subjectId = $this->subjectId($class, $data);

if ($subjectId === null) {
return $data;
}

try {
$cipherKey = $this->cipherKeyStore->get($subjectId);
} catch (CipherKeyNotExists) {
$cipherKey = null;
}

$metadata = $this->metadataFactory->metadata($class);

foreach ($metadata->propertyMetadata as $propertyMetadata) {
if (!$propertyMetadata->isPersonalData) {
continue;
}

if (!$cipherKey) {
$data[$propertyMetadata->fieldName] = $propertyMetadata->personalDataFallback;
continue;
}

try {
$data[$propertyMetadata->fieldName] = $this->cipher->decrypt(
$cipherKey,
$data[$propertyMetadata->fieldName],
);
} catch (DecryptionFailed) {
$data[$propertyMetadata->fieldName] = $propertyMetadata->personalDataFallback;
}
}

return $data;
}

/**
* @param class-string $class
* @param array<string, mixed> $data
*/
private function subjectId(string $class, array $data): string|null
{
$metadata = $this->metadataFactory->metadata($class);

if ($metadata->dataSubjectIdField === null) {
return null;
}

if (!array_key_exists($metadata->dataSubjectIdField, $data)) {
throw new MissingSubjectId();
}

$subjectId = $data[$metadata->dataSubjectIdField];

if (!is_string($subjectId)) {
throw new UnsupportedSubjectId($subjectId);
}

return $subjectId;
}

public static function createWithOpenssl(EventMetadataFactory $metadataFactory, CipherKeyStore $cryptoStore): static
{
return new self(
$metadataFactory,
$cryptoStore,
new OpensslCipherKeyFactory(),
new OpensslCipher(),
);
}
}
Loading

0 comments on commit 94002a7

Please sign in to comment.