Skip to content

Commit

Permalink
add event payload cryptographer for personal data
Browse files Browse the repository at this point in the history
  • Loading branch information
DavidBadura committed Mar 20, 2024
1 parent 3b6277f commit 8ec5c62
Show file tree
Hide file tree
Showing 61 changed files with 2,533 additions and 14 deletions.
57 changes: 55 additions & 2 deletions baseline.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<files psalm-version="5.22.2@d768d914152dbbf3486c36398802f74e80cfde48">
<files psalm-version="5.23.1@8471a896ccea3526b26d082f4461eeea467f10a4">
<file src="src/Aggregate/AggregateRootBehaviour.php">
<UnsafeInstantiation>
<code><![CDATA[new static()]]></code>
Expand All @@ -15,6 +15,34 @@
<code><![CDATA[class DoctrineHelper]]></code>
</ClassNotFinal>
</file>
<file src="src/Cryptography/Cipher/OpensslCipherKeyFactory.php">
<ArgumentTypeCoercion>
<code><![CDATA[openssl_random_pseudo_bytes($this->ivLength)]]></code>
<code><![CDATA[openssl_random_pseudo_bytes($this->keyLength)]]></code>
</ArgumentTypeCoercion>
<LessSpecificReturnStatement>
<code><![CDATA[openssl_get_cipher_methods(true)]]></code>
</LessSpecificReturnStatement>
<MoreSpecificReturnType>
<code><![CDATA[list<string>]]></code>
</MoreSpecificReturnType>
</file>
<file src="src/Cryptography/DefaultEventPayloadCryptographer.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/Store/DoctrineCipherKeyStore.php">
<ArgumentTypeCoercion>
<code><![CDATA[base64_decode($result['crypto_iv'])]]></code>
<code><![CDATA[base64_decode($result['crypto_key'])]]></code>
</ArgumentTypeCoercion>
</file>
<file src="src/EventBus/AttributeListenerProvider.php">
<MixedMethodCall>
<code><![CDATA[$method->getName()]]></code>
Expand Down Expand Up @@ -88,12 +116,21 @@
<code><![CDATA[Closure(Message):void]]></code>
</MixedReturnTypeCoercion>
</file>
<file src="tests/Benchmark/BasicImplementation/Aggregate/Profile.php">
<file src="tests/Benchmark/BasicImplementation/Profile.php">
<PropertyNotSetInConstructor>
<code><![CDATA[$email]]></code>
<code><![CDATA[$id]]></code>
<code><![CDATA[$name]]></code>
</PropertyNotSetInConstructor>
</file>
<file src="tests/Benchmark/PersonalDataBench.php">
<MissingConstructor>
<code><![CDATA[$bus]]></code>
<code><![CDATA[$id]]></code>
<code><![CDATA[$repository]]></code>
<code><![CDATA[$store]]></code>
</MissingConstructor>
</file>
<file src="tests/Benchmark/SimpleSetupBench.php">
<MissingConstructor>
<code><![CDATA[$bus]]></code>
Expand Down Expand Up @@ -145,6 +182,17 @@
<code><![CDATA[$name]]></code>
</PropertyNotSetInConstructor>
</file>
<file src="tests/Integration/PersonalData/PersonalDataTest.php">
<MixedArgument>
<code><![CDATA[$row['payload']]]></code>
</MixedArgument>
</file>
<file src="tests/Integration/PersonalData/Profile.php">
<PropertyNotSetInConstructor>
<code><![CDATA[$id]]></code>
<code><![CDATA[$name]]></code>
</PropertyNotSetInConstructor>
</file>
<file src="tests/Integration/Pipeline/Aggregate/Profile.php">
<PropertyNotSetInConstructor>
<code><![CDATA[$id]]></code>
Expand All @@ -164,6 +212,11 @@
<code><![CDATA[$name]]></code>
</PropertyNotSetInConstructor>
</file>
<file src="tests/Unit/Cryptography/Cipher/OpensslCipherTest.php">
<MixedAssignment>
<code><![CDATA[$return]]></code>
</MixedAssignment>
</file>
<file src="tests/Unit/Fixture/MessageNormalizer.php">
<MixedArgumentTypeCoercion>
<code><![CDATA[$value]]></code>
Expand Down
8 changes: 8 additions & 0 deletions deptrac.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ deptrac:
collectors:
- type: directory
value: src/Console/.*
- name: Cryptography
collectors:
- type: directory
value: src/Cryptography/.*
- name: Debug
collectors:
- type: directory
Expand Down Expand Up @@ -108,6 +112,9 @@ deptrac:
- Serializer
- Store
- Subscription
Cryptography:
- MetadataEvent
- Schema
Debug:
- Attribute
- Message
Expand Down Expand Up @@ -159,6 +166,7 @@ deptrac:
Schema:
Serializer:
- Aggregate
- Cryptography
- MetadataEvent
Snapshot:
- Aggregate
Expand Down
1 change: 1 addition & 0 deletions docs/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ nav:
- Aggregate ID: aggregate_id.md
- Normalizer: normalizer.md
- Snapshots: snapshots.md
- Personal Data: personal_data.md
- Upcasting: upcasting.md
- Outbox: outbox.md
- Pipeline: pipeline.md
Expand Down
243 changes: 243 additions & 0 deletions docs/pages/personal_data.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
# Personal Data (GDPR)

According to GDPR, personal data must be able to be deleted upon request.
But here we have the problem that our events are immutable and we cannot easily manipulate the event store.

The first solution is not to save the personal data in the Event Store at all
and use something different for this, for example a separate table or an ORM.

The other option the library offers is crypto shredding.
In this process, the personal data is encrypted with a key that is assigned to a subject (person).
When saving and reading the events, this key is then used to convert the data.
This key with the subject is saved in a database.

As soon as a request for data deletion comes,
you can simply delete the key and the personal data can no longer be decrypted.

## Configuration

Encrypting and decrypting is handled by the library.
You just have to configure the events accordingly.

### PersonalData

First of all, we have to mark the fields that contain personal data.

```php
use Patchlevel\EventSourcing\Attribute\PersonalData;

final class EmailChanged
{
public function __construct(
#[PersonalData]
public readonly string|null $email
) {
}
}
```

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.
In this case `unknown` is added:

```php
use Patchlevel\EventSourcing\Attribute\PersonalData;

final class EmailChanged
{
public function __construct(
#[PersonalData(fallback: 'unknown')]
public readonly string|null $email
) {
}
}
```

!!! danger

You have to deal with this case in your business logic such as aggregates and subscriptions.

!!! warning

You need to define a subject ID to use the personal data attribute.

!!! note

The normalized data is encrypted. This means that this happens after the 'extract' or before the 'hydrate'.

### DataSubjectId

In order for the correct key to be used, a subject ID must be defined.
Without Subject Id, no personal data can be encrypted or decrypted.

```php
use Patchlevel\EventSourcing\Attribute\PersonalData;
use Patchlevel\EventSourcing\Attribute\DataSubjectId;

final class EmailChanged
{
public function __construct(
#[DataSubjectId]
public readonly string $personId,
#[PersonalData(fallback: 'unknown')]
public readonly string|null $email
) {
}
}
```

!!! warning

A subject ID can not be a personal data.

## Setup

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.

### Cipher Key Factory

We need a factory to generate keys. We provide an openssl implementation by default.


```php
use Patchlevel\EventSourcing\Cryptography\Cipher\OpensslCipherKeyFactory;

$cipherKeyFactory = new OpensslCipherKeyFactory();
$cipherKey = $cipherKeyFactory();
```

You can change the algorithm by passing it as a parameter.

```php
use Patchlevel\EventSourcing\Cryptography\Cipher\OpensslCipherKeyFactory;

$cipherKeyFactory = new OpensslCipherKeyFactory('aes256');
$cipherKey = $cipherKeyFactory();
```

!!! tip

With `OpensslCipherKeyFactory::supportedMethods()` you can get a list of all available algorithms.

### Cipher Key Store

The keys must be stored somewhere. For this we provide a doctrine implementation.

```php
use Patchlevel\EventSourcing\Cryptography\Cipher\CipherKey;
use Patchlevel\EventSourcing\Cryptography\Store\DoctrineCipherKeyStore;

$cipherKeyStore = new DoctrineCipherKeyStore($dbalConnection);

$cipherKeyStore->store('personId', $cipherKey);
$cipherKey = $cipherKeyStore->get('personId');
$cipherKeyStore->remove('personId');
```

To use the `DoctrineCipherKeyStore` you need to register this service in Doctrine Schema Director.
Then the table will be added automatically.

```php
$schemaDirector = new DoctrineSchemaDirector(
$dbalConnection,
new ChainDoctrineSchemaConfigurator([
$store,
$cipherKeyStore,
]),
);
```

### Cipher

The encryption and decryption is handled by the `Cipher`.
We offer an openssl implementation by default.

```php

use Patchlevel\EventSourcing\Cryptography\Cipher\OpensslCipher;

$cipher = new OpensslCipher();

$encrypted = $cipher->encrypt($cipherKey, $value);
$value = $cipher->decrypt($cipherKey, $encrypted);
```

!!! note

If the encryption or decryption fails, an exception `EncryptionFailed` or `DecryptionFailed` is thrown.

### Event Payload Cryptographer

Now we have to put the whole thing together in an Event Payload Cryptographer.

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

$cryptographer = new DefaultEventPayloadCryptographer(
$eventMetadataFactory,
$cipherKeyStore,
$cipherKeyFactory,
$cipher,
);
```

You can also use the shortcut with openssl.

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

$cryptographer = DefaultEventPayloadCryptographer::createWithOpenssl(
$eventMetadataFactory,
$cipherKeyStore,
);
```

### Integration

The last step is to integrate the cryptographer into the event store.

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

DefaultEventSerializer::createFromPaths(
[__DIR__ . '/Events'],
cryptographer: $cryptographer
);
```

!!! success

Now you can save and read events with personal data.

## Remove personal data

To remove personal data, you can either remove the key manually or do it with a processor.

```php
use Patchlevel\EventSourcing\Attribute\Processor;
use Patchlevel\EventSourcing\Attribute\Subscribe;
use Patchlevel\EventSourcing\Cryptography\Store\CipherKeyStore;
use Patchlevel\EventSourcing\Message\Message;

#[Processor('delete_personal_data')]
final class DeletePersonalDataProcessor
{
public function __construct(
private readonly CipherKeyStore $cipherKeyStore,
) {
}

#[Subscribe(UserHasRequestedDeletion::class)]
public function handleUserHasRequestedDeletion(Message $message): void
{
$event = $message->event();

$this->cipherKeyStore->remove($event->personId);
}
}
```
Loading

0 comments on commit 8ec5c62

Please sign in to comment.