From 1b31b53443b22ed5a0c66ecfff2ebbcd22ab3b9e Mon Sep 17 00:00:00 2001 From: David Badura Date: Tue, 19 Mar 2024 15:52:02 +0100 Subject: [PATCH] add event payload cryptographer for personal data --- baseline.xml | 57 ++- composer.lock | 335 +++++++++--------- deptrac.yaml | 8 + docs/mkdocs.yml | 1 + docs/pages/personal_data.md | 243 +++++++++++++ phpstan-baseline.neon | 25 ++ src/Attribute/DataSubjectId.php | 12 + src/Attribute/PersonalData.php | 16 + src/Cryptography/Cipher/Cipher.php | 14 + src/Cryptography/Cipher/CipherKey.php | 20 ++ src/Cryptography/Cipher/CipherKeyFactory.php | 11 + .../Cipher/CreateCipherKeyFailed.php | 15 + src/Cryptography/Cipher/DecryptionFailed.php | 15 + src/Cryptography/Cipher/EncryptionFailed.php | 15 + .../Cipher/MethodNotSupported.php | 17 + src/Cryptography/Cipher/OpensslCipher.php | 67 ++++ .../Cipher/OpensslCipherKeyFactory.php | 65 ++++ .../DefaultEventPayloadCryptographer.php | 145 ++++++++ .../EventPayloadCryptographer.php | 24 ++ src/Cryptography/MissingSubjectId.php | 15 + src/Cryptography/Store/CipherKeyNotExists.php | 17 + src/Cryptography/Store/CipherKeyStore.php | 17 + .../Store/DoctrineCipherKeyStore.php | 105 ++++++ .../Store/InMemoryCipherKeyStore.php | 33 ++ src/Cryptography/UnsupportedSubjectId.php | 18 + .../Event/AttributeEventMetadataFactory.php | 88 +++++ .../Event/DataSubjectIdIsPersonalData.php | 24 ++ src/Metadata/Event/EventMetadata.php | 3 + src/Metadata/Event/MissingDataSubjectId.php | 20 ++ src/Metadata/Event/MultipleDataSubjectId.php | 23 ++ src/Metadata/Event/PropertyMetadata.php | 16 + src/Serializer/CryptographicHydrator.php | 40 +++ src/Serializer/DefaultEventSerializer.php | 16 +- .../Events/EmailChanged.php | 24 ++ .../Events/ProfileCreated.php | 5 + .../{Aggregate => }/Profile.php | 27 +- tests/Benchmark/PersonalDataBench.php | 128 +++++++ tests/Benchmark/SimpleSetupBench.php | 2 +- tests/Benchmark/SnapshotsBench.php | 2 +- tests/Benchmark/SplitStreamBench.php | 2 +- tests/Benchmark/SubscriptionEngineBench.php | 2 +- tests/Benchmark/blackfire.php | 2 +- .../PersonalData/Events/NameChanged.php | 24 ++ .../Events/PersonalDataRemoved.php | 12 + .../PersonalData/Events/ProfileCreated.php | 24 ++ .../PersonalData/PersonalDataTest.php | 188 ++++++++++ .../Processor/DeletePersonalDataProcessor.php | 29 ++ tests/Integration/PersonalData/Profile.php | 68 ++++ tests/Integration/PersonalData/ProfileId.php | 25 ++ .../Cipher/CreateCipherKeyFailedTest.php | 19 + .../Cipher/DecryptionFailedTest.php | 19 + .../Cipher/EncryptionFailedTest.php | 19 + .../Cipher/OpensslCipherKeyFactoryTest.php | 33 ++ .../Cryptography/Cipher/OpensslCipherTest.php | 78 ++++ .../DefaultEventPayloadCryptographerTest.php | 274 ++++++++++++++ .../Cryptography/MissingSubjectIdTest.php | 19 + .../Store/CipherKeyNotExistsTest.php | 19 + .../Store/InMemoryCipherKeyStoreTest.php | 76 ++++ .../Cryptography/UnsupportedSubjectIdTest.php | 19 + tests/Unit/Fixture/EmailChanged.php | 21 ++ .../AttributeEventMetadataFactoryTest.php | 114 ++++++ .../Serializer/CryptographicHydratorTest.php | 74 ++++ 62 files changed, 2706 insertions(+), 182 deletions(-) create mode 100644 docs/pages/personal_data.md create mode 100644 src/Attribute/DataSubjectId.php create mode 100644 src/Attribute/PersonalData.php create mode 100644 src/Cryptography/Cipher/Cipher.php create mode 100644 src/Cryptography/Cipher/CipherKey.php create mode 100644 src/Cryptography/Cipher/CipherKeyFactory.php create mode 100644 src/Cryptography/Cipher/CreateCipherKeyFailed.php create mode 100644 src/Cryptography/Cipher/DecryptionFailed.php create mode 100644 src/Cryptography/Cipher/EncryptionFailed.php create mode 100644 src/Cryptography/Cipher/MethodNotSupported.php create mode 100644 src/Cryptography/Cipher/OpensslCipher.php create mode 100644 src/Cryptography/Cipher/OpensslCipherKeyFactory.php create mode 100644 src/Cryptography/DefaultEventPayloadCryptographer.php create mode 100644 src/Cryptography/EventPayloadCryptographer.php create mode 100644 src/Cryptography/MissingSubjectId.php create mode 100644 src/Cryptography/Store/CipherKeyNotExists.php create mode 100644 src/Cryptography/Store/CipherKeyStore.php create mode 100644 src/Cryptography/Store/DoctrineCipherKeyStore.php create mode 100644 src/Cryptography/Store/InMemoryCipherKeyStore.php create mode 100644 src/Cryptography/UnsupportedSubjectId.php create mode 100644 src/Metadata/Event/DataSubjectIdIsPersonalData.php create mode 100644 src/Metadata/Event/MissingDataSubjectId.php create mode 100644 src/Metadata/Event/MultipleDataSubjectId.php create mode 100644 src/Metadata/Event/PropertyMetadata.php create mode 100644 src/Serializer/CryptographicHydrator.php create mode 100644 tests/Benchmark/BasicImplementation/Events/EmailChanged.php rename tests/Benchmark/BasicImplementation/{Aggregate => }/Profile.php (73%) create mode 100644 tests/Benchmark/PersonalDataBench.php create mode 100644 tests/Integration/PersonalData/Events/NameChanged.php create mode 100644 tests/Integration/PersonalData/Events/PersonalDataRemoved.php create mode 100644 tests/Integration/PersonalData/Events/ProfileCreated.php create mode 100644 tests/Integration/PersonalData/PersonalDataTest.php create mode 100644 tests/Integration/PersonalData/Processor/DeletePersonalDataProcessor.php create mode 100644 tests/Integration/PersonalData/Profile.php create mode 100644 tests/Integration/PersonalData/ProfileId.php create mode 100644 tests/Unit/Cryptography/Cipher/CreateCipherKeyFailedTest.php create mode 100644 tests/Unit/Cryptography/Cipher/DecryptionFailedTest.php create mode 100644 tests/Unit/Cryptography/Cipher/EncryptionFailedTest.php create mode 100644 tests/Unit/Cryptography/Cipher/OpensslCipherKeyFactoryTest.php create mode 100644 tests/Unit/Cryptography/Cipher/OpensslCipherTest.php create mode 100644 tests/Unit/Cryptography/DefaultEventPayloadCryptographerTest.php create mode 100644 tests/Unit/Cryptography/MissingSubjectIdTest.php create mode 100644 tests/Unit/Cryptography/Store/CipherKeyNotExistsTest.php create mode 100644 tests/Unit/Cryptography/Store/InMemoryCipherKeyStoreTest.php create mode 100644 tests/Unit/Cryptography/UnsupportedSubjectIdTest.php create mode 100644 tests/Unit/Fixture/EmailChanged.php create mode 100644 tests/Unit/Serializer/CryptographicHydratorTest.php diff --git a/baseline.xml b/baseline.xml index 617141b91..bc73302a6 100644 --- a/baseline.xml +++ b/baseline.xml @@ -1,5 +1,5 @@ - + @@ -15,6 +15,34 @@ + + + ivLength)]]> + keyLength)]]> + + + + + + ]]> + + + + + fieldName]]]> + + + fieldName]]]> + fieldName]]]> + fieldName]]]> + + + + + + + + getName()]]> @@ -88,12 +116,21 @@ - + + + + + + + + + + @@ -145,6 +182,17 @@ + + + + + + + + + + + @@ -164,6 +212,11 @@ + + + + + diff --git a/composer.lock b/composer.lock index 861d65f5b..ab41a19c5 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "0c745538b704294d92dfee9c2b9d6323", + "content-hash": "8e8c962c4e598466f0fca1e88c81ef9c", "packages": [ { "name": "brick/math", @@ -1033,34 +1033,33 @@ "shasum": "" }, "require": { - "php": ">=8.1", - "symfony/deprecation-contracts": "^2.5|^3", + "php": ">=8.2", "symfony/polyfill-mbstring": "~1.0", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^5.4|^6.0|^7.0" + "symfony/string": "^6.4|^7.0" }, "conflict": { - "symfony/dependency-injection": "<5.4", - "symfony/dotenv": "<5.4", - "symfony/event-dispatcher": "<5.4", - "symfony/lock": "<5.4", - "symfony/process": "<5.4" + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" }, "provide": { "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^5.4|^6.0|^7.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/event-dispatcher": "^5.4|^6.0|^7.0", + "symfony/config": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/event-dispatcher": "^6.4|^7.0", "symfony/http-foundation": "^6.4|^7.0", "symfony/http-kernel": "^6.4|^7.0", - "symfony/lock": "^5.4|^6.0|^7.0", - "symfony/messenger": "^5.4|^6.0|^7.0", - "symfony/process": "^5.4|^6.0|^7.0", - "symfony/stopwatch": "^5.4|^6.0|^7.0", - "symfony/var-dumper": "^5.4|^6.0|^7.0" + "symfony/lock": "^6.4|^7.0", + "symfony/messenger": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0", + "symfony/stopwatch": "^6.4|^7.0", + "symfony/var-dumper": "^6.4|^7.0" }, "type": "library", "autoload": { @@ -1112,93 +1111,26 @@ ], "time": "2024-02-22T20:27:20+00:00" }, - { - "name": "symfony/deprecation-contracts", - "version": "v3.4.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "7c3aff79d10325257a001fcf92d991f24fc967cf" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/7c3aff79d10325257a001fcf92d991f24fc967cf", - "reference": "7c3aff79d10325257a001fcf92d991f24fc967cf", - "shasum": "" - }, - "require": { - "php": ">=8.1" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "3.4-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" - } - }, - "autoload": { - "files": [ - "function.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "A generic function and convention to trigger deprecation notices", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.4.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2023-05-23T14:45:45+00:00" - }, { "name": "symfony/event-dispatcher", - "version": "v6.4.3", + "version": "v7.0.3", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "ae9d3a6f3003a6caf56acd7466d8d52378d44fef" + "reference": "834c28d533dd0636f910909d01b9ff45cc094b5e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/ae9d3a6f3003a6caf56acd7466d8d52378d44fef", - "reference": "ae9d3a6f3003a6caf56acd7466d8d52378d44fef", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/834c28d533dd0636f910909d01b9ff45cc094b5e", + "reference": "834c28d533dd0636f910909d01b9ff45cc094b5e", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/event-dispatcher-contracts": "^2.5|^3" }, "conflict": { - "symfony/dependency-injection": "<5.4", + "symfony/dependency-injection": "<6.4", "symfony/service-contracts": "<2.5" }, "provide": { @@ -1207,13 +1139,13 @@ }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^5.4|^6.0|^7.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/error-handler": "^5.4|^6.0|^7.0", - "symfony/expression-language": "^5.4|^6.0|^7.0", - "symfony/http-foundation": "^5.4|^6.0|^7.0", + "symfony/config": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/error-handler": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0", "symfony/service-contracts": "^2.5|^3", - "symfony/stopwatch": "^5.4|^6.0|^7.0" + "symfony/stopwatch": "^6.4|^7.0" }, "type": "library", "autoload": { @@ -1241,7 +1173,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v6.4.3" + "source": "https://github.com/symfony/event-dispatcher/tree/v7.0.3" }, "funding": [ { @@ -1257,7 +1189,7 @@ "type": "tidelift" } ], - "time": "2024-01-23T14:51:35+00:00" + "time": "2024-01-23T15:02:46+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -1337,23 +1269,23 @@ }, { "name": "symfony/finder", - "version": "v6.4.0", + "version": "v7.0.0", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "11d736e97f116ac375a81f96e662911a34cd50ce" + "reference": "6e5688d69f7cfc4ed4a511e96007e06c2d34ce56" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/11d736e97f116ac375a81f96e662911a34cd50ce", - "reference": "11d736e97f116ac375a81f96e662911a34cd50ce", + "url": "https://api.github.com/repos/symfony/finder/zipball/6e5688d69f7cfc4ed4a511e96007e06c2d34ce56", + "reference": "6e5688d69f7cfc4ed4a511e96007e06c2d34ce56", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { - "symfony/filesystem": "^6.0|^7.0" + "symfony/filesystem": "^6.4|^7.0" }, "type": "library", "autoload": { @@ -1381,7 +1313,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v6.4.0" + "source": "https://github.com/symfony/finder/tree/v7.0.0" }, "funding": [ { @@ -1397,7 +1329,7 @@ "type": "tidelift" } ], - "time": "2023-10-31T17:30:12+00:00" + "time": "2023-10-31T17:59:56+00:00" }, { "name": "symfony/polyfill-ctype", @@ -2355,16 +2287,16 @@ }, { "name": "composer/pcre", - "version": "3.1.2", + "version": "3.1.3", "source": { "type": "git", "url": "https://github.com/composer/pcre.git", - "reference": "4775f35b2d70865807c89d32c8e7385b86eb0ace" + "reference": "5b16e25a5355f1f3afdfc2f954a0a80aec4826a8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/pcre/zipball/4775f35b2d70865807c89d32c8e7385b86eb0ace", - "reference": "4775f35b2d70865807c89d32c8e7385b86eb0ace", + "url": "https://api.github.com/repos/composer/pcre/zipball/5b16e25a5355f1f3afdfc2f954a0a80aec4826a8", + "reference": "5b16e25a5355f1f3afdfc2f954a0a80aec4826a8", "shasum": "" }, "require": { @@ -2406,7 +2338,7 @@ ], "support": { "issues": "https://github.com/composer/pcre/issues", - "source": "https://github.com/composer/pcre/tree/3.1.2" + "source": "https://github.com/composer/pcre/tree/3.1.3" }, "funding": [ { @@ -2422,7 +2354,7 @@ "type": "tidelift" } ], - "time": "2024-03-07T15:38:35+00:00" + "time": "2024-03-19T10:26:25+00:00" }, { "name": "composer/semver", @@ -3228,16 +3160,16 @@ }, { "name": "doctrine/persistence", - "version": "3.3.1", + "version": "3.3.2", "source": { "type": "git", "url": "https://github.com/doctrine/persistence.git", - "reference": "b6fd1f126b13c1f7e7321f7338b14a19116b5de4" + "reference": "477da35bd0255e032826f440b94b3e37f2d56f42" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/persistence/zipball/b6fd1f126b13c1f7e7321f7338b14a19116b5de4", - "reference": "b6fd1f126b13c1f7e7321f7338b14a19116b5de4", + "url": "https://api.github.com/repos/doctrine/persistence/zipball/477da35bd0255e032826f440b94b3e37f2d56f42", + "reference": "477da35bd0255e032826f440b94b3e37f2d56f42", "shasum": "" }, "require": { @@ -3306,7 +3238,7 @@ ], "support": { "issues": "https://github.com/doctrine/persistence/issues", - "source": "https://github.com/doctrine/persistence/tree/3.3.1" + "source": "https://github.com/doctrine/persistence/tree/3.3.2" }, "funding": [ { @@ -3322,7 +3254,7 @@ "type": "tidelift" } ], - "time": "2024-03-01T19:53:13+00:00" + "time": "2024-03-12T14:54:36+00:00" }, { "name": "felixfbecker/advanced-json-rpc", @@ -3977,21 +3909,21 @@ }, { "name": "nikic/php-parser", - "version": "v4.18.0", + "version": "v4.19.1", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "1bcbb2179f97633e98bbbc87044ee2611c7d7999" + "reference": "4e1b88d21c69391150ace211e9eaf05810858d0b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/1bcbb2179f97633e98bbbc87044ee2611c7d7999", - "reference": "1bcbb2179f97633e98bbbc87044ee2611c7d7999", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/4e1b88d21c69391150ace211e9eaf05810858d0b", + "reference": "4e1b88d21c69391150ace211e9eaf05810858d0b", "shasum": "" }, "require": { "ext-tokenizer": "*", - "php": ">=7.0" + "php": ">=7.1" }, "require-dev": { "ircmaxell/php-yacc": "^0.0.7", @@ -4027,35 +3959,35 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v4.18.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v4.19.1" }, - "time": "2023-12-10T21:03:43+00:00" + "time": "2024-03-17T08:10:35+00:00" }, { "name": "ondram/ci-detector", - "version": "4.1.0", + "version": "4.2.0", "source": { "type": "git", "url": "https://github.com/OndraM/ci-detector.git", - "reference": "8a4b664e916df82ff26a44709942dfd593fa6f30" + "reference": "8b0223b5ed235fd377c75fdd1bfcad05c0f168b8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/OndraM/ci-detector/zipball/8a4b664e916df82ff26a44709942dfd593fa6f30", - "reference": "8a4b664e916df82ff26a44709942dfd593fa6f30", + "url": "https://api.github.com/repos/OndraM/ci-detector/zipball/8b0223b5ed235fd377c75fdd1bfcad05c0f168b8", + "reference": "8b0223b5ed235fd377c75fdd1bfcad05c0f168b8", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" + "php": "^7.4 || ^8.0" }, "require-dev": { - "ergebnis/composer-normalize": "^2.2", - "lmc/coding-standard": "^1.3 || ^2.1", + "ergebnis/composer-normalize": "^2.13.2", + "lmc/coding-standard": "^3.0.0", "php-parallel-lint/php-parallel-lint": "^1.2", - "phpstan/extension-installer": "^1.0.5", - "phpstan/phpstan": "^0.12.58", - "phpstan/phpstan-phpunit": "^0.12.16", - "phpunit/phpunit": "^7.1 || ^8.0 || ^9.0" + "phpstan/extension-installer": "^1.1.0", + "phpstan/phpstan": "^1.2.0", + "phpstan/phpstan-phpunit": "^1.0.0", + "phpunit/phpunit": "^9.6.13" }, "type": "library", "autoload": { @@ -4105,9 +4037,9 @@ ], "support": { "issues": "https://github.com/OndraM/ci-detector/issues", - "source": "https://github.com/OndraM/ci-detector/tree/4.1.0" + "source": "https://github.com/OndraM/ci-detector/tree/4.2.0" }, - "time": "2021-04-14T09:16:52+00:00" + "time": "2024-03-12T13:22:30+00:00" }, { "name": "patchlevel/coding-standard", @@ -4876,16 +4808,16 @@ }, { "name": "phpstan/phpstan", - "version": "1.10.60", + "version": "1.10.63", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "95dcea7d6c628a3f2f56d091d8a0219485a86bbe" + "reference": "ad12836d9ca227301f5fb9960979574ed8628339" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/95dcea7d6c628a3f2f56d091d8a0219485a86bbe", - "reference": "95dcea7d6c628a3f2f56d091d8a0219485a86bbe", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/ad12836d9ca227301f5fb9960979574ed8628339", + "reference": "ad12836d9ca227301f5fb9960979574ed8628339", "shasum": "" }, "require": { @@ -4934,20 +4866,20 @@ "type": "tidelift" } ], - "time": "2024-03-07T13:30:19+00:00" + "time": "2024-03-18T16:53:53+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "10.1.13", + "version": "10.1.14", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "d51c3aec14896d5e80b354fad58e998d1980f8f8" + "reference": "e3f51450ebffe8e0efdf7346ae966a656f7d5e5b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/d51c3aec14896d5e80b354fad58e998d1980f8f8", - "reference": "d51c3aec14896d5e80b354fad58e998d1980f8f8", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/e3f51450ebffe8e0efdf7346ae966a656f7d5e5b", + "reference": "e3f51450ebffe8e0efdf7346ae966a656f7d5e5b", "shasum": "" }, "require": { @@ -5004,7 +4936,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/10.1.13" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/10.1.14" }, "funding": [ { @@ -5012,7 +4944,7 @@ "type": "github" } ], - "time": "2024-03-09T16:54:15+00:00" + "time": "2024-03-12T15:33:41+00:00" }, { "name": "phpunit/php-file-iterator", @@ -5259,16 +5191,16 @@ }, { "name": "phpunit/phpunit", - "version": "10.5.12", + "version": "10.5.13", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "41a9886b85ac7bf3929853baf96b95361cd69d2b" + "reference": "20a63fc1c6db29b15da3bd02d4b6cf59900088a7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/41a9886b85ac7bf3929853baf96b95361cd69d2b", - "reference": "41a9886b85ac7bf3929853baf96b95361cd69d2b", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/20a63fc1c6db29b15da3bd02d4b6cf59900088a7", + "reference": "20a63fc1c6db29b15da3bd02d4b6cf59900088a7", "shasum": "" }, "require": { @@ -5340,7 +5272,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.12" + "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.13" }, "funding": [ { @@ -5356,7 +5288,7 @@ "type": "tidelift" } ], - "time": "2024-03-09T12:04:07+00:00" + "time": "2024-03-12T15:37:41+00:00" }, { "name": "psalm/plugin-phpunit", @@ -5535,16 +5467,16 @@ }, { "name": "sanmai/pipeline", - "version": "v6.9", + "version": "v6.10", "source": { "type": "git", "url": "https://github.com/sanmai/pipeline.git", - "reference": "c48f45c22c3ce4140d071f7658fb151df1cc08ea" + "reference": "cbd2ea30ba8bef596b8dad1adb9c92fb2987e430" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sanmai/pipeline/zipball/c48f45c22c3ce4140d071f7658fb151df1cc08ea", - "reference": "c48f45c22c3ce4140d071f7658fb151df1cc08ea", + "url": "https://api.github.com/repos/sanmai/pipeline/zipball/cbd2ea30ba8bef596b8dad1adb9c92fb2987e430", + "reference": "cbd2ea30ba8bef596b8dad1adb9c92fb2987e430", "shasum": "" }, "require": { @@ -5588,7 +5520,7 @@ "description": "General-purpose collections pipeline", "support": { "issues": "https://github.com/sanmai/pipeline/issues", - "source": "https://github.com/sanmai/pipeline/tree/v6.9" + "source": "https://github.com/sanmai/pipeline/tree/v6.10" }, "funding": [ { @@ -5596,7 +5528,7 @@ "type": "github" } ], - "time": "2023-10-08T11:56:54+00:00" + "time": "2024-03-16T01:33:30+00:00" }, { "name": "sebastian/cli-parser", @@ -6860,22 +6792,89 @@ ], "time": "2024-03-02T12:46:12+00:00" }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.4.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "7c3aff79d10325257a001fcf92d991f24fc967cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/7c3aff79d10325257a001fcf92d991f24fc967cf", + "reference": "7c3aff79d10325257a001fcf92d991f24fc967cf", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.4-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.4.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-05-23T14:45:45+00:00" + }, { "name": "symfony/filesystem", - "version": "v6.4.3", + "version": "v7.0.3", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "7f3b1755eb49297a0827a7575d5d2b2fd11cc9fb" + "reference": "2890e3a825bc0c0558526c04499c13f83e1b6b12" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/7f3b1755eb49297a0827a7575d5d2b2fd11cc9fb", - "reference": "7f3b1755eb49297a0827a7575d5d2b2fd11cc9fb", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/2890e3a825bc0c0558526c04499c13f83e1b6b12", + "reference": "2890e3a825bc0c0558526c04499c13f83e1b6b12", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/polyfill-ctype": "~1.8", "symfony/polyfill-mbstring": "~1.8" }, @@ -6905,7 +6904,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v6.4.3" + "source": "https://github.com/symfony/filesystem/tree/v7.0.3" }, "funding": [ { @@ -6921,7 +6920,7 @@ "type": "tidelift" } ], - "time": "2024-01-23T14:51:35+00:00" + "time": "2024-01-23T15:02:46+00:00" }, { "name": "symfony/messenger", diff --git a/deptrac.yaml b/deptrac.yaml index 5798c3dc6..40872af5b 100644 --- a/deptrac.yaml +++ b/deptrac.yaml @@ -21,6 +21,10 @@ deptrac: collectors: - type: directory value: src/Console/.* + - name: Cryptography + collectors: + - type: directory + value: src/Cryptography/.* - name: Debug collectors: - type: directory @@ -108,6 +112,9 @@ deptrac: - Serializer - Store - Subscription + Cryptography: + - MetadataEvent + - Schema Debug: - Attribute - Message @@ -159,6 +166,7 @@ deptrac: Schema: Serializer: - Aggregate + - Cryptography - MetadataEvent Snapshot: - Aggregate diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index ebe9a619a..66ba1427a 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -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 diff --git a/docs/pages/personal_data.md b/docs/pages/personal_data.md new file mode 100644 index 000000000..5531307fd --- /dev/null +++ b/docs/pages/personal_data.md @@ -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); + } +} +``` diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index e97903bba..3cec5d190 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -5,6 +5,31 @@ parameters: count: 1 path: src/Console/DoctrineHelper.php + - + message: "#^Parameter \\#1 \\$key of class Patchlevel\\\\EventSourcing\\\\Cryptography\\\\Cipher\\\\CipherKey constructor expects non\\-empty\\-string, string given\\.$#" + count: 1 + path: src/Cryptography/Cipher/OpensslCipherKeyFactory.php + + - + message: "#^Parameter \\#3 \\$iv of class Patchlevel\\\\EventSourcing\\\\Cryptography\\\\Cipher\\\\CipherKey constructor expects non\\-empty\\-string, string given\\.$#" + count: 1 + path: src/Cryptography/Cipher/OpensslCipherKeyFactory.php + + - + message: "#^Parameter \\#2 \\$data of method Patchlevel\\\\EventSourcing\\\\Cryptography\\\\Cipher\\\\Cipher\\:\\:decrypt\\(\\) expects string, mixed given\\.$#" + count: 1 + path: src/Cryptography/DefaultEventPayloadCryptographer.php + + - + message: "#^Parameter \\#1 \\$key of class Patchlevel\\\\EventSourcing\\\\Cryptography\\\\Cipher\\\\CipherKey constructor expects non\\-empty\\-string, string given\\.$#" + count: 1 + path: src/Cryptography/Store/DoctrineCipherKeyStore.php + + - + message: "#^Parameter \\#3 \\$iv of class Patchlevel\\\\EventSourcing\\\\Cryptography\\\\Cipher\\\\CipherKey constructor expects non\\-empty\\-string, string given\\.$#" + count: 1 + path: src/Cryptography/Store/DoctrineCipherKeyStore.php + - message: "#^Parameter \\#2 \\$data of method Patchlevel\\\\Hydrator\\\\Hydrator\\:\\:hydrate\\(\\) expects array\\, mixed given\\.$#" count: 1 diff --git a/src/Attribute/DataSubjectId.php b/src/Attribute/DataSubjectId.php new file mode 100644 index 000000000..e41005217 --- /dev/null +++ b/src/Attribute/DataSubjectId.php @@ -0,0 +1,12 @@ +dataEncode($data), + $key->method, + $key->key, + 0, + $key->iv, + ); + + if ($encryptedData === false) { + throw new EncryptionFailed(); + } + + return base64_encode($encryptedData); + } + + public function decrypt(CipherKey $key, string $data): mixed + { + $data = @openssl_decrypt( + base64_decode($data), + $key->method, + $key->key, + 0, + $key->iv, + ); + + if ($data === false) { + throw new DecryptionFailed(); + } + + try { + return $this->dataDecode($data); + } catch (JsonException) { + throw new DecryptionFailed(); + } + } + + private function dataEncode(mixed $data): string + { + return json_encode($data, JSON_THROW_ON_ERROR); + } + + private function dataDecode(string $data): mixed + { + return json_decode($data, true, 512, JSON_THROW_ON_ERROR); + } +} diff --git a/src/Cryptography/Cipher/OpensslCipherKeyFactory.php b/src/Cryptography/Cipher/OpensslCipherKeyFactory.php new file mode 100644 index 000000000..9f6bf091f --- /dev/null +++ b/src/Cryptography/Cipher/OpensslCipherKeyFactory.php @@ -0,0 +1,65 @@ +method)) { + throw new MethodNotSupported($this->method); + } + + $keyLength = 16; + + if (function_exists('openssl_cipher_key_length')) { + $keyLength = @openssl_cipher_key_length($this->method); + } + + $ivLength = @openssl_cipher_iv_length($this->method); + + if ($keyLength === false || $ivLength === false) { + throw new MethodNotSupported($this->method); + } + + $this->keyLength = $keyLength; + $this->ivLength = $ivLength; + } + + public function __invoke(): CipherKey + { + return new CipherKey( + openssl_random_pseudo_bytes($this->keyLength), + $this->method, + openssl_random_pseudo_bytes($this->ivLength), + ); + } + + /** @return list */ + public static function supportedMethods(): array + { + return openssl_get_cipher_methods(true); + } + + public static function methodSupported(string $method): bool + { + return in_array($method, self::supportedMethods(), true); + } +} diff --git a/src/Cryptography/DefaultEventPayloadCryptographer.php b/src/Cryptography/DefaultEventPayloadCryptographer.php new file mode 100644 index 000000000..6f20ad7ab --- /dev/null +++ b/src/Cryptography/DefaultEventPayloadCryptographer.php @@ -0,0 +1,145 @@ + $data + * + * @return array + */ + public function encrypt(string $class, array $data): array + { + $subjectId = $this->subjectId($class, $data); + + if ($subjectId === null) { + return $data; + } + + try { + $cipherKey = $this->cipherKeyStore->get($subjectId); + } catch (CipherKeyNotExists) { + $cipherKey = ($this->cipherKeyFactory)(); + $this->cipherKeyStore->store($subjectId, $cipherKey); + } + + $metadata = $this->eventMetadataFactory->metadata($class); + + foreach ($metadata->propertyMetadata as $propertyMetadata) { + if (!$propertyMetadata->isPersonalData) { + continue; + } + + $data[$propertyMetadata->fieldName] = $this->cipher->encrypt( + $cipherKey, + $data[$propertyMetadata->fieldName], + ); + } + + return $data; + } + + /** + * @param class-string $class + * @param array $data + * + * @return array + */ + public function decrypt(string $class, array $data): array + { + $subjectId = $this->subjectId($class, $data); + + if ($subjectId === null) { + return $data; + } + + try { + $cipherKey = $this->cipherKeyStore->get($subjectId); + } catch (CipherKeyNotExists) { + $cipherKey = null; + } + + $metadata = $this->eventMetadataFactory->metadata($class); + + foreach ($metadata->propertyMetadata as $propertyMetadata) { + if (!$propertyMetadata->isPersonalData) { + continue; + } + + if (!$cipherKey) { + $data[$propertyMetadata->fieldName] = $propertyMetadata->personalDataFallback; + continue; + } + + try { + $data[$propertyMetadata->fieldName] = $this->cipher->decrypt( + $cipherKey, + $data[$propertyMetadata->fieldName], + ); + } catch (DecryptionFailed) { + $data[$propertyMetadata->fieldName] = $propertyMetadata->personalDataFallback; + } + } + + return $data; + } + + /** + * @param class-string $class + * @param array $data + */ + private function subjectId(string $class, array $data): string|null + { + $metadata = $this->eventMetadataFactory->metadata($class); + + if ($metadata->dataSubjectIdField === null) { + return null; + } + + if (!array_key_exists($metadata->dataSubjectIdField, $data)) { + throw new MissingSubjectId(); + } + + $subjectId = $data[$metadata->dataSubjectIdField]; + + if (!is_string($subjectId)) { + throw new UnsupportedSubjectId($subjectId); + } + + return $subjectId; + } + + public static function createWithOpenssl(EventMetadataFactory $eventMetadataFactory, CipherKeyStore $cryptoStore): static + { + return new self( + $eventMetadataFactory, + $cryptoStore, + new OpensslCipherKeyFactory(), + new OpensslCipher(), + ); + } +} diff --git a/src/Cryptography/EventPayloadCryptographer.php b/src/Cryptography/EventPayloadCryptographer.php new file mode 100644 index 000000000..ea1aa6569 --- /dev/null +++ b/src/Cryptography/EventPayloadCryptographer.php @@ -0,0 +1,24 @@ + $data + * + * @return array + */ + public function encrypt(string $class, array $data): array; + + /** + * @param class-string $class + * @param array $data + * + * @return array + */ + public function decrypt(string $class, array $data): array; +} diff --git a/src/Cryptography/MissingSubjectId.php b/src/Cryptography/MissingSubjectId.php new file mode 100644 index 000000000..37dc7c91e --- /dev/null +++ b/src/Cryptography/MissingSubjectId.php @@ -0,0 +1,15 @@ + */ + private array $keys = []; + + public function __construct( + private readonly Connection $connection, + private readonly string $tableName = 'crypto_keys', + ) { + } + + public function get(string $id): CipherKey + { + if (array_key_exists($id, $this->keys)) { + return $this->keys[$id]; + } + + /** @var Row|false $result */ + $result = $this->connection->fetchAssociative( + "SELECT * FROM {$this->tableName} WHERE subject_id = :subject_id", + ['subject_id' => $id], + ); + + if ($result === false) { + throw new CipherKeyNotExists($id); + } + + $this->keys[$id] = new CipherKey( + base64_decode($result['crypto_key']), + $result['crypto_method'], + base64_decode($result['crypto_iv']), + ); + + return $this->keys[$id]; + } + + public function store(string $id, CipherKey $key): void + { + $this->connection->insert($this->tableName, [ + 'subject_id' => $id, + 'crypto_key' => base64_encode($key->key), + 'crypto_method' => $key->method, + 'crypto_iv' => base64_encode($key->iv), + ]); + + $this->keys[$id] = $key; + } + + public function remove(string $id): void + { + $this->connection->delete($this->tableName, ['subject_id' => $id]); + + unset($this->keys[$id]); + } + + public function configureSchema(Schema $schema, Connection $connection): void + { + if ($connection !== $this->connection) { + return; + } + + $table = $schema->createTable($this->tableName); + $table->addColumn('subject_id', 'string') + ->setNotnull(true) + ->setLength(255); + $table->addColumn('crypto_key', 'string') + ->setNotnull(true) + ->setLength(255); + $table->addColumn('crypto_method', 'string') + ->setNotnull(true) + ->setLength(255); + $table->addColumn('crypto_iv', 'string') + ->setNotnull(true) + ->setLength(255); + $table->setPrimaryKey(['subject_id']); + } + + public function clear(): void + { + $this->keys = []; + } +} diff --git a/src/Cryptography/Store/InMemoryCipherKeyStore.php b/src/Cryptography/Store/InMemoryCipherKeyStore.php new file mode 100644 index 000000000..7bf8c5f3e --- /dev/null +++ b/src/Cryptography/Store/InMemoryCipherKeyStore.php @@ -0,0 +1,33 @@ + */ + private array $keys = []; + + public function get(string $id): CipherKey + { + return $this->keys[$id] ?? throw new CipherKeyNotExists($id); + } + + public function store(string $id, CipherKey $key): void + { + $this->keys[$id] = $key; + } + + public function remove(string $id): void + { + unset($this->keys[$id]); + } + + public function clear(): void + { + $this->keys = []; + } +} diff --git a/src/Cryptography/UnsupportedSubjectId.php b/src/Cryptography/UnsupportedSubjectId.php new file mode 100644 index 000000000..bd3da12c7 --- /dev/null +++ b/src/Cryptography/UnsupportedSubjectId.php @@ -0,0 +1,18 @@ +subjectIdField($reflectionClass); + + foreach ($reflectionClass->getProperties() as $reflectionProperty) { + $propertyMetadata = $this->propertyMetadata($reflectionProperty); + + if ($propertyMetadata->isPersonalData) { + if ($subjectId === $propertyMetadata->fieldName) { + throw new DataSubjectIdIsPersonalData($event, $propertyMetadata->fieldName); + } + + $hasPersonalData = true; + } + + $propertyMetadataList[$reflectionProperty->getName()] = $propertyMetadata; + } + + if ($hasPersonalData && $subjectId === null) { + throw new MissingDataSubjectId($event); + } + $eventAttribute = $attributeReflectionList[0]->newInstance(); $this->eventMetadata[$event] = new EventMetadata( $eventAttribute->name, $this->splitStream($reflectionClass), + $subjectId, + $propertyMetadataList, ); return $this->eventMetadata[$event]; @@ -45,4 +74,63 @@ private function splitStream(ReflectionClass $reflectionClass): bool { return count($reflectionClass->getAttributes(SplitStream::class)) !== 0; } + + private function propertyMetadata(ReflectionProperty $reflectionProperty): PropertyMetadata + { + $attributeReflectionList = $reflectionProperty->getAttributes(PersonalData::class); + + if (!$attributeReflectionList) { + return new PropertyMetadata( + $reflectionProperty->getName(), + $this->fieldName($reflectionProperty), + ); + } + + $attribute = $attributeReflectionList[0]->newInstance(); + + return new PropertyMetadata( + $reflectionProperty->getName(), + $this->fieldName($reflectionProperty), + true, + $attribute->fallback, + ); + } + + private function subjectIdField(ReflectionClass $reflectionClass): string|null + { + $property = null; + + foreach ($reflectionClass->getProperties() as $reflectionProperty) { + $attributeReflectionList = $reflectionProperty->getAttributes(DataSubjectId::class); + + if (!$attributeReflectionList) { + continue; + } + + if ($property !== null) { + throw new MultipleDataSubjectId($property->getName(), $reflectionProperty->getName()); + } + + $property = $reflectionProperty; + } + + if ($property === null) { + return null; + } + + return $this->fieldName($property); + } + + private function fieldName(ReflectionProperty $reflectionProperty): string + { + $attributeReflectionList = $reflectionProperty->getAttributes(NormalizedName::class); + + if (!$attributeReflectionList) { + return $reflectionProperty->getName(); + } + + $attribute = $attributeReflectionList[0]->newInstance(); + + return $attribute->name(); + } } diff --git a/src/Metadata/Event/DataSubjectIdIsPersonalData.php b/src/Metadata/Event/DataSubjectIdIsPersonalData.php new file mode 100644 index 000000000..9ff1b2957 --- /dev/null +++ b/src/Metadata/Event/DataSubjectIdIsPersonalData.php @@ -0,0 +1,24 @@ + */ + public readonly array $propertyMetadata = [], ) { } } diff --git a/src/Metadata/Event/MissingDataSubjectId.php b/src/Metadata/Event/MissingDataSubjectId.php new file mode 100644 index 000000000..d9b7051b8 --- /dev/null +++ b/src/Metadata/Event/MissingDataSubjectId.php @@ -0,0 +1,20 @@ + $class + * @param array $data + * + * @return T + * + * @template T of object + */ + public function hydrate(string $class, array $data): object + { + $data = $this->cryptographer->decrypt($class, $data); + + return $this->hydrator->hydrate($class, $data); + } + + /** @return array */ + public function extract(object $object): array + { + $data = $this->hydrator->extract($object); + + return $this->cryptographer->encrypt($object::class, $data); + } +} diff --git a/src/Serializer/DefaultEventSerializer.php b/src/Serializer/DefaultEventSerializer.php index a1d3f2a4c..e3e0fdbc8 100644 --- a/src/Serializer/DefaultEventSerializer.php +++ b/src/Serializer/DefaultEventSerializer.php @@ -4,6 +4,7 @@ namespace Patchlevel\EventSourcing\Serializer; +use Patchlevel\EventSourcing\Cryptography\EventPayloadCryptographer; use Patchlevel\EventSourcing\Metadata\Event\AttributeEventRegistryFactory; use Patchlevel\EventSourcing\Metadata\Event\EventRegistry; use Patchlevel\EventSourcing\Serializer\Encoder\Encoder; @@ -53,11 +54,20 @@ public function deserialize(SerializedEvent $data, array $options = []): object } /** @param list $paths */ - public static function createFromPaths(array $paths, Upcaster|null $upcaster = null): static - { + public static function createFromPaths( + array $paths, + Upcaster|null $upcaster = null, + EventPayloadCryptographer|null $cryptographer = null, + ): static { + $hydrator = new MetadataHydrator(); + + if ($cryptographer) { + $hydrator = new CryptographicHydrator($hydrator, $cryptographer); + } + return new self( (new AttributeEventRegistryFactory())->create($paths), - new MetadataHydrator(), + $hydrator, new JsonEncoder(), $upcaster, ); diff --git a/tests/Benchmark/BasicImplementation/Events/EmailChanged.php b/tests/Benchmark/BasicImplementation/Events/EmailChanged.php new file mode 100644 index 000000000..d0f642cf8 --- /dev/null +++ b/tests/Benchmark/BasicImplementation/Events/EmailChanged.php @@ -0,0 +1,24 @@ +recordThat(new ProfileCreated($id, $name)); + $self->recordThat(new ProfileCreated($id, $name, $email)); return $self; } @@ -37,6 +38,11 @@ public function changeName(string $name): void $this->recordThat(new NameChanged($name)); } + public function changeEmail(string $email): void + { + $this->recordThat(new EmailChanged($this->id, $email)); + } + public function reborn(): void { $this->recordThat(new Reborn( @@ -50,6 +56,7 @@ protected function applyProfileCreated(ProfileCreated $event): void { $this->id = $event->profileId; $this->name = $event->name; + $this->email = $event->email; } #[Apply] @@ -58,15 +65,27 @@ protected function applyNameChanged(NameChanged $event): void $this->name = $event->name; } + #[Apply] + protected function applyEmailChanged(EmailChanged $event): void + { + $this->email = $event->email; + } + #[Apply] protected function applyReborn(Reborn $event): void { $this->id = $event->profileId; $this->name = $event->name; + $this->email = null; } public function name(): string { return $this->name; } + + public function email(): string|null + { + return $this->email; + } } diff --git a/tests/Benchmark/PersonalDataBench.php b/tests/Benchmark/PersonalDataBench.php new file mode 100644 index 000000000..390dac4cf --- /dev/null +++ b/tests/Benchmark/PersonalDataBench.php @@ -0,0 +1,128 @@ +bus = DefaultEventBus::create(); + + $this->store = new DoctrineDbalStore( + $connection, + DefaultEventSerializer::createFromPaths( + [__DIR__ . '/BasicImplementation/Events'], + cryptographer: $cryptographer, + ), + DefaultHeadersSerializer::createFromPaths([ + __DIR__ . '/../../src', + __DIR__ . '/BasicImplementation/Events', + ]), + 'eventstore', + ); + + $this->repository = new DefaultRepository($this->store, $this->bus, Profile::metadata()); + + $schemaDirector = new DoctrineSchemaDirector( + $connection, + new ChainDoctrineSchemaConfigurator([ + $this->store, + $cipherKeyStore, + ]), + ); + + $schemaDirector->create(); + + $this->id = ProfileId::v7(); + + $profile = Profile::create($this->id, 'Peter', 'info@patchlevel.de'); + + for ($i = 0; $i < 10_000; $i++) { + $profile->changeEmail('info@patchlevel.de'); + } + + $this->repository->save($profile); + } + + #[Bench\Revs(10)] + public function benchLoad10000Events(): void + { + $this->repository->load($this->id); + } + + #[Bench\Revs(10)] + public function benchSave1Event(): void + { + $profile = Profile::create(ProfileId::v7(), 'Peter', 'info@patchlevel.de'); + $this->repository->save($profile); + } + + #[Bench\Revs(10)] + public function benchSave10000Events(): void + { + $profile = Profile::create(ProfileId::v7(), 'Peter', 'info@patchlevel.de'); + + for ($i = 1; $i < 10_000; $i++) { + $profile->changeEmail('info@patchlevel.de'); + } + + $this->repository->save($profile); + } + + #[Bench\Revs(1)] + public function benchSave10000Aggregates(): void + { + for ($i = 1; $i < 10_000; $i++) { + $profile = Profile::create(ProfileId::v7(), 'Peter', 'info@patchlevel.de'); + $this->repository->save($profile); + } + } + + #[Bench\Revs(10)] + public function benchSave10000AggregatesTransaction(): void + { + $this->store->transactional(function (): void { + for ($i = 1; $i < 10_000; $i++) { + $profile = Profile::create(ProfileId::v7(), 'Peter', 'info@patchlevel.de'); + $this->repository->save($profile); + } + }); + } +} diff --git a/tests/Benchmark/SimpleSetupBench.php b/tests/Benchmark/SimpleSetupBench.php index e6a13c7f5..e6af30560 100644 --- a/tests/Benchmark/SimpleSetupBench.php +++ b/tests/Benchmark/SimpleSetupBench.php @@ -14,7 +14,7 @@ use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; use Patchlevel\EventSourcing\Store\DoctrineDbalStore; use Patchlevel\EventSourcing\Store\Store; -use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\Aggregate\Profile; +use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\Profile; use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\ProfileId; use Patchlevel\EventSourcing\Tests\DbalManager; use PhpBench\Attributes as Bench; diff --git a/tests/Benchmark/SnapshotsBench.php b/tests/Benchmark/SnapshotsBench.php index a8c898b31..135d36750 100644 --- a/tests/Benchmark/SnapshotsBench.php +++ b/tests/Benchmark/SnapshotsBench.php @@ -17,7 +17,7 @@ use Patchlevel\EventSourcing\Snapshot\SnapshotStore; use Patchlevel\EventSourcing\Store\DoctrineDbalStore; use Patchlevel\EventSourcing\Store\Store; -use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\Aggregate\Profile; +use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\Profile; use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\ProfileId; use Patchlevel\EventSourcing\Tests\DbalManager; use PhpBench\Attributes as Bench; diff --git a/tests/Benchmark/SplitStreamBench.php b/tests/Benchmark/SplitStreamBench.php index c0b1ced5f..c319fd198 100644 --- a/tests/Benchmark/SplitStreamBench.php +++ b/tests/Benchmark/SplitStreamBench.php @@ -16,7 +16,7 @@ use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; use Patchlevel\EventSourcing\Store\DoctrineDbalStore; use Patchlevel\EventSourcing\Store\Store; -use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\Aggregate\Profile; +use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\Profile; use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\ProfileId; use Patchlevel\EventSourcing\Tests\DbalManager; use PhpBench\Attributes as Bench; diff --git a/tests/Benchmark/SubscriptionEngineBench.php b/tests/Benchmark/SubscriptionEngineBench.php index c30a0567d..cb494de1e 100644 --- a/tests/Benchmark/SubscriptionEngineBench.php +++ b/tests/Benchmark/SubscriptionEngineBench.php @@ -19,8 +19,8 @@ use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Store\DoctrineSubscriptionStore; use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessorRepository; -use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\Aggregate\Profile; use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\Processor\SendEmailProcessor; +use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\Profile; use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\ProfileId; use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\Projection\ProfileProjector; use Patchlevel\EventSourcing\Tests\DbalManager; diff --git a/tests/Benchmark/blackfire.php b/tests/Benchmark/blackfire.php index aa785c9dc..347aa6f8e 100644 --- a/tests/Benchmark/blackfire.php +++ b/tests/Benchmark/blackfire.php @@ -10,7 +10,7 @@ use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector; use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; use Patchlevel\EventSourcing\Store\DoctrineDbalStore; -use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\Aggregate\Profile; +use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\Profile; use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\ProfileId; require_once __DIR__ . '/../../vendor/autoload.php'; diff --git a/tests/Integration/PersonalData/Events/NameChanged.php b/tests/Integration/PersonalData/Events/NameChanged.php new file mode 100644 index 000000000..741515412 --- /dev/null +++ b/tests/Integration/PersonalData/Events/NameChanged.php @@ -0,0 +1,24 @@ +connection = DbalManager::createConnection(); + } + + public function tearDown(): void + { + $this->connection->close(); + } + + public function testSuccessful(): void + { + $cipherKeyStore = new DoctrineCipherKeyStore($this->connection); + + $cryptographer = DefaultEventPayloadCryptographer::createWithOpenssl( + new AttributeEventMetadataFactory(), + $cipherKeyStore, + ); + + $store = new DoctrineDbalStore( + $this->connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events'], cryptographer: $cryptographer), + DefaultHeadersSerializer::createFromPaths([ + __DIR__ . '/../../../src', + __DIR__, + ]), + 'eventstore', + ); + + $eventBus = DefaultEventBus::create(); + + $manager = new DefaultRepositoryManager( + new AggregateRootRegistry(['profile' => Profile::class]), + $store, + $eventBus, + ); + + $repository = $manager->get(Profile::class); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + new ChainDoctrineSchemaConfigurator([ + $store, + $cipherKeyStore, + ]), + ); + + $schemaDirector->create(); + + $profileId = ProfileId::fromString('1'); + $profile = Profile::create($profileId, 'John'); + + $repository->save($profile); + + $profile = $repository->load($profileId); + + self::assertInstanceOf(Profile::class, $profile); + self::assertEquals($profileId, $profile->aggregateRootId()); + self::assertSame(1, $profile->playhead()); + self::assertSame('John', $profile->name()); + + $result = $this->connection->fetchAllAssociative('SELECT * FROM eventstore'); + + self::assertCount(1, $result); + self::assertArrayHasKey(0, $result); + + $row = $result[0]; + + self::assertStringNotContainsString('John', $row['payload']); + } + + public function testRemoveKey(): void + { + $cipherKeyStore = new DoctrineCipherKeyStore($this->connection); + + $cryptographer = DefaultEventPayloadCryptographer::createWithOpenssl( + new AttributeEventMetadataFactory(), + $cipherKeyStore, + ); + + $subscriptionStore = new DoctrineSubscriptionStore( + $this->connection, + ); + + $store = new DoctrineDbalStore( + $this->connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events'], cryptographer: $cryptographer), + DefaultHeadersSerializer::createFromPaths([ + __DIR__ . '/../../../src', + __DIR__, + ]), + 'eventstore', + ); + + $eventBus = DefaultEventBus::create(); + + $manager = new DefaultRepositoryManager( + new AggregateRootRegistry(['profile' => Profile::class]), + $store, + $eventBus, + ); + + $repository = $manager->get(Profile::class); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + new ChainDoctrineSchemaConfigurator([ + $store, + $cipherKeyStore, + $subscriptionStore, + ]), + ); + + $schemaDirector->create(); + + $engine = new DefaultSubscriptionEngine( + $store, + $subscriptionStore, + new MetadataSubscriberAccessorRepository([new DeletePersonalDataProcessor($cipherKeyStore)]), + ); + + $engine->setup(skipBooting: true); + + $profileId = ProfileId::fromString('1'); + $profile = Profile::create($profileId, 'John'); + + $repository->save($profile); + $engine->run(); + + $profile = $repository->load($profileId); + + self::assertInstanceOf(Profile::class, $profile); + self::assertEquals($profileId, $profile->aggregateRootId()); + self::assertSame(1, $profile->playhead()); + self::assertSame('John', $profile->name()); + + $profile->removePersonalData(); + $repository->save($profile); + $engine->run(); + + $profile = $repository->load($profileId); + + self::assertInstanceOf(Profile::class, $profile); + self::assertEquals($profileId, $profile->aggregateRootId()); + self::assertSame(2, $profile->playhead()); + self::assertSame('unknown', $profile->name()); + + $profile->changeName('hallo'); + $repository->save($profile); + + $profile = $repository->load($profileId); + + self::assertInstanceOf(Profile::class, $profile); + self::assertEquals($profileId, $profile->aggregateRootId()); + self::assertSame(3, $profile->playhead()); + self::assertSame('hallo', $profile->name()); + } +} diff --git a/tests/Integration/PersonalData/Processor/DeletePersonalDataProcessor.php b/tests/Integration/PersonalData/Processor/DeletePersonalDataProcessor.php new file mode 100644 index 000000000..6b661a5c1 --- /dev/null +++ b/tests/Integration/PersonalData/Processor/DeletePersonalDataProcessor.php @@ -0,0 +1,29 @@ +header(AggregateHeader::class)->aggregateId; + + $this->cipherKeyStore->remove($aggregateId); + } +} diff --git a/tests/Integration/PersonalData/Profile.php b/tests/Integration/PersonalData/Profile.php new file mode 100644 index 000000000..f3d8905b5 --- /dev/null +++ b/tests/Integration/PersonalData/Profile.php @@ -0,0 +1,68 @@ +recordThat(new ProfileCreated($id, $name)); + + return $self; + } + + public function removePersonalData(): void + { + $this->recordThat(new PersonalDataRemoved()); + } + + public function changeName(string $name): void + { + $this->recordThat(new NameChanged($this->id, $name)); + } + + #[Apply(ProfileCreated::class)] + protected function applyProfileCreated(ProfileCreated $event): void + { + $this->id = $event->profileId; + $this->name = $event->name; + } + + #[Apply(PersonalDataRemoved::class)] + protected function applyPersonalDataRemoved(): void + { + $this->name = 'unknown'; + } + + #[Apply(NameChanged::class)] + protected function applyNameChanged(NameChanged $event): void + { + $this->name = $event->name; + } + + public function name(): string + { + return $this->name; + } +} diff --git a/tests/Integration/PersonalData/ProfileId.php b/tests/Integration/PersonalData/ProfileId.php new file mode 100644 index 000000000..6b2f1f6ff --- /dev/null +++ b/tests/Integration/PersonalData/ProfileId.php @@ -0,0 +1,25 @@ +id; + } +} diff --git a/tests/Unit/Cryptography/Cipher/CreateCipherKeyFailedTest.php b/tests/Unit/Cryptography/Cipher/CreateCipherKeyFailedTest.php new file mode 100644 index 000000000..8096db340 --- /dev/null +++ b/tests/Unit/Cryptography/Cipher/CreateCipherKeyFailedTest.php @@ -0,0 +1,19 @@ +getMessage()); + } +} diff --git a/tests/Unit/Cryptography/Cipher/DecryptionFailedTest.php b/tests/Unit/Cryptography/Cipher/DecryptionFailedTest.php new file mode 100644 index 000000000..a3af5c794 --- /dev/null +++ b/tests/Unit/Cryptography/Cipher/DecryptionFailedTest.php @@ -0,0 +1,19 @@ +getMessage()); + } +} diff --git a/tests/Unit/Cryptography/Cipher/EncryptionFailedTest.php b/tests/Unit/Cryptography/Cipher/EncryptionFailedTest.php new file mode 100644 index 000000000..6dbd10ab3 --- /dev/null +++ b/tests/Unit/Cryptography/Cipher/EncryptionFailedTest.php @@ -0,0 +1,19 @@ +getMessage()); + } +} diff --git a/tests/Unit/Cryptography/Cipher/OpensslCipherKeyFactoryTest.php b/tests/Unit/Cryptography/Cipher/OpensslCipherKeyFactoryTest.php new file mode 100644 index 000000000..449c9374a --- /dev/null +++ b/tests/Unit/Cryptography/Cipher/OpensslCipherKeyFactoryTest.php @@ -0,0 +1,33 @@ +assertSame(16, strlen($cipherKey->key)); + $this->assertSame('aes128', $cipherKey->method); + $this->assertSame(16, strlen($cipherKey->iv)); + } + + public function testMethodNotSupported(): void + { + $this->expectException(MethodNotSupported::class); + + $cipherKeyFactory = new OpensslCipherKeyFactory(method: 'foo'); + $cipherKeyFactory(); + } +} diff --git a/tests/Unit/Cryptography/Cipher/OpensslCipherTest.php b/tests/Unit/Cryptography/Cipher/OpensslCipherTest.php new file mode 100644 index 000000000..ca3346cf3 --- /dev/null +++ b/tests/Unit/Cryptography/Cipher/OpensslCipherTest.php @@ -0,0 +1,78 @@ +encrypt($this->createKey(), $value); + + self::assertEquals($encryptedString, $return); + } + + public function testEncryptFailed(): void + { + $this->expectException(EncryptionFailed::class); + + $cipher = new OpensslCipher(); + $cipher->encrypt(new CipherKey( + 'key', + 'bar', + 'abcdefg123456789', + ), ''); + } + + #[DataProvider('dataProvider')] + public function testDecrypt(mixed $value, string $encryptedString): void + { + $cipher = new OpensslCipher(); + $return = $cipher->decrypt($this->createKey(), $encryptedString); + + self::assertEquals($value, $return); + } + + public function testDecryptFailed(): void + { + $this->expectException(DecryptionFailed::class); + + $cipher = new OpensslCipher(); + $cipher->decrypt($this->createKey('foo'), 'emNpWDlMWFBnRStpZk9YZktrUStRQT09'); + } + + public static function dataProvider(): Generator + { + yield 'empty' => ['', 'emNpWDlMWFBnRStpZk9YZktrUStRQT09']; + yield 'string' => ['foo bar baz', 'YUlYRnJZMEd1RkFycjNrQitETHhqQT09']; + yield 'integer' => [42, 'M1FHSnlnbWNlZFJiV2xwdzZIZUhDdz09']; + yield 'float' => [0.5, 'N2tOWGNia3lrdUJ1ancrMFA4OEY0Zz09']; + yield 'null' => [null, 'OUE1T081cXdpNmFMc1FIMGsrME5vdz09']; + yield 'true' => [true, 'NCtWMDE4WnV5NEtCamVVdkIxZjRrdz09']; + yield 'false' => [false, 'czh5NUYxWXhQOWhSbGVwWG5ETFdVQT09']; + yield 'array' => [['foo' => 'bar'], 'cHo2QlhxSnNFZG1kUEhRZ3pjcFJrUT09']; + yield 'long text' => ['Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', 'eDNCalYzSS9LbkZIcGdKNWVmUFQwTTI0YXhhSnNmdUxXeXhGUGFwMWZkTmx1ZnNwNzBUa29NcUFxUzRFV3V2WWNlUmt6YWhTSlRzVXpqd3RLZkpzUWFWYVRCR1pvbkt3TUE4UzZmaDVQcTYzMzJoWVBRRzllbHhhNjYrenNWbzFDZ2lnVm1PRFhvamozZEVmcXFYVTZGQ1dIWEgzcE1mU2w2SWlRQ2o2WFdNPQ==']; + } + + /** @param non-empty-string $key */ + private function createKey(string $key = 'key'): CipherKey + { + return new CipherKey( + $key, + 'aes128', + 'abcdefg123456789', + ); + } +} diff --git a/tests/Unit/Cryptography/DefaultEventPayloadCryptographerTest.php b/tests/Unit/Cryptography/DefaultEventPayloadCryptographerTest.php new file mode 100644 index 000000000..a739c4e37 --- /dev/null +++ b/tests/Unit/Cryptography/DefaultEventPayloadCryptographerTest.php @@ -0,0 +1,274 @@ +prophesize(CipherKeyStore::class); + $cipherKeyStore->get(Argument::any())->shouldNotBeCalled(); + + $cipherKeyFactory = $this->prophesize(CipherKeyFactory::class); + $cipher = $this->prophesize(Cipher::class); + + $cryptographer = new DefaultEventPayloadCryptographer( + new AttributeEventMetadataFactory(), + $cipherKeyStore->reveal(), + $cipherKeyFactory->reveal(), + $cipher->reveal(), + ); + + $payload = ['id' => 'foo', 'email' => 'info@patchlevel.de']; + + $result = $cryptographer->encrypt(ProfileVisited::class, ['id' => 'foo', 'email' => 'info@patchlevel.de']); + + self::assertSame($payload, $result); + } + + public function testEncryptWithMissingKey(): void + { + $cipherKey = new CipherKey( + 'foo', + 'bar', + 'baz', + ); + + $cipherKeyStore = $this->prophesize(CipherKeyStore::class); + $cipherKeyStore->get('foo')->willThrow(new CipherKeyNotExists('foo')); + $cipherKeyStore->store('foo', $cipherKey)->shouldBeCalled(); + + $cipherKeyFactory = $this->prophesize(CipherKeyFactory::class); + $cipherKeyFactory->__invoke()->willReturn($cipherKey)->shouldBeCalledOnce(); + + $cipher = $this->prophesize(Cipher::class); + $cipher + ->encrypt($cipherKey, 'info@patchlevel.de') + ->willReturn('encrypted') + ->shouldBeCalledOnce(); + + $cryptographer = new DefaultEventPayloadCryptographer( + new AttributeEventMetadataFactory(), + $cipherKeyStore->reveal(), + $cipherKeyFactory->reveal(), + $cipher->reveal(), + ); + + $result = $cryptographer->encrypt(EmailChanged::class, ['id' => 'foo', 'email' => 'info@patchlevel.de']); + + self::assertEquals(['id' => 'foo', 'email' => 'encrypted'], $result); + } + + public function testEncryptWithExistingKey(): void + { + $cipherKey = new CipherKey( + 'foo', + 'bar', + 'baz', + ); + + $cipherKeyStore = $this->prophesize(CipherKeyStore::class); + $cipherKeyStore->get('foo')->willReturn($cipherKey); + $cipherKeyStore->store('foo', Argument::type(CipherKey::class))->shouldNotBeCalled(); + + $cipherKeyFactory = $this->prophesize(CipherKeyFactory::class); + $cipherKeyFactory->__invoke()->shouldNotBeCalled(); + + $cipher = $this->prophesize(Cipher::class); + $cipher + ->encrypt($cipherKey, 'info@patchlevel.de') + ->willReturn('encrypted') + ->shouldBeCalledOnce(); + + $cryptographer = new DefaultEventPayloadCryptographer( + new AttributeEventMetadataFactory(), + $cipherKeyStore->reveal(), + $cipherKeyFactory->reveal(), + $cipher->reveal(), + ); + + $result = $cryptographer->encrypt(EmailChanged::class, ['id' => 'foo', 'email' => 'info@patchlevel.de']); + + self::assertEquals(['id' => 'foo', 'email' => 'encrypted'], $result); + } + + public function testSkipDecrypt(): void + { + $cipherKeyStore = $this->prophesize(CipherKeyStore::class); + $cipherKeyStore->get(Argument::any())->shouldNotBeCalled(); + + $cipherKeyFactory = $this->prophesize(CipherKeyFactory::class); + $cipher = $this->prophesize(Cipher::class); + + $cryptographer = new DefaultEventPayloadCryptographer( + new AttributeEventMetadataFactory(), + $cipherKeyStore->reveal(), + $cipherKeyFactory->reveal(), + $cipher->reveal(), + ); + + $payload = ['id' => 'foo', 'email' => 'info@patchlevel.de']; + + $result = $cryptographer->decrypt(ProfileVisited::class, ['id' => 'foo', 'email' => 'info@patchlevel.de']); + + self::assertSame($payload, $result); + } + + public function testDecryptWithMissingKey(): void + { + $cipherKeyStore = $this->prophesize(CipherKeyStore::class); + $cipherKeyStore->get('foo')->willThrow(new CipherKeyNotExists('foo')); + + $cipherKeyFactory = $this->prophesize(CipherKeyFactory::class); + $cipherKeyFactory->__invoke()->shouldNotBeCalled(); + + $cipher = $this->prophesize(Cipher::class); + $cipher->decrypt()->shouldNotBeCalled(); + + $cryptographer = new DefaultEventPayloadCryptographer( + new AttributeEventMetadataFactory(), + $cipherKeyStore->reveal(), + $cipherKeyFactory->reveal(), + $cipher->reveal(), + ); + + $result = $cryptographer->decrypt(EmailChanged::class, ['id' => 'foo', 'email' => 'encrypted']); + + self::assertEquals(['id' => 'foo', 'email' => 'fallback'], $result); + } + + public function testDecryptWithInvalidKey(): void + { + $cipherKey = new CipherKey( + 'foo', + 'bar', + 'baz', + ); + + $cipherKeyStore = $this->prophesize(CipherKeyStore::class); + $cipherKeyStore->get('foo')->willReturn($cipherKey); + $cipherKeyStore->store('foo', Argument::type(CipherKey::class))->shouldNotBeCalled(); + + $cipherKeyFactory = $this->prophesize(CipherKeyFactory::class); + $cipherKeyFactory->__invoke()->shouldNotBeCalled(); + + $cipher = $this->prophesize(Cipher::class); + $cipher + ->decrypt($cipherKey, 'encrypted') + ->willThrow(new DecryptionFailed()) + ->shouldBeCalledOnce(); + + $cryptographer = new DefaultEventPayloadCryptographer( + new AttributeEventMetadataFactory(), + $cipherKeyStore->reveal(), + $cipherKeyFactory->reveal(), + $cipher->reveal(), + ); + + $result = $cryptographer->decrypt(EmailChanged::class, ['id' => 'foo', 'email' => 'encrypted']); + + self::assertEquals(['id' => 'foo', 'email' => 'fallback'], $result); + } + + public function testDecryptWithExistingKey(): void + { + $cipherKey = new CipherKey( + 'foo', + 'bar', + 'baz', + ); + + $cipherKeyStore = $this->prophesize(CipherKeyStore::class); + $cipherKeyStore->get('foo')->willReturn($cipherKey); + $cipherKeyStore->store('foo', Argument::type(CipherKey::class))->shouldNotBeCalled(); + + $cipherKeyFactory = $this->prophesize(CipherKeyFactory::class); + $cipherKeyFactory->__invoke()->shouldNotBeCalled(); + + $cipher = $this->prophesize(Cipher::class); + $cipher + ->decrypt($cipherKey, 'encrypted') + ->willReturn('info@patchlevel.de') + ->shouldBeCalledOnce(); + + $cryptographer = new DefaultEventPayloadCryptographer( + new AttributeEventMetadataFactory(), + $cipherKeyStore->reveal(), + $cipherKeyFactory->reveal(), + $cipher->reveal(), + ); + + $result = $cryptographer->decrypt(EmailChanged::class, ['id' => 'foo', 'email' => 'encrypted']); + + self::assertEquals(['id' => 'foo', 'email' => 'info@patchlevel.de'], $result); + } + + public function testUnsupportedSubjectId(): void + { + $this->expectException(UnsupportedSubjectId::class); + + $cipherKeyStore = $this->prophesize(CipherKeyStore::class); + $cipherKeyFactory = $this->prophesize(CipherKeyFactory::class); + $cipher = $this->prophesize(Cipher::class); + + $cryptographer = new DefaultEventPayloadCryptographer( + new AttributeEventMetadataFactory(), + $cipherKeyStore->reveal(), + $cipherKeyFactory->reveal(), + $cipher->reveal(), + ); + + $cryptographer->decrypt(EmailChanged::class, ['id' => null, 'email' => 'encrypted']); + } + + public function testMissingSubjectId(): void + { + $this->expectException(MissingSubjectId::class); + + $cipherKeyStore = $this->prophesize(CipherKeyStore::class); + $cipherKeyFactory = $this->prophesize(CipherKeyFactory::class); + $cipher = $this->prophesize(Cipher::class); + + $cryptographer = new DefaultEventPayloadCryptographer( + new AttributeEventMetadataFactory(), + $cipherKeyStore->reveal(), + $cipherKeyFactory->reveal(), + $cipher->reveal(), + ); + + $cryptographer->decrypt(EmailChanged::class, ['email' => 'encrypted']); + } + + public function testCreateWithOpenssl(): void + { + $cipherKeyStore = $this->prophesize(CipherKeyStore::class); + + $cryptographer = DefaultEventPayloadCryptographer::createWithOpenssl( + new AttributeEventMetadataFactory(), + $cipherKeyStore->reveal(), + ); + + self::assertInstanceOf(DefaultEventPayloadCryptographer::class, $cryptographer); + } +} diff --git a/tests/Unit/Cryptography/MissingSubjectIdTest.php b/tests/Unit/Cryptography/MissingSubjectIdTest.php new file mode 100644 index 000000000..90b6c0b6e --- /dev/null +++ b/tests/Unit/Cryptography/MissingSubjectIdTest.php @@ -0,0 +1,19 @@ +getMessage()); + } +} diff --git a/tests/Unit/Cryptography/Store/CipherKeyNotExistsTest.php b/tests/Unit/Cryptography/Store/CipherKeyNotExistsTest.php new file mode 100644 index 000000000..c7c1d112f --- /dev/null +++ b/tests/Unit/Cryptography/Store/CipherKeyNotExistsTest.php @@ -0,0 +1,19 @@ +getMessage()); + } +} diff --git a/tests/Unit/Cryptography/Store/InMemoryCipherKeyStoreTest.php b/tests/Unit/Cryptography/Store/InMemoryCipherKeyStoreTest.php new file mode 100644 index 000000000..d4ee6ec22 --- /dev/null +++ b/tests/Unit/Cryptography/Store/InMemoryCipherKeyStoreTest.php @@ -0,0 +1,76 @@ +store('foo', $key); + + self::assertSame($key, $store->get('foo')); + } + + public function testLoadFailed(): void + { + $this->expectException(CipherKeyNotExists::class); + + $store = new InMemoryCipherKeyStore(); + $store->get('foo'); + } + + public function testRemove(): void + { + $key = new CipherKey( + 'foo', + 'bar', + 'baz', + ); + + $store = new InMemoryCipherKeyStore(); + $store->store('foo', $key); + + self::assertSame($key, $store->get('foo')); + + $store->remove('foo'); + + $this->expectException(CipherKeyNotExists::class); + + $store->get('foo'); + } + + public function testClear(): void + { + $key = new CipherKey( + 'foo', + 'bar', + 'baz', + ); + + $store = new InMemoryCipherKeyStore(); + $store->store('foo', $key); + + self::assertSame($key, $store->get('foo')); + + $store->clear(); + + $this->expectException(CipherKeyNotExists::class); + + $store->get('foo'); + } +} diff --git a/tests/Unit/Cryptography/UnsupportedSubjectIdTest.php b/tests/Unit/Cryptography/UnsupportedSubjectIdTest.php new file mode 100644 index 000000000..036660ec9 --- /dev/null +++ b/tests/Unit/Cryptography/UnsupportedSubjectIdTest.php @@ -0,0 +1,19 @@ +getMessage()); + } +} diff --git a/tests/Unit/Fixture/EmailChanged.php b/tests/Unit/Fixture/EmailChanged.php new file mode 100644 index 000000000..60bcbfc41 --- /dev/null +++ b/tests/Unit/Fixture/EmailChanged.php @@ -0,0 +1,21 @@ +metadata($event::class); self::assertSame('profile_created', $metadata->name); + self::assertSame(false, $metadata->splitStream); + self::assertSame(null, $metadata->dataSubjectIdField); + self::assertEmpty($metadata->propertyMetadata); + } + + public function testSplitStream(): void + { + $event = new #[Event('profile_created')] + #[SplitStream] + class { + }; + + $metadataFactory = new AttributeEventMetadataFactory(); + $metadata = $metadataFactory->metadata($event::class); + + self::assertSame('profile_created', $metadata->name); + self::assertSame(true, $metadata->splitStream); + self::assertSame(null, $metadata->dataSubjectIdField); + self::assertEmpty($metadata->propertyMetadata); + } + + public function testPersonalData(): void + { + $event = new #[Event('profile_created')] + class ('id', 'name') { + public function __construct( + #[DataSubjectId] + #[NormalizedName('_id')] + public string $id, + #[PersonalData('fallback')] + #[NormalizedName('_name')] + public string $name, + ) { + } + }; + + $metadataFactory = new AttributeEventMetadataFactory(); + $metadata = $metadataFactory->metadata($event::class); + + self::assertSame('profile_created', $metadata->name); + self::assertSame(false, $metadata->splitStream); + self::assertSame('_id', $metadata->dataSubjectIdField); + self::assertCount(2, $metadata->propertyMetadata); + + self::assertSame('id', $metadata->propertyMetadata['id']->propertyName); + self::assertSame(false, $metadata->propertyMetadata['id']->isPersonalData); + self::assertSame('_id', $metadata->propertyMetadata['id']->fieldName); + self::assertSame(null, $metadata->propertyMetadata['id']->personalDataFallback); + + self::assertSame('name', $metadata->propertyMetadata['name']->propertyName); + self::assertSame(true, $metadata->propertyMetadata['name']->isPersonalData); + self::assertSame('_name', $metadata->propertyMetadata['name']->fieldName); + self::assertSame('fallback', $metadata->propertyMetadata['name']->personalDataFallback); + } + + public function testMissingDataSubjectId(): void + { + $event = new #[Event('profile_created')] + class ('name') { + public function __construct( + #[PersonalData] + public string $name, + ) { + } + }; + + $this->expectException(MissingDataSubjectId::class); + + $metadataFactory = new AttributeEventMetadataFactory(); + $metadataFactory->metadata($event::class); + } + + public function testDataSubjectIdIsPersonalData(): void + { + $event = new #[Event('profile_created')] + class ('name') { + public function __construct( + #[DataSubjectId] + #[PersonalData] + public string $name, + ) { + } + }; + + $this->expectException(DataSubjectIdIsPersonalData::class); + + $metadataFactory = new AttributeEventMetadataFactory(); + $metadataFactory->metadata($event::class); + } + + public function testMultipleDataSubjectId(): void + { + $event = new #[Event('profile_created')] + class ('id', 'name') { + public function __construct( + #[DataSubjectId] + public string $id, + #[DataSubjectId] + public string $name, + ) { + } + }; + + $this->expectException(MultipleDataSubjectId::class); + + $metadataFactory = new AttributeEventMetadataFactory(); + $metadataFactory->metadata($event::class); } } diff --git a/tests/Unit/Serializer/CryptographicHydratorTest.php b/tests/Unit/Serializer/CryptographicHydratorTest.php new file mode 100644 index 000000000..b6431318e --- /dev/null +++ b/tests/Unit/Serializer/CryptographicHydratorTest.php @@ -0,0 +1,74 @@ + 'bar']; + $encryptedPayload = ['foo' => 'encrypted']; + + $parentHydrator = $this->prophesize(Hydrator::class); + $parentHydrator + ->hydrate(stdClass::class, $payload) + ->willReturn($object) + ->shouldBeCalledOnce(); + + $cryptographer = $this->prophesize(EventPayloadCryptographer::class); + $cryptographer + ->decrypt(stdClass::class, $encryptedPayload) + ->willReturn($payload) + ->shouldBeCalledOnce(); + + $hydrator = new CryptographicHydrator( + $parentHydrator->reveal(), + $cryptographer->reveal(), + ); + + $return = $hydrator->hydrate(stdClass::class, $encryptedPayload); + + self::assertSame($object, $return); + } + + public function testExtract(): void + { + $object = new stdClass(); + $payload = ['foo' => 'bar']; + $encryptedPayload = ['foo' => 'encrypted']; + + $parentHydrator = $this->prophesize(Hydrator::class); + $parentHydrator + ->extract($object) + ->willReturn($payload) + ->shouldBeCalledOnce(); + + $cryptographer = $this->prophesize(EventPayloadCryptographer::class); + $cryptographer + ->encrypt(stdClass::class, $payload) + ->willReturn($encryptedPayload) + ->shouldBeCalledOnce(); + + $hydrator = new CryptographicHydrator( + $parentHydrator->reveal(), + $cryptographer->reveal(), + ); + + $return = $hydrator->extract($object); + + self::assertSame($encryptedPayload, $return); + } +}