diff --git a/composer.json b/composer.json index 0d8c2ca14..5ba197ea2 100644 --- a/composer.json +++ b/composer.json @@ -27,6 +27,9 @@ ], "Ecotone\\Amqp\\": "packages/Amqp/src", "Ecotone\\AnnotationFinder\\": "packages/Ecotone/src/AnnotationFinder/", + "Ecotone\\DataProtection\\": [ + "packages/DataProtection/src" + ], "Ecotone\\Dbal\\": [ "packages/Ecotone/src/Dbal/", "packages/Dbal/src" @@ -75,6 +78,9 @@ "Test\\Ecotone\\Amqp\\": [ "packages/Amqp/tests" ], + "Test\\Ecotone\\DataProtection\\": [ + "packages/DataProtection/tests" + ], "Test\\Ecotone\\Dbal\\": [ "packages/Dbal/tests" ], @@ -113,6 +119,7 @@ "php": "^8.2", "doctrine/dbal": "^3.9|^4.0", "doctrine/persistence": "^2.5|^3.4", + "defuse/php-encryption": "^2.4", "enqueue/amqp-lib": "^0.10.25", "enqueue/redis": "^0.10.9", "enqueue/sqs": "^0.10.15", diff --git a/packages/Amqp/src/AmqpBackedMessageChannelBuilder.php b/packages/Amqp/src/AmqpBackedMessageChannelBuilder.php index 28d44602d..dabe3600d 100644 --- a/packages/Amqp/src/AmqpBackedMessageChannelBuilder.php +++ b/packages/Amqp/src/AmqpBackedMessageChannelBuilder.php @@ -48,25 +48,20 @@ public static function create( ); } - private function getAmqpOutboundChannelAdapter(): AmqpOutboundChannelAdapterBuilder - { - return $this->outboundChannelAdapter; - } - /** * @deprecated use withPublisherConfirms * @TODO Ecotone 2.0 remove */ public function withPublisherAcknowledgments(bool $enabled): self { - $this->getAmqpOutboundChannelAdapter()->withPublisherConfirms($enabled); + $this->outboundChannelAdapter->withPublisherConfirms($enabled); return $this; } public function withPublisherConfirms(bool $enabled): self { - $this->getAmqpOutboundChannelAdapter()->withPublisherConfirms($enabled); + $this->outboundChannelAdapter->withPublisherConfirms($enabled); return $this; } diff --git a/packages/DataProtection/.gitattributes b/packages/DataProtection/.gitattributes new file mode 100644 index 000000000..5699823c5 --- /dev/null +++ b/packages/DataProtection/.gitattributes @@ -0,0 +1,7 @@ +tests/ export-ignore +.coveralls.yml export-ignore +.gitattributes export-ignore +.gitignore export-ignore +behat.yaml export-ignore +phpstan.neon export-ignore +phpunit.xml export-ignore \ No newline at end of file diff --git a/packages/DataProtection/.github/FUNDING.yml b/packages/DataProtection/.github/FUNDING.yml new file mode 100644 index 000000000..c7eaae65e --- /dev/null +++ b/packages/DataProtection/.github/FUNDING.yml @@ -0,0 +1,12 @@ +# These are supported funding model platforms + +github: [dgafka] +patreon: # Replace with a single Open Collective username +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +otechie: # Replace with a single Otechie username +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/packages/DataProtection/.github/ISSUE_TEMPLATE/bug_report.md b/packages/DataProtection/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..2fc86f2cf --- /dev/null +++ b/packages/DataProtection/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,10 @@ +--- +name: This is Read-Only repository +about: Report at ecotoneframework/ecotone-dev +title: '' +labels: '' +assignees: '' + +--- + +Report issue at [ecotone-dev](ecotoneframework/ecotone-dev) \ No newline at end of file diff --git a/packages/DataProtection/.gitignore b/packages/DataProtection/.gitignore new file mode 100644 index 000000000..18c159d80 --- /dev/null +++ b/packages/DataProtection/.gitignore @@ -0,0 +1,9 @@ +.idea/ +vendor/ +bin/ +tests/coverage +!tests/coverage/.gitkeep +file +.phpunit.result.cache +composer.lock +phpunit.xml diff --git a/packages/DataProtection/LICENSE b/packages/DataProtection/LICENSE new file mode 100644 index 000000000..82205508a --- /dev/null +++ b/packages/DataProtection/LICENSE @@ -0,0 +1,21 @@ +Copyright (c) 2025 Dariusz Gafka + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +**Scope of the License** + +Apache-2.0 Licence applies to non Enterprise Functionalities of the Ecotone Framework. +Functionalities of the Ecotone Framework referred to as Enterprise functionalities, are not covered under the Apache-2.0 license. These functionalities are provided under a separate Enterprise License. +For details on the Enterprise License, please refer to the [LICENSE-ENTERPRISE](./LICENSE-ENTERPRISE) file. \ No newline at end of file diff --git a/packages/DataProtection/LICENSE-ENTERPRISE b/packages/DataProtection/LICENSE-ENTERPRISE new file mode 100644 index 000000000..fad1a5a8d --- /dev/null +++ b/packages/DataProtection/LICENSE-ENTERPRISE @@ -0,0 +1,3 @@ +Copyright (c) 2025 Dariusz Gafka + +Licence is available at [ecotone.tech/documents/ecotone_enterprise_licence.pdf](https://ecotone.tech/documents/ecotone_enterprise_licence.pdf) \ No newline at end of file diff --git a/packages/DataProtection/README.md b/packages/DataProtection/README.md new file mode 100644 index 000000000..a46452a91 --- /dev/null +++ b/packages/DataProtection/README.md @@ -0,0 +1,63 @@ +# This is Read Only Repository +To contribute make use of [Ecotone-Dev repository](https://github.com/ecotoneframework/ecotone-dev). + +

+ +

+ +![Github Actions](https://github.com/ecotoneFramework/ecotone-dev/actions/workflows/split-testing.yml/badge.svg) +[![Latest Stable Version](https://poser.pugx.org/ecotone/ecotone/v/stable)](https://packagist.org/packages/ecotone/ecotone) +[![License](https://poser.pugx.org/ecotone/ecotone/license)](https://packagist.org/packages/ecotone/ecotone) +[![Total Downloads](https://img.shields.io/packagist/dt/ecotone/ecotone)](https://packagist.org/packages/ecotone/ecotone) +[![PHP Version Require](https://img.shields.io/packagist/dependency-v/ecotone/ecotone/php.svg)](https://packagist.org/packages/ecotone/ecotone) + +The roots of Object Oriented Programming (OOP) were mainly about communication using Messages and logic encapsulation. +`Ecotone` aims to return to the origins of OOP, by providing tools which allows us to fully move the focus from Objects to Flows, from Data storage to Application Design, from Technicalities to Business logic. +Ecotone does that by making Messages first class-citizen in our Applications. + +Thanks to being Message-Driven at the foundation level, Ecotone provides architecture which is resilient and scalable by default, making it possible for Developers to focus on business problems instead of technical concerns. +Together with declarative configuration and higher level building blocks, it makes the system design explicit, easy to follow and change no matter of Developers experience. + +Visit main page [ecotone.tech](https://ecotone.tech) to learn more. + +> Ecotone can be used with [Symfony](https://docs.ecotone.tech/modules/symfony-ddd-cqrs-event-sourcing) and [Laravel](https://docs.ecotone.tech/modules/laravel-ddd-cqrs-event-sourcing) frameworks, or any other framework using [Ecotone Lite](https://docs.ecotone.tech/install-php-service-bus#install-ecotone-lite-no-framework). +> +## Getting started + +The quickstart [page](https://docs.ecotone.tech/quick-start) of the +[reference guide](https://docs.ecotone.tech) provides a starting point for using Ecotone. +Read more on the [Ecotone's Blog](https://blog.ecotone.tech). + +## AI-Friendly Documentation + +Ecotone provides AI-optimized documentation for use with AI assistants and code editors: + +- **MCP Server**: `https://docs.ecotone.tech/~gitbook/mcp` - [Install in VSCode](vscode:mcp/install?%7B%22name%22%3A%22Ecotone%22%2C%22url%22%3A%22https%3A%2F%2Fdocs.ecotone.tech%2F~gitbook%2Fmcp%22%7D) +- **LLMs.txt**: [ecotone.tech/llms.txt](https://ecotone.tech/llms.txt) +- **Context7**: Available via [@upstash/context7-mcp](https://github.com/upstash/context7) + +Learn more: [AI Integration Guide](https://docs.ecotone.tech/other/ai-integration) + +## Feature requests and issue reporting + +Use [issue tracking system](https://github.com/ecotoneframework/ecotone-dev/issues) for new feature request and bugs. +Please verify that it's not already reported by someone else. + +## Contact + +If you want to talk or ask questions about Ecotone + +- [**Twitter**](https://twitter.com/EcotonePHP) +- **support@simplycodedsoftware.com** +- [**Community Channel**](https://discord.gg/GwM2BSuXeg) + +## Support Ecotone + +If you want to help building and improving Ecotone consider becoming a sponsor: + +- [Sponsor Ecotone](https://github.com/sponsors/dgafka) +- [Contribute to Ecotone](https://github.com/ecotoneframework/ecotone-dev). + +## Tags + +PHP, DDD, CQRS, Event Sourcing, Symfony, Laravel, Service Bus, Event Driven Architecture, SOA, Events, Commands diff --git a/packages/DataProtection/composer.json b/packages/DataProtection/composer.json new file mode 100644 index 000000000..5e56ed9fc --- /dev/null +++ b/packages/DataProtection/composer.json @@ -0,0 +1,81 @@ +{ + "name": "ecotone/data-protection", + "license": [ + "Apache-2.0", + "proprietary" + ], + "homepage": "https://docs.ecotone.tech/", + "forum": "https://discord.gg/GwM2BSuXeg", + "type": "library", + "minimum-stability": "dev", + "prefer-stable": true, + "authors": [ + { + "name": "Dariusz Gafka", + "email": "support@simplycodedsoftware.com" + } + ], + "keywords": ["ecotone", "Encryption", "OpenSSL", "Data Protection", "Data Obfuscation"], + "description": "Extends Ecotone with Data Protection features allowing to obfuscate messages with sensitive data.", + "autoload": { + "psr-4": { + "Ecotone\\DataProtection\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "Test\\Ecotone\\DataProtection\\": [ + "tests" + ] + } + }, + "require": { + "ext-openssl": "*", + "ecotone/ecotone": "~1.295.0", + "ecotone/jms-converter": "~1.295.0", + "defuse/php-encryption": "^2.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.5|^10.5|^11.0", + "phpstan/phpstan": "^1.8", + "psr/container": "^2.0", + "wikimedia/composer-merge-plugin": "^2.1" + }, + "scripts": { + "tests:phpstan": "vendor/bin/phpstan", + "tests:phpunit": "vendor/bin/phpunit", + "tests:ci": [ + "@tests:phpstan", + "@tests:phpunit" + ] + }, + "extra": { + "branch-alias": { + "dev-main": "1.62-dev" + }, + "ecotone": { + "repository": "DataProtection" + }, + "merge-plugin": { + "include": [ + "../local_packages.json" + ] + }, + "license-info": { + "Apache-2.0": { + "name": "Apache License 2.0", + "url": "https://github.com/ecotoneframework/ecotone-dev/blob/main/LICENSE", + "description": "Allows to use non Enterprise features of Ecotone. For more information please write to support@simplycodedsoftware.com" + }, + "proprietary": { + "name": "Enterprise License", + "description": "Allows to use Enterprise features of Ecotone. For more information please write to support@simplycodedsoftware.com" + } + } + }, + "config": { + "allow-plugins": { + "wikimedia/composer-merge-plugin": true + } + } +} diff --git a/packages/DataProtection/phpstan.neon b/packages/DataProtection/phpstan.neon new file mode 100644 index 000000000..672e0fa1f --- /dev/null +++ b/packages/DataProtection/phpstan.neon @@ -0,0 +1,4 @@ +parameters: + level: 1 + paths: + - src \ No newline at end of file diff --git a/packages/DataProtection/phpunit.xml.dist b/packages/DataProtection/phpunit.xml.dist new file mode 100644 index 000000000..fc3ebe4f7 --- /dev/null +++ b/packages/DataProtection/phpunit.xml.dist @@ -0,0 +1,20 @@ + + + + + ./src + + + + + + + + tests + + + diff --git a/packages/DataProtection/src/Attribute/UsingSensitiveData.php b/packages/DataProtection/src/Attribute/UsingSensitiveData.php new file mode 100644 index 000000000..428f3f99d --- /dev/null +++ b/packages/DataProtection/src/Attribute/UsingSensitiveData.php @@ -0,0 +1,21 @@ +encryptionKeyName; + } +} diff --git a/packages/DataProtection/src/Attribute/WithSensitiveHeader.php b/packages/DataProtection/src/Attribute/WithSensitiveHeader.php new file mode 100644 index 000000000..a1807edd9 --- /dev/null +++ b/packages/DataProtection/src/Attribute/WithSensitiveHeader.php @@ -0,0 +1,16 @@ +headers, 'Header names should be all strings.'); + } +} diff --git a/packages/DataProtection/src/Configuration/DataProtectionConfiguration.php b/packages/DataProtection/src/Configuration/DataProtectionConfiguration.php new file mode 100644 index 000000000..4b0c8286c --- /dev/null +++ b/packages/DataProtection/src/Configuration/DataProtectionConfiguration.php @@ -0,0 +1,48 @@ + $keys + */ + private function __construct(private array $keys, private string $defaultKey) + { + } + + public static function create(string $name, Key $key): self + { + return new self(keys: [$name => $key], defaultKey: $name); + } + + public function withKey(string $name, Key $key, bool $asDefault = false): self + { + Assert::keyNotExists($this->keys, $name, sprintf('Encryption key name `%s` already exists', $name)); + + $config = clone $this; + $config->keys[$name] = $key; + + if ($asDefault) { + $config->defaultKey = $name; + } + + return $config; + } + + public function keyName(?string $name): string + { + return array_key_exists($name, $this->keys) ? $name : $this->defaultKey; + } + + public function keys(): array + { + return $this->keys; + } +} diff --git a/packages/DataProtection/src/Configuration/DataProtectionModule.php b/packages/DataProtection/src/Configuration/DataProtectionModule.php new file mode 100644 index 000000000..dc37801e4 --- /dev/null +++ b/packages/DataProtection/src/Configuration/DataProtectionModule.php @@ -0,0 +1,115 @@ +findAnnotatedClasses(UsingSensitiveData::class); + + foreach ($messagesUsingSensitiveData as $messageUsingSensitiveData) { + $classDefinition = $interfaceToCallRegistry->getClassDefinitionFor(Type::create($messageUsingSensitiveData)); + $usingSensitiveDataAttribute = $classDefinition->getSingleClassAnnotation(Type::create(UsingSensitiveData::class)); + + $sensitiveHeaders = $classDefinition->findSingleClassAnnotation(Type::create(WithSensitiveHeaders::class))?->headers ?? []; + foreach ($classDefinition->getClassAnnotations(Type::create(WithSensitiveHeader::class)) as $sensitiveHeader) { + $sensitiveHeaders[] = $sensitiveHeader->header; + } + + $obfuscators[$messageUsingSensitiveData] = [ + 'encryptionKey' => $usingSensitiveDataAttribute->encryptionKeyName(), + 'sensitiveHeaders' => $sensitiveHeaders, + ]; + } + + return new self($obfuscators); + } + + public function prepare(Configuration $messagingConfiguration, array $extensionObjects, ModuleReferenceSearchService $moduleReferenceSearchService, InterfaceToCallRegistry $interfaceToCallRegistry): void + { + Assert::isTrue(ExtensionObjectResolver::contains(DataProtectionConfiguration::class, $extensionObjects), sprintf('%s was not found.', DataProtectionConfiguration::class)); + Assert::isTrue(ExtensionObjectResolver::contains(JMSConverterConfiguration::class, $extensionObjects), sprintf('%s package require %s package to be enabled. Did you forget to define %s?', ModulePackageList::DATA_PROTECTION_PACKAGE, ModulePackageList::JMS_CONVERTER_PACKAGE, JMSConverterConfiguration::class)); + + $dataProtectionConfiguration = ExtensionObjectResolver::resolveUnique(DataProtectionConfiguration::class, $extensionObjects, new stdClass()); + + foreach ($dataProtectionConfiguration->keys() as $encryptionKeyName => $key) { + $messagingConfiguration->registerServiceDefinition( + id: sprintf('ecotone.encryption.key.%s', $encryptionKeyName), + definition: new Definition( + Key::class, + [$key->saveToAsciiSafeString()], + 'loadFromAsciiSafeString' + ) + ); + } + + $messageObfuscatorDefinition = new Definition(MessageObfuscator::class); + + foreach ($this->obfuscators as $messageClass => $config) { + $messageObfuscatorDefinition->addMethodCall('withKey', [$messageClass, Reference::to(sprintf('ecotone.encryption.key.%s', $dataProtectionConfiguration->keyName($config['encryptionKey'])))]); + $messageObfuscatorDefinition->addMethodCall('withSensitiveHeaders', [$messageClass, $config['sensitiveHeaders']]); + } + + $messagingConfiguration->registerServiceDefinition(id: MessageObfuscator::class, definition: $messageObfuscatorDefinition); + + foreach (ExtensionObjectResolver::resolve(MessageChannelWithSerializationBuilder::class, $extensionObjects) as $pollableMessageChannel) { + $messagingConfiguration->registerChannelInterceptor( + new OutboundEncryptionChannelBuilder($pollableMessageChannel->getMessageChannelName()) + ); + $messagingConfiguration->registerChannelInterceptor( + new OutboundDecryptionChannelBuilder($pollableMessageChannel->getMessageChannelName()) + ); + } + } + + public function canHandle($extensionObject): bool + { + return + $extensionObject instanceof DataProtectionConfiguration + || $extensionObject instanceof JMSConverterConfiguration + || ($extensionObject instanceof MessageChannelWithSerializationBuilder && $extensionObject->isPollable()) + ; + } + + public function getModulePackageName(): string + { + return ModulePackageList::DATA_PROTECTION_PACKAGE; + } +} diff --git a/packages/DataProtection/src/Obfuscator/MessageObfuscator.php b/packages/DataProtection/src/Obfuscator/MessageObfuscator.php new file mode 100644 index 000000000..3e4a3a13e --- /dev/null +++ b/packages/DataProtection/src/Obfuscator/MessageObfuscator.php @@ -0,0 +1,92 @@ + + */ + private array $encryptionKeys = []; + + /** + * @var array> + */ + private array $sensitiveHeaders = []; + + public function encrypt(Message $message): Message + { + if (! $message->getHeaders()->containsKey(MessageHeaders::TYPE_ID)) { + return $message; + } + + $type = $message->getHeaders()->get(MessageHeaders::TYPE_ID); + if (! array_key_exists($type, $this->encryptionKeys)) { + return $message; + } + + $key = $this->encryptionKeys[$type]; + $encryptedPayload = base64_encode(Crypto::encrypt($message->getPayload(), $key)); + $headers = $message->getHeaders()->headers(); + foreach ($this->sensitiveHeaders[$type] as $sensitiveHeader) { + if (array_key_exists($sensitiveHeader, $headers)) { + $headers[$sensitiveHeader] = base64_encode(Crypto::encrypt($headers[$sensitiveHeader], $key)); + } + } + + $preparedMessage = MessageBuilder::withPayload($encryptedPayload) + ->setMultipleHeaders($headers) + ; + + return $preparedMessage->build(); + } + + public function decrypt(Message $message): Message + { + if (! $message->getHeaders()->containsKey(MessageHeaders::TYPE_ID)) { + return $message; + } + + $type = $message->getHeaders()->get(MessageHeaders::TYPE_ID); + if (! array_key_exists($type, $this->encryptionKeys)) { + return $message; + } + + $key = $this->encryptionKeys[$type]; + $decryptedPayload = Crypto::decrypt(base64_decode($message->getPayload()), $key); + $headers = $message->getHeaders()->headers(); + foreach ($this->sensitiveHeaders[$type] as $sensitiveHeader) { + if (array_key_exists($sensitiveHeader, $headers)) { + $headers[$sensitiveHeader] = Crypto::decrypt(base64_decode($headers[$sensitiveHeader]), $key); + } + } + + $preparedMessage = MessageBuilder::withPayload($decryptedPayload) + ->setMultipleHeaders($headers) + ; + + return $preparedMessage->build(); + } + + public function withKey(string $messageClass, Key $key): void + { + $this->encryptionKeys[$messageClass] = $key; + } + + public function withSensitiveHeaders(string $messageClass, array $headers): void + { + Assert::allStrings($headers, sprintf('Headers for message class %s should be array of strings', $messageClass)); + + $this->sensitiveHeaders[$messageClass] = $headers; + } +} diff --git a/packages/DataProtection/src/OutboundDecryptionChannelBuilder.php b/packages/DataProtection/src/OutboundDecryptionChannelBuilder.php new file mode 100644 index 000000000..643fadb14 --- /dev/null +++ b/packages/DataProtection/src/OutboundDecryptionChannelBuilder.php @@ -0,0 +1,36 @@ +relatedChannel; + } + + public function getPrecedence(): int + { + return PrecedenceChannelInterceptor::MESSAGE_SERIALIZATION + 1; + } + + public function compile(MessagingContainerBuilder $builder): Definition + { + return new Definition(OutboundDecryptionChannelInterceptor::class, [Reference::to(MessageObfuscator::class)]); + } +} diff --git a/packages/DataProtection/src/OutboundDecryptionChannelInterceptor.php b/packages/DataProtection/src/OutboundDecryptionChannelInterceptor.php new file mode 100644 index 000000000..a638cb6bf --- /dev/null +++ b/packages/DataProtection/src/OutboundDecryptionChannelInterceptor.php @@ -0,0 +1,36 @@ +canHandle($message)) { + return $message; + } + + return $this->messageObfuscator->decrypt($message); + } + + private function canHandle(Message $message): bool + { + return $message->getHeaders()->containsKey(MessageHeaders::CONTENT_TYPE) + && MediaType::parseMediaType($message->getHeaders()->get(MessageHeaders::CONTENT_TYPE))->isCompatibleWith(MediaType::createApplicationJson()) + ; + } +} diff --git a/packages/DataProtection/src/OutboundEncryptionChannelBuilder.php b/packages/DataProtection/src/OutboundEncryptionChannelBuilder.php new file mode 100644 index 000000000..ce24c42ec --- /dev/null +++ b/packages/DataProtection/src/OutboundEncryptionChannelBuilder.php @@ -0,0 +1,36 @@ +relatedChannel; + } + + public function getPrecedence(): int + { + return PrecedenceChannelInterceptor::MESSAGE_SERIALIZATION - 1; + } + + public function compile(MessagingContainerBuilder $builder): Definition + { + return new Definition(OutboundEncryptionChannelInterceptor::class, [Reference::to(MessageObfuscator::class)]); + } +} diff --git a/packages/DataProtection/src/OutboundEncryptionChannelInterceptor.php b/packages/DataProtection/src/OutboundEncryptionChannelInterceptor.php new file mode 100644 index 000000000..2ba652023 --- /dev/null +++ b/packages/DataProtection/src/OutboundEncryptionChannelInterceptor.php @@ -0,0 +1,36 @@ +canHandle($message)) { + return $message; + } + + return $this->messageObfuscator->encrypt($message); + } + + private function canHandle(Message $message): bool + { + return $message->getHeaders()->containsKey(MessageHeaders::CONTENT_TYPE) + && MediaType::parseMediaType($message->getHeaders()->get('contentType'))->isCompatibleWith(MediaType::createApplicationJson()) + ; + } +} diff --git a/packages/DataProtection/tests/Fixture/MessageReceiver.php b/packages/DataProtection/tests/Fixture/MessageReceiver.php new file mode 100644 index 000000000..6530b67bc --- /dev/null +++ b/packages/DataProtection/tests/Fixture/MessageReceiver.php @@ -0,0 +1,25 @@ +receivedMessage = $message; + $this->receivedHeaders = $headers; + } + + public function receivedMessage(): ?object + { + return $this->receivedMessage; + } + + public function receivedHeaders(): array + { + return $this->receivedHeaders; + } +} diff --git a/packages/DataProtection/tests/Fixture/ObfuscateAnnotatedMessages/FullyObfuscatedMessage.php b/packages/DataProtection/tests/Fixture/ObfuscateAnnotatedMessages/FullyObfuscatedMessage.php new file mode 100644 index 000000000..26fa7722d --- /dev/null +++ b/packages/DataProtection/tests/Fixture/ObfuscateAnnotatedMessages/FullyObfuscatedMessage.php @@ -0,0 +1,22 @@ +withReceived($message, $headers); + } + + #[CommandHandler(endpointId: 'test.PartiallyObfuscatedMessage')] + public function handlePartiallyObfuscatedMessage( + #[Payload] PartiallyObfuscatedMessage $message, + #[Headers] array $headers, + #[Reference] MessageReceiver $messageReceiver, + ): void { + $messageReceiver->withReceived($message, $headers); + } + + #[CommandHandler(endpointId: 'test.MessageWithSecondaryKeyEncryption')] + public function handleMessageWithSecondaryKeyEncryption( + #[Payload] MessageWithSecondaryKeyEncryption $message, + #[Headers] array $headers, + #[Reference] MessageReceiver $messageReceiver, + ): void { + $messageReceiver->withReceived($message, $headers); + } +} diff --git a/packages/DataProtection/tests/Fixture/TestEnum.php b/packages/DataProtection/tests/Fixture/TestEnum.php new file mode 100644 index 000000000..923dc3bdf --- /dev/null +++ b/packages/DataProtection/tests/Fixture/TestEnum.php @@ -0,0 +1,8 @@ +withReceived($message, $headers); + } + + #[EventHandler(endpointId: 'test.PartiallyObfuscatedMessage')] + public function handlePartiallyObfuscatedMessage( + #[Payload] PartiallyObfuscatedMessage $message, + #[Headers] array $headers, + #[Reference] MessageReceiver $messageReceiver, + ): void { + $messageReceiver->withReceived($message, $headers); + } + + #[EventHandler(endpointId: 'test.MessageWithSecondaryKeyEncryption')] + public function handleMessageWithSecondaryKeyEncryption( + #[Payload] MessageWithSecondaryKeyEncryption $message, + #[Headers] array $headers, + #[Reference] MessageReceiver $messageReceiver, + ): void { + $messageReceiver->withReceived($message, $headers); + } +} diff --git a/packages/DataProtection/tests/Integration/ObfuscateAnnotatedMessagesTest.php b/packages/DataProtection/tests/Integration/ObfuscateAnnotatedMessagesTest.php new file mode 100644 index 000000000..b26bcac76 --- /dev/null +++ b/packages/DataProtection/tests/Integration/ObfuscateAnnotatedMessagesTest.php @@ -0,0 +1,251 @@ +primaryKey = Key::createNewRandomKey(); + $this->secondaryKey = Key::createNewRandomKey(); + } + + public function test_fully_obfuscated_command_handler_message(): void + { + $ecotone = $this->bootstrapEcotoneWithCommandHandler($channel = QueueChannel::create('test'), $messageReceiver = new MessageReceiver()); + + $ecotone->sendCommand( + $messageSent = new FullyObfuscatedMessage( + class: new TestClass('value', TestEnum::FIRST), + enum: TestEnum::FIRST, + argument: 'value', + ), + metadata: $metadataSent = [ + 'foo' => 'secret-value', + 'bar' => 'even-more-secret-value', + 'baz' => 'non-sensitive-value', + ] + ); + + $channelMessage = $channel->receive(); + $messagePayload = Crypto::decrypt(base64_decode($channelMessage->getPayload()), $this->primaryKey); + $messageHeaders = $channelMessage->getHeaders(); + + self::assertEquals('{"class":{"argument":"value","enum":"first"},"enum":"first","argument":"value"}', $messagePayload); + self::assertEquals('secret-value', Crypto::decrypt(base64_decode($messageHeaders->get('foo')), $this->primaryKey)); + self::assertEquals('even-more-secret-value', Crypto::decrypt(base64_decode($messageHeaders->get('bar')), $this->primaryKey)); + self::assertEquals('non-sensitive-value', $messageHeaders->get('baz')); + + $ecotone->sendCommand($messageSent, metadata: $metadataSent); + $ecotone->run('test', ExecutionPollingMetadata::createWithTestingSetup()); + + $receivedHeaders = $messageReceiver->receivedHeaders(); + self::assertEquals($messageSent, $messageReceiver->receivedMessage()); + self::assertEquals('secret-value', $receivedHeaders['foo']); + self::assertEquals('even-more-secret-value', $receivedHeaders['bar']); + self::assertEquals('non-sensitive-value', $receivedHeaders['baz']); + } + + public function test_partially_obfuscated_command_handler_message(): void + { + $ecotone = $this->bootstrapEcotoneWithCommandHandler($channel = QueueChannel::create('test'), $messageReceiver = new MessageReceiver()); + + $ecotone->sendCommand( + $messageSent = new PartiallyObfuscatedMessage( + class: new TestClass('value', TestEnum::FIRST), + enum: TestEnum::FIRST, + argument: 'value', + ), + metadata: $metadataSent = [ + 'foo' => 'secret-value', + 'bar' => 'even-more-secret-value', + 'baz' => 'non-sensitive-value', + ] + ); + + $channelMessage = $channel->receive(); + $messagePayload = Crypto::decrypt(base64_decode($channelMessage->getPayload()), $this->primaryKey); + $messageHeaders = $channelMessage->getHeaders(); + + self::assertEquals('{"class":{"argument":"value","enum":"first"},"enum":"first","argument":"value"}', $messagePayload); + self::assertEquals('secret-value', Crypto::decrypt(base64_decode($messageHeaders->get('foo')), $this->primaryKey)); + self::assertEquals('even-more-secret-value', Crypto::decrypt(base64_decode($messageHeaders->get('bar')), $this->primaryKey)); + self::assertEquals('non-sensitive-value', $messageHeaders->get('baz')); + + $ecotone->sendCommand($messageSent, metadata: $metadataSent); + $ecotone->run('test', ExecutionPollingMetadata::createWithTestingSetup()); + + $receivedHeaders = $messageReceiver->receivedHeaders(); + self::assertEquals($messageSent, $messageReceiver->receivedMessage()); + self::assertEquals('secret-value', $receivedHeaders['foo']); + self::assertEquals('even-more-secret-value', $receivedHeaders['bar']); + self::assertEquals('non-sensitive-value', $receivedHeaders['baz']); + } + + public function test_obfuscate_command_handler_message_with_non_default_key(): void + { + $ecotone = $this->bootstrapEcotoneWithCommandHandler($channel = QueueChannel::create('test'), $messageReceiver = new MessageReceiver()); + + $ecotone->sendCommand($messageSent = new MessageWithSecondaryKeyEncryption(argument: 'value')); + + $channelMessage = $channel->receive(); + $messagePayload = Crypto::decrypt(base64_decode($channelMessage->getPayload()), $this->secondaryKey); + + self::assertEquals('{"argument":"value"}', $messagePayload); + + $ecotone->sendCommand($messageSent); + $ecotone->run('test', ExecutionPollingMetadata::createWithTestingSetup()); + + self::assertEquals($messageSent, $messageReceiver->receivedMessage()); + } + + public function test_fully_obfuscated_event_handler_message(): void + { + $ecotone = $this->bootstrapEcotoneWithEventHandler($channel = QueueChannel::create('test'), $messageReceiver = new MessageReceiver()); + + $ecotone->publishEvent( + $messageSent = new FullyObfuscatedMessage( + class: new TestClass('value', TestEnum::FIRST), + enum: TestEnum::FIRST, + argument: 'value', + ), + metadata: $metadataSent = [ + 'foo' => 'secret-value', + 'bar' => 'even-more-secret-value', + 'baz' => 'non-sensitive-value', + ] + ); + + $channelMessage = $channel->receive(); + $messagePayload = Crypto::decrypt(base64_decode($channelMessage->getPayload()), $this->primaryKey); + $messageHeaders = $channelMessage->getHeaders(); + + self::assertEquals('{"class":{"argument":"value","enum":"first"},"enum":"first","argument":"value"}', $messagePayload); + self::assertEquals('secret-value', Crypto::decrypt(base64_decode($messageHeaders->get('foo')), $this->primaryKey)); + self::assertEquals('even-more-secret-value', Crypto::decrypt(base64_decode($messageHeaders->get('bar')), $this->primaryKey)); + self::assertEquals('non-sensitive-value', $messageHeaders->get('baz')); + + $ecotone->publishEvent($messageSent, metadata: $metadataSent); + $ecotone->run('test', ExecutionPollingMetadata::createWithTestingSetup()); + + $receivedHeaders = $messageReceiver->receivedHeaders(); + self::assertEquals($messageSent, $messageReceiver->receivedMessage()); + self::assertEquals('secret-value', $receivedHeaders['foo']); + self::assertEquals('even-more-secret-value', $receivedHeaders['bar']); + self::assertEquals('non-sensitive-value', $receivedHeaders['baz']); + } + + public function test_partially_obfuscated_event_handler_message(): void + { + $ecotone = $this->bootstrapEcotoneWithEventHandler($channel = QueueChannel::create('test'), $messageReceiver = new MessageReceiver()); + + $ecotone->publishEvent( + $messageSent = new PartiallyObfuscatedMessage( + class: new TestClass('value', TestEnum::FIRST), + enum: TestEnum::FIRST, + argument: 'value', + ), + metadata: $metadataSent = [ + 'foo' => 'secret-value', + 'bar' => 'even-more-secret-value', + 'baz' => 'non-sensitive-value', + ] + ); + + $channelMessage = $channel->receive(); + $messagePayload = Crypto::decrypt(base64_decode($channelMessage->getPayload()), $this->primaryKey); + $messageHeaders = $channelMessage->getHeaders(); + + self::assertEquals('{"class":{"argument":"value","enum":"first"},"enum":"first","argument":"value"}', $messagePayload); + self::assertEquals('secret-value', Crypto::decrypt(base64_decode($messageHeaders->get('foo')), $this->primaryKey)); + self::assertEquals('even-more-secret-value', Crypto::decrypt(base64_decode($messageHeaders->get('bar')), $this->primaryKey)); + self::assertEquals('non-sensitive-value', $messageHeaders->get('baz')); + + $ecotone->publishEvent($messageSent, metadata: $metadataSent); + $ecotone->run('test', ExecutionPollingMetadata::createWithTestingSetup()); + + $receivedHeaders = $messageReceiver->receivedHeaders(); + self::assertEquals($messageSent, $messageReceiver->receivedMessage()); + self::assertEquals('secret-value', $receivedHeaders['foo']); + self::assertEquals('even-more-secret-value', $receivedHeaders['bar']); + self::assertEquals('non-sensitive-value', $receivedHeaders['baz']); + } + + public function test_obfuscate_event_handler_message_with_non_default_key(): void + { + $ecotone = $this->bootstrapEcotoneWithEventHandler($channel = QueueChannel::create('test'), $messageReceiver = new MessageReceiver()); + + $ecotone->publishEvent($messageSent = new MessageWithSecondaryKeyEncryption(argument: 'value')); + + $channelMessage = $channel->receive(); + $messagePayload = Crypto::decrypt(base64_decode($channelMessage->getPayload()), $this->secondaryKey); + + self::assertEquals('{"argument":"value"}', $messagePayload); + + $ecotone->publishEvent($messageSent); + $ecotone->run('test', ExecutionPollingMetadata::createWithTestingSetup()); + + self::assertEquals($messageSent, $messageReceiver->receivedMessage()); + } + + private function bootstrapEcotoneWithCommandHandler(MessageChannel $messageChannel, MessageReceiver $messageReceiver): FlowTestSupport + { + return $this->bootstrapEcotone([TestCommandHandler::class], [new TestCommandHandler()], $messageChannel, $messageReceiver); + } + + private function bootstrapEcotoneWithEventHandler(MessageChannel $messageChannel, MessageReceiver $messageReceiver): FlowTestSupport + { + return $this->bootstrapEcotone([TestEventHandler::class], [new TestEventHandler()], $messageChannel, $messageReceiver); + } + + private function bootstrapEcotone(array $classesToResolve, array $container, MessageChannel $messageChannel, MessageReceiver $receivedMessage): FlowTestSupport + { + return EcotoneLite::bootstrapFlowTesting( + classesToResolve: $classesToResolve, + containerOrAvailableServices: array_merge([ + $receivedMessage, + AmqpConnectionFactory::class => AmqpMessagingTestCase::getRabbitConnectionFactory(), + AmqpExtConnection::class => AmqpMessagingTestCase::getRabbitConnectionFactory(), + AmqpLibConnection::class => AmqpMessagingTestCase::getRabbitConnectionFactory(), + ], $container), + configuration: ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::AMQP_PACKAGE, ModulePackageList::ASYNCHRONOUS_PACKAGE, ModulePackageList::DATA_PROTECTION_PACKAGE, ModulePackageList::JMS_CONVERTER_PACKAGE])) + ->withNamespaces(['Test\Ecotone\DataProtection\Fixture\ObfuscateAnnotatedMessages']) + ->withExtensionObjects([ + DataProtectionConfiguration::create('primary', $this->primaryKey) + ->withKey('secondary', $this->secondaryKey), + SimpleMessageChannelBuilder::create('test', $messageChannel), + JMSConverterConfiguration::createWithDefaults()->withDefaultEnumSupport(true), + ]) + ); + } +} diff --git a/packages/DataProtection/tests/Unit/MessageObfuscatorTest.php b/packages/DataProtection/tests/Unit/MessageObfuscatorTest.php new file mode 100644 index 000000000..49066d74a --- /dev/null +++ b/packages/DataProtection/tests/Unit/MessageObfuscatorTest.php @@ -0,0 +1,52 @@ +message = MessageBuilder::withPayload(json_encode([ + 'foo' => 'value', + 'bar' => 'value', + ], JSON_THROW_ON_ERROR)) + ->setHeader(MessageHeaders::TYPE_ID, ObfuscatedMessage::class) + ->setHeader('foo', 'bar') + ->build() + ; + + $this->messageWithoutTypeId = MessageBuilder::withPayload(json_encode([ + 'foo' => 'value', + 'bar' => 'value', + ], JSON_THROW_ON_ERROR)) + ->setHeader('foo', 'bar') + ->build() + ; + } + + public function test_obfuscate_only_supported_message(): void + { + $messageObfuscator = new MessageObfuscator(); + + self::assertSame($this->message, $messageObfuscator->encrypt($this->message)); + self::assertSame($this->messageWithoutTypeId, $messageObfuscator->encrypt($this->messageWithoutTypeId)); + + self::assertSame($this->message, $messageObfuscator->decrypt($this->message)); + self::assertSame($this->messageWithoutTypeId, $messageObfuscator->decrypt($this->messageWithoutTypeId)); + } +} diff --git a/packages/Ecotone/src/Messaging/Channel/PollableChannel/Serialization/OutboundSerializationChannelInterceptor.php b/packages/Ecotone/src/Messaging/Channel/PollableChannel/Serialization/OutboundSerializationChannelInterceptor.php index 51ee82584..c863f084c 100644 --- a/packages/Ecotone/src/Messaging/Channel/PollableChannel/Serialization/OutboundSerializationChannelInterceptor.php +++ b/packages/Ecotone/src/Messaging/Channel/PollableChannel/Serialization/OutboundSerializationChannelInterceptor.php @@ -16,7 +16,7 @@ /** * licence Apache-2.0 */ -final class OutboundSerializationChannelInterceptor extends AbstractChannelInterceptor implements ChannelInterceptor +final class OutboundSerializationChannelInterceptor extends AbstractChannelInterceptor { public function __construct( private OutboundMessageConverter $outboundMessageConverter, @@ -27,13 +27,13 @@ public function __construct( /** * @inheritDoc */ - public function preSend(Message $messageToConvert, MessageChannel $messageChannel): ?Message + public function preSend(Message $message, MessageChannel $messageChannel): ?Message { - if ($messageToConvert instanceof ErrorMessage) { - return $messageToConvert; + if ($message instanceof ErrorMessage) { + return $message; } - $outboundMessage = $this->outboundMessageConverter->prepare($messageToConvert, $this->conversionService); + $outboundMessage = $this->outboundMessageConverter->prepare($message, $this->conversionService); $preparedMessage = MessageBuilder::withPayload($outboundMessage->getPayload()) ->setMultipleHeaders($outboundMessage->getHeaders()); diff --git a/packages/Ecotone/src/Messaging/Channel/PollableChannelInterceptorAdapter.php b/packages/Ecotone/src/Messaging/Channel/PollableChannelInterceptorAdapter.php index 0220c4fbf..a987e5e74 100644 --- a/packages/Ecotone/src/Messaging/Channel/PollableChannelInterceptorAdapter.php +++ b/packages/Ecotone/src/Messaging/Channel/PollableChannelInterceptorAdapter.php @@ -89,7 +89,7 @@ private function receiveFor(?PollingMetadata $pollingMetadata): ?Message } foreach ($this->sortedChannelInterceptors as $channelInterceptor) { - $channelInterceptor->postReceive($message, $this->messageChannel); + $message = $channelInterceptor->postReceive($message, $this->messageChannel); } return $message; diff --git a/packages/Ecotone/src/Messaging/Config/ModuleClassList.php b/packages/Ecotone/src/Messaging/Config/ModuleClassList.php index 846295781..58c55f1fd 100644 --- a/packages/Ecotone/src/Messaging/Config/ModuleClassList.php +++ b/packages/Ecotone/src/Messaging/Config/ModuleClassList.php @@ -8,6 +8,7 @@ use Ecotone\Amqp\Configuration\RabbitConsumerModule; use Ecotone\Amqp\Publisher\AmqpMessagePublisherModule; use Ecotone\Amqp\Transaction\AmqpTransactionModule; +use Ecotone\DataProtection\Configuration\DataProtectionModule; use Ecotone\Dbal\Configuration\DbalConnectionModule; use Ecotone\Dbal\Configuration\DbalPublisherModule; use Ecotone\Dbal\Database\DatabaseSetupModule; @@ -204,4 +205,8 @@ class ModuleClassList public const KAFKA_MODULES = [ KafkaModule::class, ]; + + const DATA_PROTECTION_MODULES = [ + DataProtectionModule::class, + ]; } diff --git a/packages/Ecotone/src/Messaging/Config/ModulePackageList.php b/packages/Ecotone/src/Messaging/Config/ModulePackageList.php index 51adc667b..32b8e2e1d 100644 --- a/packages/Ecotone/src/Messaging/Config/ModulePackageList.php +++ b/packages/Ecotone/src/Messaging/Config/ModulePackageList.php @@ -15,6 +15,7 @@ final class ModulePackageList */ public const ASYNCHRONOUS_PACKAGE = 'asynchronous'; public const AMQP_PACKAGE = 'amqp'; + public const DATA_PROTECTION_PACKAGE = 'dataProtection'; public const DBAL_PACKAGE = 'dbal'; public const REDIS_PACKAGE = 'redis'; public const SQS_PACKAGE = 'sqs'; @@ -42,6 +43,7 @@ public static function getModuleClassesForPackage(string $packageName): array ModulePackageList::TEST_PACKAGE => ModuleClassList::TEST_MODULES, ModulePackageList::LARAVEL_PACKAGE => ModuleClassList::LARAVEL_MODULES, ModulePackageList::SYMFONY_PACKAGE => ModuleClassList::SYMFONY_MODULES, + ModulePackageList::DATA_PROTECTION_PACKAGE => ModuleClassList::DATA_PROTECTION_MODULES, default => throw ConfigurationException::create(sprintf('Given unknown package name %s. Available packages name are: %s', $packageName, implode(',', self::allPackages()))) }; } @@ -64,6 +66,7 @@ public static function allPackages(): array self::TRACING_PACKAGE, self::LARAVEL_PACKAGE, self::SYMFONY_PACKAGE, + self::DATA_PROTECTION_PACKAGE, ]; } diff --git a/packages/Ecotone/src/Messaging/Conversion/AutoCollectionConversionService.php b/packages/Ecotone/src/Messaging/Conversion/AutoCollectionConversionService.php index 46b4f4964..0e38f7404 100644 --- a/packages/Ecotone/src/Messaging/Conversion/AutoCollectionConversionService.php +++ b/packages/Ecotone/src/Messaging/Conversion/AutoCollectionConversionService.php @@ -63,7 +63,6 @@ public function convert($source, Type $sourcePHPType, MediaType $sourceMediaType public function canConvert(Type $sourceType, MediaType $sourceMediaType, Type $targetType, MediaType $targetMediaType): bool { if ($this->getConverter($sourceType, $sourceMediaType, $targetType, $targetMediaType)) { - ; return true; } if ($sourceType->isIterable() && $sourceType instanceof Type\GenericType diff --git a/packages/Ecotone/src/Messaging/Conversion/SerializedToObject/DeserializingConverter.php b/packages/Ecotone/src/Messaging/Conversion/SerializedToObject/DeserializingConverter.php index b0ed7d5d9..4a7e60674 100644 --- a/packages/Ecotone/src/Messaging/Conversion/SerializedToObject/DeserializingConverter.php +++ b/packages/Ecotone/src/Messaging/Conversion/SerializedToObject/DeserializingConverter.php @@ -20,9 +20,6 @@ */ class DeserializingConverter implements Converter { - /** - * @inheritDoc - */ public function convert($source, Type $sourceType, MediaType $sourceMediaType, Type $targetType, MediaType $targetMediaType, ?ConversionService $conversionService = null) { $phpVar = unserialize(stripslashes($source)); @@ -36,9 +33,6 @@ public function convert($source, Type $sourceType, MediaType $sourceMediaType, T return $phpVar; } - /** - * @inheritDoc - */ public function matches(Type $sourceType, MediaType $sourceMediaType, Type $targetType, MediaType $targetMediaType): bool { return $sourceMediaType->isCompatibleWithParsed(MediaType::APPLICATION_X_PHP_SERIALIZED) diff --git a/packages/Ecotone/src/Messaging/Handler/ClassDefinition.php b/packages/Ecotone/src/Messaging/Handler/ClassDefinition.php index 927a9e413..9c75584a6 100644 --- a/packages/Ecotone/src/Messaging/Handler/ClassDefinition.php +++ b/packages/Ecotone/src/Messaging/Handler/ClassDefinition.php @@ -151,13 +151,12 @@ public function getPropertiesWithAnnotation(Type $annotationClass): array /** * @return object[] */ - public function getClassAnnotations(): array + public function getClassAnnotations(?ObjectType $annotationType = null): array { - return $this->classAnnotations; - } + if ($annotationType === null) { + return $this->classAnnotations; + } - public function getSingleClassAnnotation(ObjectType $annotationType): object - { $foundAnnotations = []; foreach ($this->classAnnotations as $classAnnotation) { if ($annotationType->accepts($classAnnotation)) { @@ -165,6 +164,13 @@ public function getSingleClassAnnotation(ObjectType $annotationType): object } } + return $foundAnnotations; + } + + public function getSingleClassAnnotation(ObjectType $annotationType): object + { + $foundAnnotations = $this->getClassAnnotations($annotationType); + if (count($foundAnnotations) < 1) { throw InvalidArgumentException::create("Attribute {$annotationType} was not found for {$this}"); } @@ -175,6 +181,13 @@ public function getSingleClassAnnotation(ObjectType $annotationType): object return $foundAnnotations[0]; } + public function findSingleClassAnnotation(ObjectType $annotationType): ?object + { + $foundAnnotations = $this->getClassAnnotations($annotationType); + + return $foundAnnotations[0] ?? null; + } + public function isAnnotation(): bool { return $this->isAnnotation; diff --git a/packages/local_packages.json b/packages/local_packages.json index b97dbf8d1..bf61019b7 100644 --- a/packages/local_packages.json +++ b/packages/local_packages.json @@ -12,6 +12,10 @@ "type": "path", "url": "Dbal" }, + { + "type": "path", + "url": "DataProtection" + }, { "type": "path", "url": "Enqueue"