Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
808 changes: 22 additions & 786 deletions README.md

Large diffs are not rendered by default.

10 changes: 7 additions & 3 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@
"name": "patchlevel/hydrator",
"type": "library",
"license": "MIT",
"description": "Hydrator",
"description": "A library for seamless hydration of objects to arrays - and back again, optimized for developer experience and performance",
"keywords": [
"hydrator",
"serializer"
"serializer",
"normalizer",
"denormalizer",
"object mapping",
"patchlevel"
],
"homepage": "https://github.com/patchlevel/hydrator",
"homepage": "https://patchlevel.dev/docs/hydrator/latest",
"authors": [
{
"name": "Daniel Badura",
Expand Down
42 changes: 42 additions & 0 deletions docs/caching.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Caching

Before the hydrator can process a class, it builds metadata for it: the
properties, their field names and the resolved [normalizers](normalizer.md).
This happens once per class and process and is cheap, but with many classes
(or in short-lived processes) you can cache the metadata with any PSR-6 or
PSR-16 cache to skip the reflection entirely.

## Configure the cache

Pass the cache to the builder with `setCache`. Both PSR-6
(`Psr\Cache\CacheItemPoolInterface`) and PSR-16 (`Psr\SimpleCache\CacheInterface`)
implementations are accepted.

```php
use Patchlevel\Hydrator\CoreExtension;
use Patchlevel\Hydrator\StackHydratorBuilder;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;

$hydrator = (new StackHydratorBuilder())
->useExtension(new CoreExtension())
->setCache(new FilesystemAdapter())
->build();
```
:::note
Internally the builder wraps the metadata factory in a `Psr6MetadataFactory` or
`Psr16MetadataFactory` from the `Patchlevel\Hydrator\Metadata` namespace. You can
also use these decorators directly if you construct the `StackHydrator` by hand.
:::

:::warning
The cached metadata contains the resolved normalizer instances and everything
metadata enrichers stored in `extras`, so all of it must be serializable. Clear
the cache when you change attributes, property types or normalizers, stale
metadata leads to confusing results.
:::

## Learn more

* [How metadata enrichers add data to the metadata](extensions.md)
* [How normalizers are resolved](normalizer.md)
* [How to use the hydrator](hydrator.md)
206 changes: 206 additions & 0 deletions docs/cryptography.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
# Cryptography

The cryptography extension can encrypt and decrypt sensitive data, e.g.
personal data of customers. For each subject (e.g. a person) a separate cipher
key is created and used to encrypt the marked fields. If the key is deleted,
the data becomes unreadable. This pattern is known as crypto-shredding and
makes "forgetting" a person possible even in immutable storage.
:::experimental
The cryptography extension is experimental and may change in a minor release.
:::

## Setup

Register the `CryptographyExtension` on the builder and pass it a
`Cryptographer`. The `BaseCryptographer` with the openssl cipher is the default
choice; it needs a [cipher key store](#cipher-key-store) to keep the keys.

```php
use Patchlevel\Hydrator\CoreExtension;
use Patchlevel\Hydrator\Extension\Cryptography\BaseCryptographer;
use Patchlevel\Hydrator\Extension\Cryptography\CryptographyExtension;
use Patchlevel\Hydrator\Extension\Cryptography\Store\InMemoryCipherKeyStore;
use Patchlevel\Hydrator\StackHydratorBuilder;

$cipherKeyStore = new InMemoryCipherKeyStore();

$hydrator = (new StackHydratorBuilder())
->useExtension(new CoreExtension())
->useExtension(new CryptographyExtension(BaseCryptographer::createWithOpenssl($cipherKeyStore)))
->build();
```

## DataSubjectId

First you need to define which field identifies the subject the data belongs
to. The cipher key is created and looked up per subject id.

```php
use Patchlevel\Hydrator\Extension\Cryptography\Attribute\DataSubjectId;
use Patchlevel\Hydrator\Extension\Cryptography\Attribute\SensitiveData;

final class EmailChanged
{
public function __construct(
#[DataSubjectId]
public readonly string $profileId,
#[SensitiveData]
public readonly string|null $email,
) {
}
}
```
:::warning
The `DataSubjectId` must be a string, you can use a [normalizer](normalizer.md)
to convert a value object to a string. The subject id itself cannot be sensitive
data.
:::

You can also use multiple subject ids in one class by naming them and
referencing the name from the sensitive fields. The default name is `default`.

```php
use Patchlevel\Hydrator\Extension\Cryptography\Attribute\DataSubjectId;
use Patchlevel\Hydrator\Extension\Cryptography\Attribute\SensitiveData;

final class ProfilesMerged
{
public function __construct(
#[DataSubjectId(name: 'source')]
public readonly string $sourceProfileId,
#[SensitiveData(subjectIdName: 'source')]
public readonly string|null $sourceEmail,
#[DataSubjectId(name: 'target')]
public readonly string $targetProfileId,
#[SensitiveData(subjectIdName: 'target')]
public readonly string|null $targetEmail,
) {
}
}
```

## Fallback values

If the data could not be decrypted, because the key has been removed, a
fallback value is inserted. The default fallback is `null`. You can change this
with the `fallback` parameter:

```php
use Patchlevel\Hydrator\Extension\Cryptography\Attribute\DataSubjectId;
use Patchlevel\Hydrator\Extension\Cryptography\Attribute\SensitiveData;

final class ProfileCreated
{
public function __construct(
#[DataSubjectId]
public readonly string $profileId,
#[SensitiveData(fallback: 'unknown')]
public readonly string $name,
) {
}
}
```

You can also use a callable as a fallback. It receives the subject id:

```php
use Patchlevel\Hydrator\Extension\Cryptography\Attribute\DataSubjectId;
use Patchlevel\Hydrator\Extension\Cryptography\Attribute\SensitiveData;

final class ProfileCreated
{
public function __construct(
#[DataSubjectId]
public readonly string $profileId,
#[SensitiveData(fallback: 'deleted profile')]
public readonly string $name,
#[SensitiveData(fallbackCallable: [self::class, 'anonymizedEmail'])]
public readonly string $email,
) {
}

public static function anonymizedEmail(string $subjectId): string
{
return sprintf('%s@anonymized.example', $subjectId);
}
}
```
:::note
`fallback` and `fallbackCallable` are mutually exclusive, setting both throws
an exception.
:::

## Cipher Key Store

The cipher keys must be stored somewhere. For testing purposes there is an
in-memory implementation:

```php
use Patchlevel\Hydrator\Extension\Cryptography\Store\InMemoryCipherKeyStore;

$cipherKeyStore = new InMemoryCipherKeyStore();
```

For production you have to implement the `CipherKeyStore` interface yourself,
backed by a database or a key management service, because only you know where
the keys should live:

```php
namespace Patchlevel\Hydrator\Extension\Cryptography\Store;

use Patchlevel\Hydrator\Extension\Cryptography\Cipher\CipherKey;

interface CipherKeyStore
{
/** @throws CipherKeyNotExists */
public function currentKeyFor(string $subjectId): CipherKey;

/** @throws CipherKeyNotExists */
public function get(string $id): CipherKey;

public function store(CipherKey $key): void;

public function remove(string $id): void;

public function removeWithSubjectId(string $subjectId): void;
}
```

To avoid hitting your key storage for every operation, you can wrap the store
in one of the cache decorators:

```php
use Patchlevel\Hydrator\Extension\Cryptography\Store\Psr6CacheStoreDecorator;
use Patchlevel\Hydrator\Extension\Cryptography\Store\Psr16CacheStoreDecorator;

$cipherKeyStore = new Psr6CacheStoreDecorator($myDatabaseStore, $psr6CachePool);
// or
$cipherKeyStore = new Psr16CacheStoreDecorator($myDatabaseStore, $psr16Cache);
```

## Remove personal data

To remove personal data, you only need to remove the keys for the subject from
the store. All encrypted fields of that subject then resolve to their
[fallback values](#fallback-values).

```php
$cipherKeyStore->removeWithSubjectId('profile-1');
```
:::danger
Removing a cipher key is irreversible. The encrypted data can never be decrypted
again, that is the point of crypto-shredding, but make sure it is what you want.
:::

:::tip
Cryptography is very expensive in terms of performance. You can combine it with
[lazy objects](lazy.md) so the data is only decrypted when the object is
actually accessed.
:::

## Learn more

* [How to hydrate objects lazily](lazy.md)
* [How to reshape outdated stored data](upcasting.md)
* [How extensions work](extensions.md)
* [How to use the hydrator](hydrator.md)
Loading
Loading