GDPR Compliance #189
-
Hello everyone, In A pragmatic introduction to Event Sourcing (49:09) on YouTube, @frankdejonge answered, that there are two possible way to deal with personal data in a GDPR compliant system:
I'm very interested in the 2nd option and I'm wondering, what the "best" implementation of such a solution would look like. Default architecture w/out GDPR: Adding a namespace MarkupV1 {
#[Attribute(Attribute::TARGET_PROPERTY)]
final class GDPR
{
// NOP
}
final class AccountWasUpdatedEvent implements \EventSauce\EventSourcing\Serialization\SerializablePayload
{
public function __construct(
public readonly string $myFavoriteColor,
#[GDPR]
public readonly string $mySocialSecurityNumber,
) {}
public function toPayload(): array
{
return (array)$this;
}
public static function fromPayload(array $payload): static
{
return new static(
$payload['myFavoriteColor'],
$payload['mySocalSecurityNumber'],
);
}
}
} Using a custom namespace MarkupV1 {
/*
* based on \EventSauce\EventSourcing\Serialization\ConstructingMessageSerializer
*/
final class GdprMessageSerializer implements \EventSauce\EventSourcing\Serialization\MessageSerializer
{
// (...)
public function serializeMessage(Message $message): array
{
/** @var SerializablePayload $event */
$event = $message->payload();
// GDPR
$personalData = [];
$hasEventPersonalData = false;
// simplefied for readablity: this can not resolve nested objects or iterables.
foreach (get_object_vars($event) as $property => $value) {
$reflect = new ReflectionProperty($event::class, $property);
$attributes = $reflect->getAttributes(GDPR::class);
if ($attributes !== []) {
$hasEventPersonalData = true;
$personalData[$property] = '[REDACTED]';
}
}
if ($hasEventPersonalData) {
$redactedEvent = $event->fromPayload(array_merge($event->toPayload(), $personalData));
// persist personal data
$this->personalDataRepository->save($headers[Header::EVENT_ID], $personalData);
}
// (...)
}
}
} And to reconstitute an aggregate: namespace MarkupV1 {
/*
* based on \EventSauce\EventSourcing\Serialization\ConstructingMessageSerializer
*/
final class GdprMessageSerializer implements \EventSauce\EventSourcing\Serialization\MessageSerializer
{
// (...)
public function unserializePayload(array $payload): Message
{
// (...)
try {
$personalData = $this->personalDataRepository->get($payload['headers'][Header::EVENT_ID]);
$eventAsArray = array_merge($event->toPayload(), $personalData);
$event = $event->fromPayload($eventAsArray);
} catch (PersonalDataNotFound) {
// NOP
}
return new Message($event, $payload['headers']);
}
}
} This mockup reveals multiple problems:
Is this a good idea? Could this be done within the library itself? |
Beta Was this translation helpful? Give feedback.
Replies: 4 comments
-
Update I wrapped the namespace MockupV2
{
final class GdprMessageSerializer implements MessageSerializer
{
public function __construct(
private readonly ConstructingMessageSerializer $eventSauceMessageSerializer,
private readonly GdprRepository $gdprRepository,
private readonly GdprAttributeReader $gdprAttributeReader,
){}
public function serializeMessage(Message $message): array
{
$payload = $this->eventSauceMessageSerializer->serializeMessage($message);
$payload['headers'][Header::EVENT_ID] ??= Uuid::uuid4()->toString();
$messageAnalyseResult = $this->gdprAttributeReader->analyse($message);
if ($messageAnalyseResult->hasPersonalData()) {
// persist personal data
$personalData = $messageAnalyseResult->personalData();
$eventId = $payload['headers'][Header::EVENT_ID];
$this->gdprRepository->save($eventId, $personalData);
// redact payload
$redactedData = $messageAnalyseResult->redactedData();
// TODO
$payloadAsJson = json_encode($payload['payload'], flags: JSON_THROW_ON_ERROR);
$payloadAsArray = json_decode($payloadAsJson, true);
$payload['payload'] = array_replace_recursive($payloadAsArray, $redactedData);
}
return $payload;
}
public function unserializePayload(array $payloadAsArray): Message
{
// get personal data or empty array
$eventId = (string)@$payloadAsArray['headers'][Header::EVENT_ID];
$personalData = $this->gdprRepository->find($eventId);
// restore personal data
$originalPayload = $payloadAsArray['payload'];
$payloadAsArray['payload'] = array_replace_recursive($originalPayload, $personalData);
return $this->eventSauceMessageSerializer->unserializePayload($payloadAsArray);
}
}
} And added a namespace MockupV2
{
final class MessageAnalyseResult
{
public function __construct(
private readonly bool $hasPersonalData,
private readonly array $personalData,
private readonly array $redactedData,
){}
public function hasPersonalData(): bool
{
return $this->hasPersonalData;
}
public function personalData(): array
{
return $this->personalData;
}
public function redactedData(): array
{
return $this->redactedData;
}
}
} Then I tried to resolve all (nested) properties and check, if they have the namespace MockupV2
{
final class GdprAttributeReader
{
public function analyse(Message $message): MessageAnalyseResult
{
/** @var SerializablePayload $event */
$event = $message->payload();
[$redactedData, $personalData] = $this->readPropertiesWithGdprAttribute($event);
$hasPersonalData = $personalData !== [];
return new MessageAnalyseResult($hasPersonalData, $personalData, $redactedData);
}
// oh god...
private function readPropertiesWithGdprAttribute(
$object,
?string $parentProperty = null,
array $redactedData = [],
array $personalData = [],
bool $isIterable = false,
mixed $key = null,
): array
{
if (is_iterable($object)) {
foreach ($object as $key => $item) {
// recursive
[$redactedData, $personalData] = $this->readPropertiesWithGdprAttribute(
object: $item,
parentProperty: $parentProperty,
redactedData: $redactedData,
personalData: $personalData,
isIterable: true,
key: $key
);
}
}
if (!is_object($object)) {
return [$redactedData, $personalData];
}
foreach (get_object_vars($object) as $property => $value) {
$reflect = new ReflectionProperty($object::class, $property);
$attributes = $reflect->getAttributes(GDPR::class);
if ($attributes !== []) {
if (!$isIterable) {
$redactedData[$parentProperty][$property] = '[REDACTED]';
$personalData[$parentProperty][$property] = $value;
} else {
$redactedData[$parentProperty][$key][$property] = '[REDACTED]';
$personalData[$parentProperty][$key][$property] = $value;
}
}
// recursive
[$redactedData, $personalData] = $this->readPropertiesWithGdprAttribute(
object: $value,
parentProperty: $property,
redactedData: $redactedData,
personalData: $personalData,
isIterable: $personalData
);
}
return [$redactedData, $personalData];
}
}
} |
Beta Was this translation helpful? Give feedback.
-
An implementation option can be to embrace GDPR in the domain itself rather than hiding it away in technical concerns (repositories serializers, etc). This would consist of passing the GDPR compliant storage to the actions where PII is collected, received, or generated. In a bit of pseudo-code, this would look like this: $aggregateRoot = $repository->retrieve($id);
$aggregateRoot->performAction($gdrpComplicantStorage);
function performAction(GdprStorage $gdprStorage, PersonalIdentifiableInformation $pii)
{
$storageId = $gdprStorage->storePii([
'key' => $pii->value(),
]);
$this->recordThat(new PiiWasCollected($storageId));
} This leaves the PII out of the information flow of events. The domain can deal with storage, retrieving, an having forgotten PIIl. |
Beta Was this translation helpful? Give feedback.
-
Thank you, that is an interesting approach! Does this mean you would have a one In your example, how would the I tried to flesh it out a bit - this is the storage interface: interface GdprStorageInterface
{
public function save(PersonalIdentifiableInformation $pii);
public function getOrCreate(string $aggregateId): PersonalIdentifiableInformation;
public function delete(string $aggregateId): void;
} A simple DTO for the moment: final class PersonalIdentifiableInformation
{
public function __construct(
public readonly string $aggregateId,
public ?string $socialSecurityNumber = null,
public ?string $emailAddress = null,
){}
} The aggregate: final class UserAggregate
{
private string $aggregateId;
private bool $isSubscribedToNewsletter = false;
public function subscribeToNewsletter(GdprStorageInterface $storage, string $emailAddress): void
{
// ensure user is currently not subscribed
assert(!$this->isSubscribedToNewsletter);
// update PII
$pii = $storage->getOrCreate($this->aggregateId);
$pii->emailAddress = $emailAddress;
$storage->save($pii);
$this->recordThat(new UserWasSubscribedToNewsletter($emailAddress));
}
public function applyUserWasSubscribedToNewsletter(UserWasSubscribedToNewsletter $event): void
{
$this->isSubscribedToNewsletter = true;
}
} Deleting PII looks like this: final class UserAggregate
{
// (...)
public function deleteEmailAddress(GdprStorageInterface $storage): void
{
// update PII
$pii = $storage->getOrCreate($this->aggregateId);
$emailAddress = $pii->emailAddress;
$pii->emailAddress = null;
$storage->save($pii);
$this->recordThat(new UserEmailAddressWasDeleted($emailAddress));
}
public function applyUserEmailAddressWasDeleted(UserEmailAddressWasDeleted $event): void
{
$this->isSubscribedToNewsletter = false;
}
public function deleteAccount(GdprStorageInterface $storage): void
{
// delete PII
$storage->delete($this->aggregateId);
$this->recordThat(new UserAccountWasScheduledForDeletion());
}
public function applyUserAccountWasScheduledForDeletion(UserAccountWasScheduledForDeletion $event): void
{
$this->isSubscribedToNewsletter = false;
}
} I think, most methods would require the final class UserAggregate
{
// (...)
public function abusingWriteModelForReads(GdprStorageInterface $storage): ExampleUserViewModel
{
$pii = $storage->getOrCreate($this->aggregateId);
return new ExampleUserViewModel(
// $this
$this->aggregateId,
$this->isSubscribedToNewsletter,
// PII
$pii->emailAddress,
$pii->socialSecurityNumber
);
}
} |
Beta Was this translation helpful? Give feedback.
-
To answer this question: Yes, GDPR-compliance can be archived with one of these options:
Summary
Additional considerations
(I'll mark this question as "solved" just to get it out of the way. |
Beta Was this translation helpful? Give feedback.
To answer this question:
Yes, GDPR-compliance can be archived with one of these options: