Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add event payload cryptographer for personal data #546

Merged
merged 3 commits into from
Mar 25, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/mutation-tests-diff.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,4 @@ jobs:
dependency-versions: ${{ matrix.dependencies }}

- name: "Infection"
run: "vendor/bin/roave-infection-static-analysis-plugin --git-diff-lines --git-diff-base=origin/$GITHUB_BASE_REF --ignore-msi-with-no-mutations --only-covered --min-msi=80 --min-covered-msi=100"
run: "vendor/bin/roave-infection-static-analysis-plugin --git-diff-lines --git-diff-base=origin/$GITHUB_BASE_REF --ignore-msi-with-no-mutations --only-covered --min-msi=80 --min-covered-msi=95"
55 changes: 54 additions & 1 deletion baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,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 @@ -97,12 +125,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 @@ -154,6 +191,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 @@ -173,6 +221,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
229 changes: 229 additions & 0 deletions docs/pages/personal_data.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
# 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\DataSubjectId;
use Patchlevel\EventSourcing\Attribute\PersonalData;

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\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);
}
}
```
4 changes: 2 additions & 2 deletions infection.json.dist
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"mutators": {
"@default": true
},
"minMsi": 60,
"minCoveredMsi": 87,
"minMsi": 55,
"minCoveredMsi": 90,
"testFrameworkOptions": "--testsuite=unit"
}
Loading
Loading