Skip to content

Commit ea7d71e

Browse files
authored
Merge pull request #196 from patchlevel/add-docs
add docs
2 parents 327125d + a21267e commit ea7d71e

18 files changed

Lines changed: 1554 additions & 793 deletions

README.md

Lines changed: 22 additions & 786 deletions
Large diffs are not rendered by default.

composer.json

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,16 @@
22
"name": "patchlevel/hydrator",
33
"type": "library",
44
"license": "MIT",
5-
"description": "Hydrator",
5+
"description": "A library for seamless hydration of objects to arrays - and back again, optimized for developer experience and performance",
66
"keywords": [
77
"hydrator",
8-
"serializer"
8+
"serializer",
9+
"normalizer",
10+
"denormalizer",
11+
"object mapping",
12+
"patchlevel"
913
],
10-
"homepage": "https://github.com/patchlevel/hydrator",
14+
"homepage": "https://patchlevel.dev/docs/hydrator/latest",
1115
"authors": [
1216
{
1317
"name": "Daniel Badura",

docs/caching.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Caching
2+
3+
Before the hydrator can process a class, it builds metadata for it: the
4+
properties, their field names and the resolved [normalizers](normalizer.md).
5+
This happens once per class and process and is cheap, but with many classes
6+
(or in short-lived processes) you can cache the metadata with any PSR-6 or
7+
PSR-16 cache to skip the reflection entirely.
8+
9+
## Configure the cache
10+
11+
Pass the cache to the builder with `setCache`. Both PSR-6
12+
(`Psr\Cache\CacheItemPoolInterface`) and PSR-16 (`Psr\SimpleCache\CacheInterface`)
13+
implementations are accepted.
14+
15+
```php
16+
use Patchlevel\Hydrator\CoreExtension;
17+
use Patchlevel\Hydrator\StackHydratorBuilder;
18+
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
19+
20+
$hydrator = (new StackHydratorBuilder())
21+
->useExtension(new CoreExtension())
22+
->setCache(new FilesystemAdapter())
23+
->build();
24+
```
25+
:::note
26+
Internally the builder wraps the metadata factory in a `Psr6MetadataFactory` or
27+
`Psr16MetadataFactory` from the `Patchlevel\Hydrator\Metadata` namespace. You can
28+
also use these decorators directly if you construct the `StackHydrator` by hand.
29+
:::
30+
31+
:::warning
32+
The cached metadata contains the resolved normalizer instances and everything
33+
metadata enrichers stored in `extras`, so all of it must be serializable. Clear
34+
the cache when you change attributes, property types or normalizers, stale
35+
metadata leads to confusing results.
36+
:::
37+
38+
## Learn more
39+
40+
* [How metadata enrichers add data to the metadata](extensions.md)
41+
* [How normalizers are resolved](normalizer.md)
42+
* [How to use the hydrator](hydrator.md)

docs/cryptography.md

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
# Cryptography
2+
3+
The cryptography extension can encrypt and decrypt sensitive data, e.g.
4+
personal data of customers. For each subject (e.g. a person) a separate cipher
5+
key is created and used to encrypt the marked fields. If the key is deleted,
6+
the data becomes unreadable. This pattern is known as crypto-shredding and
7+
makes "forgetting" a person possible even in immutable storage.
8+
:::experimental
9+
The cryptography extension is experimental and may change in a minor release.
10+
:::
11+
12+
## Setup
13+
14+
Register the `CryptographyExtension` on the builder and pass it a
15+
`Cryptographer`. The `BaseCryptographer` with the openssl cipher is the default
16+
choice; it needs a [cipher key store](#cipher-key-store) to keep the keys.
17+
18+
```php
19+
use Patchlevel\Hydrator\CoreExtension;
20+
use Patchlevel\Hydrator\Extension\Cryptography\BaseCryptographer;
21+
use Patchlevel\Hydrator\Extension\Cryptography\CryptographyExtension;
22+
use Patchlevel\Hydrator\Extension\Cryptography\Store\InMemoryCipherKeyStore;
23+
use Patchlevel\Hydrator\StackHydratorBuilder;
24+
25+
$cipherKeyStore = new InMemoryCipherKeyStore();
26+
27+
$hydrator = (new StackHydratorBuilder())
28+
->useExtension(new CoreExtension())
29+
->useExtension(new CryptographyExtension(BaseCryptographer::createWithOpenssl($cipherKeyStore)))
30+
->build();
31+
```
32+
33+
## DataSubjectId
34+
35+
First you need to define which field identifies the subject the data belongs
36+
to. The cipher key is created and looked up per subject id.
37+
38+
```php
39+
use Patchlevel\Hydrator\Extension\Cryptography\Attribute\DataSubjectId;
40+
use Patchlevel\Hydrator\Extension\Cryptography\Attribute\SensitiveData;
41+
42+
final class EmailChanged
43+
{
44+
public function __construct(
45+
#[DataSubjectId]
46+
public readonly string $profileId,
47+
#[SensitiveData]
48+
public readonly string|null $email,
49+
) {
50+
}
51+
}
52+
```
53+
:::warning
54+
The `DataSubjectId` must be a string, you can use a [normalizer](normalizer.md)
55+
to convert a value object to a string. The subject id itself cannot be sensitive
56+
data.
57+
:::
58+
59+
You can also use multiple subject ids in one class by naming them and
60+
referencing the name from the sensitive fields. The default name is `default`.
61+
62+
```php
63+
use Patchlevel\Hydrator\Extension\Cryptography\Attribute\DataSubjectId;
64+
use Patchlevel\Hydrator\Extension\Cryptography\Attribute\SensitiveData;
65+
66+
final class ProfilesMerged
67+
{
68+
public function __construct(
69+
#[DataSubjectId(name: 'source')]
70+
public readonly string $sourceProfileId,
71+
#[SensitiveData(subjectIdName: 'source')]
72+
public readonly string|null $sourceEmail,
73+
#[DataSubjectId(name: 'target')]
74+
public readonly string $targetProfileId,
75+
#[SensitiveData(subjectIdName: 'target')]
76+
public readonly string|null $targetEmail,
77+
) {
78+
}
79+
}
80+
```
81+
82+
## Fallback values
83+
84+
If the data could not be decrypted, because the key has been removed, a
85+
fallback value is inserted. The default fallback is `null`. You can change this
86+
with the `fallback` parameter:
87+
88+
```php
89+
use Patchlevel\Hydrator\Extension\Cryptography\Attribute\DataSubjectId;
90+
use Patchlevel\Hydrator\Extension\Cryptography\Attribute\SensitiveData;
91+
92+
final class ProfileCreated
93+
{
94+
public function __construct(
95+
#[DataSubjectId]
96+
public readonly string $profileId,
97+
#[SensitiveData(fallback: 'unknown')]
98+
public readonly string $name,
99+
) {
100+
}
101+
}
102+
```
103+
104+
You can also use a callable as a fallback. It receives the subject id:
105+
106+
```php
107+
use Patchlevel\Hydrator\Extension\Cryptography\Attribute\DataSubjectId;
108+
use Patchlevel\Hydrator\Extension\Cryptography\Attribute\SensitiveData;
109+
110+
final class ProfileCreated
111+
{
112+
public function __construct(
113+
#[DataSubjectId]
114+
public readonly string $profileId,
115+
#[SensitiveData(fallback: 'deleted profile')]
116+
public readonly string $name,
117+
#[SensitiveData(fallbackCallable: [self::class, 'anonymizedEmail'])]
118+
public readonly string $email,
119+
) {
120+
}
121+
122+
public static function anonymizedEmail(string $subjectId): string
123+
{
124+
return sprintf('%s@anonymized.example', $subjectId);
125+
}
126+
}
127+
```
128+
:::note
129+
`fallback` and `fallbackCallable` are mutually exclusive, setting both throws
130+
an exception.
131+
:::
132+
133+
## Cipher Key Store
134+
135+
The cipher keys must be stored somewhere. For testing purposes there is an
136+
in-memory implementation:
137+
138+
```php
139+
use Patchlevel\Hydrator\Extension\Cryptography\Store\InMemoryCipherKeyStore;
140+
141+
$cipherKeyStore = new InMemoryCipherKeyStore();
142+
```
143+
144+
For production you have to implement the `CipherKeyStore` interface yourself,
145+
backed by a database or a key management service, because only you know where
146+
the keys should live:
147+
148+
```php
149+
namespace Patchlevel\Hydrator\Extension\Cryptography\Store;
150+
151+
use Patchlevel\Hydrator\Extension\Cryptography\Cipher\CipherKey;
152+
153+
interface CipherKeyStore
154+
{
155+
/** @throws CipherKeyNotExists */
156+
public function currentKeyFor(string $subjectId): CipherKey;
157+
158+
/** @throws CipherKeyNotExists */
159+
public function get(string $id): CipherKey;
160+
161+
public function store(CipherKey $key): void;
162+
163+
public function remove(string $id): void;
164+
165+
public function removeWithSubjectId(string $subjectId): void;
166+
}
167+
```
168+
169+
To avoid hitting your key storage for every operation, you can wrap the store
170+
in one of the cache decorators:
171+
172+
```php
173+
use Patchlevel\Hydrator\Extension\Cryptography\Store\Psr6CacheStoreDecorator;
174+
use Patchlevel\Hydrator\Extension\Cryptography\Store\Psr16CacheStoreDecorator;
175+
176+
$cipherKeyStore = new Psr6CacheStoreDecorator($myDatabaseStore, $psr6CachePool);
177+
// or
178+
$cipherKeyStore = new Psr16CacheStoreDecorator($myDatabaseStore, $psr16Cache);
179+
```
180+
181+
## Remove personal data
182+
183+
To remove personal data, you only need to remove the keys for the subject from
184+
the store. All encrypted fields of that subject then resolve to their
185+
[fallback values](#fallback-values).
186+
187+
```php
188+
$cipherKeyStore->removeWithSubjectId('profile-1');
189+
```
190+
:::danger
191+
Removing a cipher key is irreversible. The encrypted data can never be decrypted
192+
again, that is the point of crypto-shredding, but make sure it is what you want.
193+
:::
194+
195+
:::tip
196+
Cryptography is very expensive in terms of performance. You can combine it with
197+
[lazy objects](lazy.md) so the data is only decrypted when the object is
198+
actually accessed.
199+
:::
200+
201+
## Learn more
202+
203+
* [How to hydrate objects lazily](lazy.md)
204+
* [How to reshape outdated stored data](upcasting.md)
205+
* [How extensions work](extensions.md)
206+
* [How to use the hydrator](hydrator.md)

0 commit comments

Comments
 (0)