From 1835307b3e68affeda7e1a9a2584eb6aac1c763b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20H=C3=A9lias?= Date: Fri, 7 Apr 2023 16:01:11 +0200 Subject: [PATCH] Improve CI & PHPDoc --- .gitattributes | 10 +- .github/stale.yml | 22 ++ .github/workflows/ci.yml | 43 --- .github/workflows/phpunit.yml | 93 ++++++ .gitignore | 9 +- LICENSE | 12 +- README.md | 106 +++---- composer.json | 30 +- config/services.php | 93 ++++++ phpunit.xml | 42 +++ phpunit.xml.dist | 28 -- ...tionKeyStringProviderGeneratorCommand.php} | 6 +- src/Command/FieldIndexPlannerCommand.php | 6 +- src/Command/GenerateIndexesCommand.php | 25 +- src/Configuration/EncryptedField.php | 11 +- src/Configuration/IndexableField.php | 6 +- .../Compiler/IndexGeneratorPass.php | 49 +++ ...DoctrineCiphersweetEncryptionExtension.php | 16 +- src/Encryptors/CiphersweetEncryptor.php | 81 ++++- src/Encryptors/EncryptorInterface.php | 19 +- src/Entity/IndexedEntityAttributeTrait.php | 1 - src/Entity/IndexedEntityInterface.php | 2 - src/Entity/IndexedEntityTrait.php | 1 - ...MissingPropertyFromReflectionException.php | 3 - src/Exception/UndefinedGeneratorException.php | 3 - ...ndbDoctrineCiphersweetEncryptionBundle.php | 18 +- src/Resources/config/encryption-services.yml | 89 ------ src/Services/IndexableFieldsService.php | 165 +++++----- src/Services/IndexesGenerator.php | 48 ++- .../IndexesGeneratorInterface.php | 8 +- .../IndexesGenerators/TokenizerGenerator.php | 19 +- .../ValueEndingByGenerator.php | 10 +- .../ValueStartingByGenerator.php | 6 +- src/Services/PropertyHydratorService.php | 32 +- .../DoctrineCiphersweetSubscriber.php | 286 +++++++++--------- tests/App/.gitignore | 2 + tests/App/Controller/TestController.php | 60 ++++ .../CiphersweetEncryptorObservable.php | 11 +- tests/App/Kernel.php | 42 +-- tests/App/Model/MyEntityAttribute.php | 97 ++++++ tests/App/Model/MyEntityAttributeIndexes.php | 22 ++ .../MyEntityAttributeIndexesRepository.php | 17 ++ .../MyEntityAttributeRepository.php} | 8 +- tests/App/bin/console | 42 --- tests/App/config.yaml | 24 ++ tests/App/config/bootstrap.php | 25 -- tests/App/config/bundles.php | 7 - tests/App/config/doctrine74.yaml | 27 -- tests/App/config/doctrine80.yaml | 33 -- tests/App/config/packages/framework.yaml | 2 - tests/App/config/packages/routing.yaml | 3 - tests/App/config/services.yaml | 4 - tests/App/doctrine.yaml | 19 ++ tests/App/routing.yaml | 14 + tests/App/services.yaml | 19 ++ tests/Functional/FunctionalTest.php | 55 ++++ tests/Model/Annotations/MyEntity.php | 66 ---- tests/Model/Attributes/MyEntityAttribute.php | 59 ---- tests/Repository/MyEntityRepository.php | 19 -- .../Encryptors/CiphersweetEncryptorTest.php | 30 +- .../TokenizerGeneratorTest.php | 3 +- .../ValueEndingByGeneratorTest.php | 3 +- .../ValueStartingByGeneratorTest.php | 3 +- .../DoctrineCiphersweetSubscriberTest.php | 25 +- tests/bootstrap.php | 14 - tests/phpunit-bootstrap.php | 30 ++ 66 files changed, 1190 insertions(+), 993 deletions(-) create mode 100644 .github/stale.yml delete mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/phpunit.yml create mode 100644 config/services.php create mode 100644 phpunit.xml delete mode 100644 phpunit.xml.dist rename src/Command/{EncryptionKeyStringProviderGenerator.php => EncryptionKeyStringProviderGeneratorCommand.php} (93%) create mode 100644 src/DependencyInjection/Compiler/IndexGeneratorPass.php delete mode 100644 src/Resources/config/encryption-services.yml create mode 100644 tests/App/.gitignore create mode 100644 tests/App/Controller/TestController.php rename tests/{ => App}/Encryptors/CiphersweetEncryptorObservable.php (66%) create mode 100644 tests/App/Model/MyEntityAttribute.php create mode 100644 tests/App/Model/MyEntityAttributeIndexes.php create mode 100644 tests/App/Repository/MyEntityAttributeIndexesRepository.php rename tests/{Repository/MyEntityRepositoryAttribute.php => App/Repository/MyEntityAttributeRepository.php} (56%) delete mode 100644 tests/App/bin/console create mode 100644 tests/App/config.yaml delete mode 100644 tests/App/config/bootstrap.php delete mode 100644 tests/App/config/bundles.php delete mode 100644 tests/App/config/doctrine74.yaml delete mode 100644 tests/App/config/doctrine80.yaml delete mode 100644 tests/App/config/packages/framework.yaml delete mode 100644 tests/App/config/packages/routing.yaml delete mode 100644 tests/App/config/services.yaml create mode 100644 tests/App/doctrine.yaml create mode 100644 tests/App/routing.yaml create mode 100644 tests/App/services.yaml create mode 100644 tests/Functional/FunctionalTest.php delete mode 100644 tests/Model/Annotations/MyEntity.php delete mode 100644 tests/Model/Attributes/MyEntityAttribute.php delete mode 100644 tests/Repository/MyEntityRepository.php delete mode 100644 tests/bootstrap.php create mode 100644 tests/phpunit-bootstrap.php diff --git a/.gitattributes b/.gitattributes index 304e5fa..b9724f1 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,8 +2,8 @@ # https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html # Ignore all test and documentation with "export-ignore". -/.github export-ignore -/tests export-ignore -/.gitattributes export-ignore -/.gitignore export-ignore -/phpunit.xml.dist export-ignore +/.github export-ignore +/tests export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/phpunit.xml export-ignore diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 0000000..84ecd4c --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,22 @@ +# Number of days of inactivity before an issue becomes stale +daysUntilStale: 60 + +# Number of days of inactivity before a stale issue is closed +daysUntilClose: 7 + +# Issues with these labels will never be considered stale +exemptLabels: + - bug + - enhancement + - RFC + +# Label to use when marking an issue as stale +staleLabel: stale + +# Comment to post when marking an issue as stale. Set to `false` to disable +markComment: > + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. Thank you + for your contributions. +# Comment to post when closing a stale issue. Set to `false` to disable +closeComment: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 9a67b46..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: ci - -on: - pull_request: - branches: [ master ] - -jobs: - build: - - runs-on: ubuntu-latest - - strategy: - fail-fast: false - matrix: - php-version: [ '7.4', '8.0', '8.1', '8.2' ] - - steps: - - name: checkout - uses: actions/checkout@v3 - - - name: php-setup - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php-version }} - - - name: composer-validate - run: composer validate - - - name: composer-cache - id: composer-cache - uses: actions/cache@v3 - with: - path: vendor - key: ${{ runner.os }}-${{ matrix.php-version }}-composer-${{ hashFiles('**/composer.lock') }} - restore-keys: | - ${{ runner.os }}-${{ matrix.php-version }}-composer- - - - name: composer-install - if: steps.composer-cache.outputs.cache-hit != 'true' - run: composer install --prefer-dist --no-progress --no-suggest - - - name: phpunit - run: vendor/bin/phpunit diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml new file mode 100644 index 0000000..99dcb1f --- /dev/null +++ b/.github/workflows/phpunit.yml @@ -0,0 +1,93 @@ +name: PHPUnit + +on: + push: + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + + test: + name: "PHP ${{ matrix.php }} - Symfony ${{ matrix.symfony }}" + runs-on: ubuntu-latest + continue-on-error: ${{ matrix.can-fail }} + + strategy: + fail-fast: false + matrix: + include: + # Lowest Deps + - php: 8.0 + symfony: 5.4.* + coverage: 'none' + composer-flags: '--prefer-stable --prefer-lowest' + can-fail: false + # LTS with latest stable PHP + - php: latest + symfony: 5.4.* + coverage: 'none' + composer-flags: '--prefer-stable' + can-fail: false + # Active release + - php: latest + symfony: 6.2.* + coverage: pcov + composer-flags: '--prefer-stable' + can-fail: false + # Development release + - php: nightly + symfony: 6.3.*@dev + coverage: 'none' + composer-flags: '' + can-fail: true + + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + tools: composer:v2, flex + coverage: ${{ matrix.coverage }} + ini-values: date.timezone=UTC,memory_limit=-1,session.gc_probability=0,apc.enable_cli=1,zend.assertions=1 + env: + fail-fast: true + + - name: Set Composer stability + if: matrix.symfony == '6.3.*@dev' + run: "composer config minimum-stability dev" + + - name: Get composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Cache composer dependencies + uses: actions/cache@v3 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-composer- + + - name: Install Composer dependencies + run: composer update ${{ matrix.composer-flags }} --no-interaction --no-progress --optimize-autoloader + env: + SYMFONY_REQUIRE: ${{ matrix.symfony }} + + - name: Run tests + run: composer test + + - name: Monitor coverage + if: matrix.coverage != 'none' + uses: slavcodev/coverage-monitor-action@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + coverage_path: coverage-report.xml + threshold_alert: 60 + threshold_warning: 80 diff --git a/.gitignore b/.gitignore index 5cf8cb5..f49ca77 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,5 @@ -/.idea/ -/tests/App/var -/tests/coverage/ /vendor/ -.DS_Store -composer.lock +.php_cs.cache .phpunit.result.cache -phpunit.xml +composer.lock +coverage-report.xml diff --git a/LICENSE b/LICENSE index 8aa2645..d345d08 100644 --- a/LICENSE +++ b/LICENSE @@ -1,13 +1,11 @@ -MIT License - -Copyright (c) [year] [fullname] +Copyright (c) 2021-present OD&B Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. @@ -17,5 +15,5 @@ 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. +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md index beebc9d..a6e2355 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Doctrine Ciphersweet Encryption Bundle ## Introduction + This bundle aims to make life easier to developers who want to set encrypted fields in their entities thanks to Ciphersweet library. This bundle is inspired from the talk given at Afup ForumPHP : [REX sur le chiffrement de base de données](https://afup.org/talks/3455-rex-sur-le-chiffrement-de-base-de-donnees). We also used the WIP public repository : https://github.com/PhilETaylor/doctrine-ciphersweet. @@ -40,7 +41,8 @@ return [ ## Usage ### 1. Setup DOCTRINE_CIPHERSWEET_KEY environment key -This bundle comes with several commands and annotations but first of all, you'll need to setup the `DOCTRINE_CIPHERSWEET_KEY` secret environment key. + +This bundle comes with several commands and annotations/attributes but first of all, you'll need to setup the `DOCTRINE_CIPHERSWEET_KEY` secret environment key. First of all, you can just init a random one in your environment file. ```php @@ -60,31 +62,33 @@ php bin/console odb:enc:generate-string-key | php bin/console secrets:set DOCTRI ``` Then remove the entry in your .env file -### 2. Add annotations to your entities -This bundle comes with 2 annotations in order to set encryption fields : +### 2. Add annotations / attributes to your entities + +This bundle comes with 2 annotations / attributes in order to set encryption fields : -- @EncryptedField : Marks the field as encrypted and will automatically use Ciphersweet library to encrypt/decrypt on onFlush and onLoad events -- @IndexableField : Marks the field as searchable and several indexes can be generated in a separate table in order to search by terms starting by or ending by. +- **EncryptedField** : Marks the field as encrypted and will automatically use Ciphersweet library to encrypt/decrypt on onFlush and onLoad events +- **IndexableField** : Marks the field as searchable and several indexes can be generated in a separate table in order to search by terms starting by or ending by. -#### @EncrytedField -This annotation comes with 3 options: +#### EncrytedField +This annotation/attribute comes with 3 options: -- (int) $filterBits : Number of bits used for encryption (Default : 32) -- (bool) $indexable : Activate a default index for exact search apart of the @IndexableField annotation. If true, will try to set Index string data into a field named with a "_bi" suffix -- (string) $mappedTypedProperty: If you want to encrypt data other than strings (other types currenty supported : int, float, bool), you'll need to set a string field used by doctrine for persistance purpose instead of your raw field. Then use this parameter to automatically hydrate decrypted data into the target field. +- (int) $filterBits : Number of bits used for encryption. (Default : `32`) +- (bool) $indexable : Activate a default index for exact search apart of the **IndexableField** annotation. If true, will try to set **index** string data into a field named with a "_bi" suffix. (Default : `true`) +- (string) $mappedTypedProperty: If you want to encrypt data other than strings (other types currenty supported : int, float, bool), you'll need to set a string field used by doctrine for persistance purpose instead of your raw field. Then use this parameter to automatically hydrate decrypted data into the target field. (Default : `null`) -#### @IndexableField -This annotation comes with 4 options: +#### IndexableField +This annotation/attribute comes with 4 options: -- (bool) $autoRefresh : Automatically regenerate related indexes to an entity upon persist or update event. - (string) $indexesEntityClass : Name of the entity class that will store the indexes (can be mutualized) -- (string) $valuePreprocessMethod : Before indexes generation, you may need to clean your input in order to reduce the number of indexes to generate (trim value, slug it, etc.). You can do it by setting this option. For the moment, the method mention can only by related to the current entity class. -- (array) $indexesGenerationMethods : List of methods used to generate several searchable values from the original one. For example the `ValueStartingByGenerator` can take the value "abcdef" in order to generate indexes for ["a", "ab", "abc', "abcd", ...]. So that you can search entities with a field starting by those values. -- (bool) $fastIndexing : If true, will use a faster indexing method. +- (bool) $autoRefresh : Automatically regenerate related indexes to an entity upon persist or update event. (Default : `true`) +- (array) $indexesGenerationMethods : List of methods used to generate several searchable values from the original one. For example the `ValueStartingByGenerator` can take the value "abcdef" in order to generate indexes for ["a", "ab", "abc', "abcd", ...]. So that you can search entities with a field starting by those values. (Default : `[]`) +- (string) $valuePreprocessMethod : Before indexes generation, you may need to clean your input in order to reduce the number of indexes to generate (trim value, slug it, etc.). You can do it by setting this option. For the moment, the method mention can only by related to the current entity class. (Default : `null`) +- (bool) $fastIndexing : If true, will use a faster indexing method. (Default : `true`) ### 3. Generating indexes + To make the entities searchable, the library provides a feature called "Blind Index" which is a unique index calculated from the original value. -By default, we provide a default index field to every encrypted ones (using the $indexable option). If you need to setup a search of values starting by a term, you'll need the `@IndexableField` annotation and set a dedicated indexes table. +By default, we provide a default index field to every encrypted ones (using the $indexable option). If you need to setup a search of values starting by a term, you'll need the `IndexableField` annotation/attribute and set a dedicated indexes table. This dedicated entity must implement the `Odandb\DoctrineCiphersweetEncryptionBundle\Entity\IndexedEntityInterface` and you can use the `Odandb\DoctrineCiphersweetEncryptionBundle\Entity\IndexedEntityTrait` to make your life easier. Basically, this table will be composed of those columns : @@ -117,55 +121,38 @@ In this mode, the command will split the work in smaller chuncks and start subpr Refer to the command for more informations. ### 4. Here is a full example : + ```php -/** - * - * @ORM\Entity(repositoryClass="App\Repository\MySecretEntityRepository") - * @ORM\Table(indexes={ - * @ORM\Index(name="anum_blind_idx", columns={"account_number_bi"}), - * }) - */ +#[ORM\Entity(repositoryClass: App\Repository\MySecretEntityRepository::class) +#[ORM\Index(name: 'anum_blind_idx', columns: ['account_number_bi'])] class MySecretEntity { - /** - * @ORM\Id - * @ORM\GeneratedValue - * @ORM\Column(type="integer") - */ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] private int $id; - /** - * @var string - * @ORM\Column(type="string", length=36) - */ + #[ORM\Column(type: 'string', length: 36)] private string $uuid; - /** - * @ORM\Column(type="string") - * - * @EncryptedField - * @IndexableField(indexesEntityClass="App\Entity\MySecretEntityIndexes", autoRefresh=false, indexesGenerationMethods={"ValueStartingBy"}, valuePreprocessMethod="cleanAccountNumber") - */ + #[ORM\Column(type: 'string')] + #[EncryptedField] + #[IndexableField(indexesEntityClass: App\Entity\MySecretEntityIndexes::class, autoRefresh: false, indexesGenerationMethods: ['ValueStartingBy'], valuePreprocessMethod: 'cleanAccountNumber')] private string $accountNumber; - /** - * @ORM\Column(type="string", length=10) - */ + #[ORM\Column(type: 'string', length: 10)] private string $accountNumberBi; private int $secretNumber; - /** - * @ORM\Column(type="string") - * - * @EncryptedField(mappedTypedProperty="secretNumber", indexable=false) - */ + #[ORM\Column(type: 'string')] + #[EncryptedField(mappedTypedProperty: 'secretNumber', indexable: false)] private string $secretNumberEncrypted; /** * @var Collection|null - * @ORM\OneToMany(targetEntity="MySecretEntityIndexes", mappedBy="targetEntity", cascade={"persist"}) */ + #[ORM\OneToMany(targetEntity: MySecretEntityIndexes::class, mappedBy: 'targetEntity', cascade: ['persist'])] private ?Collection $indexes; /** @@ -191,33 +178,22 @@ class MySecretEntity use Odandb\DoctrineCiphersweetEncryptionBundle\Entity\IndexedEntityInterface; use Odandb\DoctrineCiphersweetEncryptionBundle\Entity\IndexedEntityTrait; + /** * Class storing indexes for MySecretEntity. - * - * @ORM\Entity(repositoryClass="App\Repository\MySecretEntityIndexesRepository") - * @ORM\Table(indexes={ - * @ORM\Index(name="blind_idx", columns={"index_bi"}), - * @ORM\Index(name="field_and_blind_idx", columns={"fieldname", "index_bi"}) - * }) */ +#[ORM\Entity(repositoryClass: App\Repository\MySecretEntityIndexesRepository::class) +#[ORM\Index(name: 'blind_idx', columns: ['index_bi'])] +#[ORM\Index(name: 'field_and_blind_idx', columns: ['fieldname', 'index_bi'])] class MySecretEntityIndexes implements IndexedEntityInterface { use IndexedEntityTrait; /** * @var MySecretEntity|null - * - * @ORM\ManyToOne(targetEntity="App\Entity\MySecretEntity", inversedBy="indexes") - * @ORM\JoinColumn(name="target_entity_id", referencedColumnName="id", onDelete="CASCADE") */ + #[ORM\ManyToOne(targetEntity: App\Entity\MySecretEntity::class, inversedBy: 'indexes')] + #[ORM\JoinColumn(name: 'target_entity_id', referencedColumnName: 'id', onDelete: 'CASCADE')] protected object $targetEntity; } ``` - -## More to come - -- [ ] Tests -- [ ] Add Indexes generator methods -- [ ] Your suggestions here ... - - diff --git a/composer.json b/composer.json index cffb404..3e76e47 100644 --- a/composer.json +++ b/composer.json @@ -1,13 +1,15 @@ { "name": "odandb/doctrine-ciphersweet-encryption-bundle", - "type": "symfony-bundle", "description": "Bridge between Doctrine and Ciphersweet libary in order to make encrypted and searchable fields", + "license": "MIT", + "type": "symfony-bundle", "keywords": [ + "symfony", + "bundle", "doctrine", "encryption", "cipher" ], - "license": "MIT", "authors": [ { "name": "Mathieu Girard", @@ -20,34 +22,32 @@ ], "require": { "php": ">=7.4", + "ext-mbstring": "*", "doctrine/annotations": "^2.0", - "doctrine/orm": "^2.7", + "doctrine/doctrine-bundle": "^2.7", + "doctrine/orm": "^2.14", "paragonie/ciphersweet": "^3.0 || ^4.0", "symfony/config": "^5.4 || ^6.0", "symfony/console": "^5.4 || ^6.0", "symfony/dependency-injection": "^5.4 || ^6.0", "symfony/deprecation-contracts": "^2.5 || ^3.2", + "symfony/framework-bundle": "^5.4 || ^6.0", "symfony/http-kernel": "^5.4 || ^6.0", "symfony/process": "^5.4 || ^6.0", "symfony/property-access": "^5.4 || ^6.0", "symfony/property-info": "^5.4 || ^6.0", - "symfony/yaml": "^5.4 || ^6.0" + "symfony/service-contracts": "^2.2 || ^3.2" }, "require-dev": { - "doctrine/doctrine-bundle": "^2.7", "phpunit/phpunit": "^9.5", - "roave/security-advisories": "dev-latest", + "symfony/browser-kit": "^5.4 || ^6.0", "symfony/doctrine-bridge": "^5.4 || ^6.0", - "symfony/dotenv": "^5.4 || ^6.0", - "symfony/framework-bundle": "^5.4 || ^6.0", - "symfony/phpunit-bridge": "^6.0" + "symfony/phpunit-bridge": "^6.2", + "symfony/yaml": "^5.4 || ^6.0" }, "suggest": { "phpdocumentor/reflection-docblock": "To use the PHPDoc" }, - "config": { - "sort-packages": true - }, "autoload": { "psr-4": { "Odandb\\DoctrineCiphersweetEncryptionBundle\\": "src/" @@ -57,5 +57,11 @@ "psr-4": { "Odandb\\DoctrineCiphersweetEncryptionBundle\\Tests\\": "tests/" } + }, + "config": { + "sort-packages": true + }, + "scripts": { + "test": "vendor/bin/phpunit --coverage-clover=coverage-report.xml" } } diff --git a/config/services.php b/config/services.php new file mode 100644 index 0000000..dbe897a --- /dev/null +++ b/config/services.php @@ -0,0 +1,93 @@ +services() + ->instanceof(IndexesGeneratorInterface::class) + ->tag('encryption.index_generator') + + // Paragon + ->set('encryption.paragon.string_provider', StringProvider::class) + ->args([ + env('DOCTRINE_CIPHERSWEET_KEY') + ]) + ->set('encryption.paragon.cipher_sweet', CipherSweet::class) + ->args([ + service('encryption.paragon.string_provider') + ]) + + // Command + ->set('encryption.console.key_string_provider_generator', EncryptionKeyStringProviderGeneratorCommand::class) + ->tag('console.command') + ->set('encryption.console.field_index_planner', FieldIndexPlannerCommand::class) + ->tag('console.command') + ->set('encryption.console.generate_indexes', GenerateIndexesCommand::class) + ->args([ + service('encryption.indexable_field') + ]) + ->tag('console.command') + + // Encryptors + ->set('encryption.encryptor.cipher_sweet', CiphersweetEncryptor::class) + ->args([ + service('encryption.paragon.cipher_sweet') + ]) + ->alias(EncryptorInterface::class, 'encryption.encryptor.cipher_sweet') + + // Indexes Generators + ->set('encryption.indexes_generator', IndexesGenerator::class) + ->args([ + abstract_arg('All services with tag "encryption.index_generator" are stored in a service locator by IndexGeneratorPass'), + service(EncryptorInterface::class) + ]) + ->set('encryption.indexes_generator.tokenizer', TokenizerGenerator::class) + ->tag('encryption.index_generator', ['key' => 'TokenizerGenerator']) + ->set('encryption.indexes_generator.value_starting_by', ValueStartingByGenerator::class) + ->tag('encryption.index_generator', ['key' => 'ValueStartingByGenerator']) + ->set('encryption.indexes_generator.value_ending_by', ValueEndingByGenerator::class) + ->tag('encryption.index_generator', ['key' => 'ValueEndingByGenerator']) + + ->set('encryption.indexable_field', IndexableFieldsService::class) + ->args([ + service('annotation_reader')->nullOnInvalid(), // @deprecated + service(EntityManagerInterface::class), + service('encryption.indexes_generator'), + service('property_accessor') + ]) + + // Property + ->set('encryption.property_hydrator', PropertyHydratorService::class) + ->args([ + service('property_info'), + service('property_accessor') + ]) + + ->set('encryption.subscriber', DoctrineCiphersweetSubscriber::class) + ->args([ + service('annotation_reader'), // @deprecated + service(EncryptorInterface::class), + service('encryption.indexable_field'), + service('encryption.property_hydrator') + ]) + ->tag('doctrine.event_subscriber') + ; +}; diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..07505b8 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + ./tests/Unit + + + ./tests/Functional + + + + + + ./src/ + + + src/Entity + src/Exception + + + + + + + diff --git a/phpunit.xml.dist b/phpunit.xml.dist deleted file mode 100644 index 1ec20cb..0000000 --- a/phpunit.xml.dist +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - - - - - - - - - tests - - - diff --git a/src/Command/EncryptionKeyStringProviderGenerator.php b/src/Command/EncryptionKeyStringProviderGeneratorCommand.php similarity index 93% rename from src/Command/EncryptionKeyStringProviderGenerator.php rename to src/Command/EncryptionKeyStringProviderGeneratorCommand.php index 4d15eae..52f2ff6 100644 --- a/src/Command/EncryptionKeyStringProviderGenerator.php +++ b/src/Command/EncryptionKeyStringProviderGeneratorCommand.php @@ -2,7 +2,6 @@ declare(strict_types=1); - namespace Odandb\DoctrineCiphersweetEncryptionBundle\Command; use ParagonIE\ConstantTime\Hex; @@ -13,7 +12,7 @@ use Symfony\Component\Console\Style\SymfonyStyle; #[AsCommand(name: 'odb:enc:generate-string-key', description: 'Generate default encryption key for StringProvider (one of the different key provider managed by Ciphersweet library).')] -class EncryptionKeyStringProviderGenerator extends Command +class EncryptionKeyStringProviderGeneratorCommand extends Command { /** @deprecated */ protected static $defaultName = 'odb:enc:generate-string-key'; @@ -29,6 +28,9 @@ protected function configure(): void ; } + /** + * @throws \Exception + */ protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); diff --git a/src/Command/FieldIndexPlannerCommand.php b/src/Command/FieldIndexPlannerCommand.php index 973b9fd..f845bb6 100644 --- a/src/Command/FieldIndexPlannerCommand.php +++ b/src/Command/FieldIndexPlannerCommand.php @@ -2,10 +2,9 @@ declare(strict_types=1); - namespace Odandb\DoctrineCiphersweetEncryptionBundle\Command; - +use ParagonIE\CipherSweet\Exception\PlannerException; use ParagonIE\CipherSweet\Planner\FieldIndexPlanner; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -30,6 +29,9 @@ protected function configure(): void ; } + /** + * @throws PlannerException + */ protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); diff --git a/src/Command/GenerateIndexesCommand.php b/src/Command/GenerateIndexesCommand.php index 4b2e2d0..cf1c452 100644 --- a/src/Command/GenerateIndexesCommand.php +++ b/src/Command/GenerateIndexesCommand.php @@ -2,9 +2,9 @@ declare(strict_types=1); - namespace Odandb\DoctrineCiphersweetEncryptionBundle\Command; +use Odandb\DoctrineCiphersweetEncryptionBundle\Exception\MissingPropertyFromReflectionException; use Odandb\DoctrineCiphersweetEncryptionBundle\Services\IndexableFieldsService; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -19,7 +19,6 @@ #[AsCommand(name: 'odb:enc:indexes', description: 'Determine the Blind Index plan for a given field.')] class GenerateIndexesCommand extends Command { - /** @deprecated */ protected static $defaultName = 'odb:enc:indexes'; /** @deprecated */ protected static $defaultDescription = 'Determine the Blind Index plan for a given field.'; @@ -59,6 +58,9 @@ protected function configure(): void ; } + /** + * @throws MissingPropertyFromReflectionException + */ protected function execute(InputInterface $input, OutputInterface $output): int { $this->io = new SymfonyStyle($input, $output); @@ -98,6 +100,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 0; } + /** + * @return array + */ protected function validateParallelOptions(InputInterface $input): array { $optionsInError = []; @@ -112,6 +117,11 @@ protected function validateParallelOptions(InputInterface $input): array return $optionsInError; } + /** + * @param array{nb_process: int, timeout: int, chuncks: int} $parallelConfig + * + * @throws MissingPropertyFromReflectionException + */ protected function initAndRunFiltersGenerationSubProcesses(string $className, array $parallelConfig): void { @@ -148,15 +158,15 @@ protected function initAndRunFiltersGenerationSubProcesses(string $className, ar $this->io->success('Done in ' . (time() - $start).'s'); } + /** + * @param array $pools + */ private function runProcesses(array $pools): void { $finishedProcesses = []; $isSomethingRunning = true; while ($isSomethingRunning) { $isSomethingRunning = false; - /** - * @var Process $process - */ foreach ($pools as $key => $process) { if ($process->isRunning()) { $isSomethingRunning = true; @@ -175,6 +185,9 @@ private function runProcesses(array $pools): void } } + /** + * @throws MissingPropertyFromReflectionException + */ protected function regenerateFiltersByFieldnameAndIds(string $className, ?string $fieldnames, ?string $ids, bool $purge = false): void { $fieldnamesAr = $fieldnames !== null ? explode(',', $fieldnames) : null; @@ -188,7 +201,7 @@ protected function regenerateFiltersByFieldnameAndIds(string $className, ?string } $this->io->comment('Generating Indexes'); - $this->indexableFieldsService->handleFilterableFieldsForChunck($className, $idsAr, $contexts, false); + $this->indexableFieldsService->handleFilterableFieldsForChunck($className, $idsAr, $contexts); if ($idsAr !== null) { $this->io->success(sprintf('Done for %s class and %d ids', $className, count($idsAr))); } else { diff --git a/src/Configuration/EncryptedField.php b/src/Configuration/EncryptedField.php index 4cd82de..2c8f7e3 100644 --- a/src/Configuration/EncryptedField.php +++ b/src/Configuration/EncryptedField.php @@ -2,7 +2,6 @@ declare(strict_types=1); - namespace Odandb\DoctrineCiphersweetEncryptionBundle\Configuration; use Attribute; @@ -22,18 +21,18 @@ class EncryptedField public int $filterBits = EncryptorInterface::DEFAULT_FILTER_BITS; /** @readonly */ - public ?string $mappedTypedProperty = null; + public bool $indexable = true; /** @readonly */ - public bool $indexable = true; + public ?string $mappedTypedProperty = null; public function __construct( int $filterBits = EncryptorInterface::DEFAULT_FILTER_BITS, - ?string $mappedTypedProperty = null, - bool $indexable = true + bool $indexable = true, + ?string $mappedTypedProperty = null ) { $this->filterBits = $filterBits; - $this->mappedTypedProperty = $mappedTypedProperty; $this->indexable = $indexable; + $this->mappedTypedProperty = $mappedTypedProperty; } } diff --git a/src/Configuration/IndexableField.php b/src/Configuration/IndexableField.php index 5e0c421..2c105de 100644 --- a/src/Configuration/IndexableField.php +++ b/src/Configuration/IndexableField.php @@ -2,7 +2,6 @@ declare(strict_types=1); - namespace Odandb\DoctrineCiphersweetEncryptionBundle\Configuration; use Attribute; @@ -24,7 +23,10 @@ class IndexableField /** @readonly */ public bool $autoRefresh = true; - /** @readonly */ + /** + * @readonly + * @var array + */ public array $indexesGenerationMethods = []; /** @readonly */ diff --git a/src/DependencyInjection/Compiler/IndexGeneratorPass.php b/src/DependencyInjection/Compiler/IndexGeneratorPass.php new file mode 100644 index 0000000..81bda69 --- /dev/null +++ b/src/DependencyInjection/Compiler/IndexGeneratorPass.php @@ -0,0 +1,49 @@ +has('encryption.indexes_generator')) { + return; + } + + $indexesGeneratorDefinition = $container->getDefinition('encryption.indexes_generator'); + + $mapping = []; + foreach ($container->findTaggedServiceIds('encryption.index_generator') as $id => $attributes) { + $class = $container->getDefinition($id)->getClass(); + if (method_exists($class, 'getIndexKey')) { + $mapping[$class::getIndexKey()] = new Reference($id); + + continue; + } + + $mapping[$attributes['key']] = new Reference($id); + } + + $services = $container->findTaggedServiceIds('odb.index_generator'); + if (\count($services) > 0) { + trigger_deprecation( + 'odandb/doctrine-ciphersweet-encryption-bundle', + '0.11', + 'The tag "odb.index_generator" is deprecated and will be remove in doctrine-ciphersweet-encryption-bundle 1.0' + ); + + foreach ($services as $id => $attributes) { + $mapping[$attributes['key']] = new Reference($id); + } + } + + $indexesGeneratorDefinition->replaceArgument(0, ServiceLocatorTagPass::register($container, $mapping)); + } +} diff --git a/src/DependencyInjection/DoctrineCiphersweetEncryptionExtension.php b/src/DependencyInjection/DoctrineCiphersweetEncryptionExtension.php index 9f34c7f..8548b1c 100644 --- a/src/DependencyInjection/DoctrineCiphersweetEncryptionExtension.php +++ b/src/DependencyInjection/DoctrineCiphersweetEncryptionExtension.php @@ -2,22 +2,22 @@ declare(strict_types=1); - namespace Odandb\DoctrineCiphersweetEncryptionBundle\DependencyInjection; +use Odandb\DoctrineCiphersweetEncryptionBundle\Services\IndexesGenerators\IndexesGeneratorInterface; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Extension\Extension; -use Symfony\Component\DependencyInjection\Loader; +use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; class DoctrineCiphersweetEncryptionExtension extends Extension { - /** - * @inheritDoc - */ - public function load(array $configs, ContainerBuilder $container) + public function load(array $configs, ContainerBuilder $container): void { - $loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); - $loader->load('encryption-services.yml'); + $loader = new PhpFileLoader($container, new FileLocator(__DIR__.'/../../config')); + $loader->load('services.php'); + + $container->registerForAutoconfiguration(IndexesGeneratorInterface::class) + ->addTag('encryption.index_generator'); } } diff --git a/src/Encryptors/CiphersweetEncryptor.php b/src/Encryptors/CiphersweetEncryptor.php index f55b663..2b89458 100644 --- a/src/Encryptors/CiphersweetEncryptor.php +++ b/src/Encryptors/CiphersweetEncryptor.php @@ -2,14 +2,18 @@ declare(strict_types=1); - namespace Odandb\DoctrineCiphersweetEncryptionBundle\Encryptors; use ParagonIE\CipherSweet\BlindIndex; use ParagonIE\CipherSweet\CipherSweet; use ParagonIE\CipherSweet\EncryptedField; +use ParagonIE\CipherSweet\Exception\BlindIndexNameCollisionException; +use ParagonIE\CipherSweet\Exception\BlindIndexNotFoundException; +use ParagonIE\CipherSweet\Exception\CipherSweetException; +use ParagonIE\CipherSweet\Exception\CryptoOperationException; +use Symfony\Contracts\Service\ResetInterface; -class CiphersweetEncryptor implements EncryptorInterface +class CiphersweetEncryptor implements EncryptorInterface, ResetInterface { private CipherSweet $engine; @@ -23,6 +27,15 @@ public function __construct(CipherSweet $engine) $this->biCache = []; } + /** + * {@inheritdoc} + * + * @throws CipherSweetException + * @throws CryptoOperationException + * @throws BlindIndexNotFoundException + * @throws BlindIndexNameCollisionException + * @throws \SodiumException + */ public function prepareForStorage(object $entity, string $fieldName, string $string, bool $index = true, int $filterBits = self::DEFAULT_FILTER_BITS, bool $fastIndexing = self::DEFAULT_FAST_INDEXING): array { $entitClassName = \get_class($entity); @@ -42,6 +55,13 @@ public function prepareForStorage(object $entity, string $fieldName, string $str return $this->doEncrypt($entitClassName, $fieldName, $string, $index, $filterBits, $fastIndexing); } + /** + * @throws CipherSweetException + * @throws CryptoOperationException + * @throws BlindIndexNotFoundException + * @throws BlindIndexNameCollisionException + * @throws \SodiumException + */ protected function doEncrypt(string $entitClassName, string $fieldName, string $string, bool $index = true, int $filterBits = self::DEFAULT_FILTER_BITS, bool $fastIndexing = self::DEFAULT_FAST_INDEXING): array { $encryptedField = (new EncryptedField($this->engine, $entitClassName, $fieldName)); @@ -53,9 +73,11 @@ protected function doEncrypt(string $entitClassName, string $fieldName, string $ $result = $encryptedField->prepareForStorage($string); + // Cache for encrypt/decrypt $this->cache[$entitClassName][$fieldName][$string] = $result[0]; $this->cache[$entitClassName][$fieldName][$result[0]] = $string; + // Cache blind index if ($index) { $this->biCache[$entitClassName][$fieldName][$string] = $result[1][$fieldName.'_bi']; } @@ -63,27 +85,46 @@ protected function doEncrypt(string $entitClassName, string $fieldName, string $ return $result; } - public function decrypt(string $entity_classname, string $fieldName, string $string, int $filterBits = self::DEFAULT_FILTER_BITS, bool $fastIndexing = self::DEFAULT_FAST_INDEXING): string + /** + * {@inheritdoc} + * + * @throws CipherSweetException + * @throws CryptoOperationException + */ + public function decrypt(string $entityClassName, string $fieldName, string $string, int $filterBits = self::DEFAULT_FILTER_BITS, bool $fastIndexing = self::DEFAULT_FAST_INDEXING): string { - if (isset($this->cache[$entity_classname][$fieldName][$string])) { - return $this->cache[$entity_classname][$fieldName][$string]; + if (isset($this->cache[$entityClassName][$fieldName][$string])) { + return $this->cache[$entityClassName][$fieldName][$string]; } - return $this->doDecrypt($entity_classname, $fieldName, $string); + return $this->doDecrypt($entityClassName, $fieldName, $string); } - protected function doDecrypt(string $entity_classname, string $fieldName, string $string): string + /** + * @throws CipherSweetException + * @throws CryptoOperationException + */ + protected function doDecrypt(string $entityClassName, string $fieldName, string $string): string { - $decryptedValue = (new EncryptedField($this->engine, $entity_classname, $fieldName)) + $decryptedValue = (new EncryptedField($this->engine, $entityClassName, $fieldName)) ->decryptValue($string); - $this->cache[$entity_classname][$fieldName][$string] = $decryptedValue; - $this->cache[$entity_classname][$fieldName][$decryptedValue] = $string; + $this->cache[$entityClassName][$fieldName][$string] = $decryptedValue; + $this->cache[$entityClassName][$fieldName][$decryptedValue] = $string; return $decryptedValue; } - public function getBlindIndex($entityName, $fieldName, string $value, int $filterBits = self::DEFAULT_FILTER_BITS, bool $fastIndexing = self::DEFAULT_FAST_INDEXING): string + /** + * {@inheritdoc} + * + * @throws CryptoOperationException + * @throws CipherSweetException + * @throws BlindIndexNotFoundException + * @throws BlindIndexNameCollisionException + * @throws \SodiumException + */ + public function getBlindIndex(string $entityName, string $fieldName, string $value, int $filterBits = self::DEFAULT_FILTER_BITS, bool $fastIndexing = self::DEFAULT_FAST_INDEXING): string { if (isset($this->biCache[$entityName][$fieldName][$value])) { return $this->biCache[$entityName][$fieldName][$value]; @@ -92,7 +133,14 @@ public function getBlindIndex($entityName, $fieldName, string $value, int $filte return $this->doGetBlindIndex($entityName, $fieldName, $value, $filterBits, $fastIndexing); } - private function doGetBlindIndex($entityName, $fieldName, string $value, int $filterBits = self::DEFAULT_FILTER_BITS, bool $fastIndexing = self::DEFAULT_FAST_INDEXING): string + /** + * @throws CryptoOperationException + * @throws CipherSweetException + * @throws BlindIndexNotFoundException + * @throws BlindIndexNameCollisionException + * @throws \SodiumException + */ + protected function doGetBlindIndex(string $entityName, string $fieldName, string $value, int $filterBits = self::DEFAULT_FILTER_BITS, bool $fastIndexing = self::DEFAULT_FAST_INDEXING): string { $index = (new EncryptedField($this->engine, $entityName, $fieldName)) ->addBlindIndex( @@ -105,8 +153,17 @@ private function doGetBlindIndex($entityName, $fieldName, string $value, int $fi return $index; } + /** + * {@inheritdoc} + */ public function getPrefix(): string { return $this->engine->getBackend()->getPrefix(); } + + public function reset(): void + { + $this->cache = []; + $this->biCache = []; + } } diff --git a/src/Encryptors/EncryptorInterface.php b/src/Encryptors/EncryptorInterface.php index a883147..2118109 100644 --- a/src/Encryptors/EncryptorInterface.php +++ b/src/Encryptors/EncryptorInterface.php @@ -2,7 +2,6 @@ declare(strict_types=1); - namespace Odandb\DoctrineCiphersweetEncryptionBundle\Encryptors; use ParagonIE\CipherSweet\CipherSweet; @@ -14,11 +13,25 @@ interface EncryptorInterface public function __construct(CipherSweet $engine); + /** + * Encrypt a value and calculate this blind indices + * + * @return array{0:string, 1: array} + */ public function prepareForStorage(object $entity, string $fieldName, string $string, bool $index = true, int $filterBits = self::DEFAULT_FILTER_BITS, bool $fastIndexing = self::DEFAULT_FAST_INDEXING): array; - public function decrypt(string $entity_classname, string $fieldName, string $string, int $filterBits = self::DEFAULT_FILTER_BITS, bool $fastIndexing = self::DEFAULT_FAST_INDEXING): string; + /** + * Decrypt a value + */ + public function decrypt(string $entityClassName, string $fieldName, string $string, int $filterBits = self::DEFAULT_FILTER_BITS, bool $fastIndexing = self::DEFAULT_FAST_INDEXING): string; - public function getBlindIndex($entityName, $fieldName, string $value, int $filterBits = self::DEFAULT_FILTER_BITS, bool $fastIndexing = self::DEFAULT_FAST_INDEXING): string; + /** + * Get the blind index of the field + */ + public function getBlindIndex(string $entityName, string $fieldName, string $value, int $filterBits = self::DEFAULT_FILTER_BITS, bool $fastIndexing = self::DEFAULT_FAST_INDEXING): string; + /** + * Get the prefix of the encryptor + */ public function getPrefix(): string; } diff --git a/src/Entity/IndexedEntityAttributeTrait.php b/src/Entity/IndexedEntityAttributeTrait.php index 502868b..ee10b87 100644 --- a/src/Entity/IndexedEntityAttributeTrait.php +++ b/src/Entity/IndexedEntityAttributeTrait.php @@ -2,7 +2,6 @@ declare(strict_types=1); - namespace Odandb\DoctrineCiphersweetEncryptionBundle\Entity; use Doctrine\ORM\Mapping as ORM; diff --git a/src/Entity/IndexedEntityInterface.php b/src/Entity/IndexedEntityInterface.php index 7b4351e..45688ff 100644 --- a/src/Entity/IndexedEntityInterface.php +++ b/src/Entity/IndexedEntityInterface.php @@ -2,10 +2,8 @@ declare(strict_types=1); - namespace Odandb\DoctrineCiphersweetEncryptionBundle\Entity; - interface IndexedEntityInterface { public function setTargetEntity(?object $targetEntity): self; diff --git a/src/Entity/IndexedEntityTrait.php b/src/Entity/IndexedEntityTrait.php index bb6a583..9a4377e 100644 --- a/src/Entity/IndexedEntityTrait.php +++ b/src/Entity/IndexedEntityTrait.php @@ -2,7 +2,6 @@ declare(strict_types=1); - namespace Odandb\DoctrineCiphersweetEncryptionBundle\Entity; use Doctrine\ORM\Mapping as ORM; diff --git a/src/Exception/MissingPropertyFromReflectionException.php b/src/Exception/MissingPropertyFromReflectionException.php index 60da974..a649e51 100644 --- a/src/Exception/MissingPropertyFromReflectionException.php +++ b/src/Exception/MissingPropertyFromReflectionException.php @@ -2,11 +2,8 @@ declare(strict_types=1); - namespace Odandb\DoctrineCiphersweetEncryptionBundle\Exception; - class MissingPropertyFromReflectionException extends \Exception { - } diff --git a/src/Exception/UndefinedGeneratorException.php b/src/Exception/UndefinedGeneratorException.php index 342a3b0..6e3eea9 100644 --- a/src/Exception/UndefinedGeneratorException.php +++ b/src/Exception/UndefinedGeneratorException.php @@ -2,11 +2,8 @@ declare(strict_types=1); - namespace Odandb\DoctrineCiphersweetEncryptionBundle\Exception; - class UndefinedGeneratorException extends \Exception { - } diff --git a/src/OdandbDoctrineCiphersweetEncryptionBundle.php b/src/OdandbDoctrineCiphersweetEncryptionBundle.php index 235ed13..2eb9ba7 100644 --- a/src/OdandbDoctrineCiphersweetEncryptionBundle.php +++ b/src/OdandbDoctrineCiphersweetEncryptionBundle.php @@ -2,12 +2,12 @@ declare(strict_types=1); - namespace Odandb\DoctrineCiphersweetEncryptionBundle; - +use Odandb\DoctrineCiphersweetEncryptionBundle\DependencyInjection\Compiler\IndexGeneratorPass; use Odandb\DoctrineCiphersweetEncryptionBundle\DependencyInjection\DoctrineCiphersweetEncryptionExtension; -use Symfony\Component\DependencyInjection\Extension\Extension; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Extension\ExtensionInterface; use Symfony\Component\HttpKernel\Bundle\Bundle; class OdandbDoctrineCiphersweetEncryptionBundle extends Bundle @@ -15,7 +15,7 @@ class OdandbDoctrineCiphersweetEncryptionBundle extends Bundle /** * Overridden to allow for the custom extension alias. */ - public function getContainerExtension(): Extension + public function getContainerExtension(): ?ExtensionInterface { if (null === $this->extension) { $this->extension = new DoctrineCiphersweetEncryptionExtension(); @@ -23,4 +23,14 @@ public function getContainerExtension(): Extension return $this->extension; } + + public function build(ContainerBuilder $container): void + { + $container->addCompilerPass(new IndexGeneratorPass()); + } + + public function getPath(): string + { + return \dirname(__DIR__); + } } diff --git a/src/Resources/config/encryption-services.yml b/src/Resources/config/encryption-services.yml deleted file mode 100644 index 4383722..0000000 --- a/src/Resources/config/encryption-services.yml +++ /dev/null @@ -1,89 +0,0 @@ -services: - Odandb\DoctrineCiphersweetEncryptionBundle\Subscribers\DoctrineCiphersweetSubscriber: - class: Odandb\DoctrineCiphersweetEncryptionBundle\Subscribers\DoctrineCiphersweetSubscriber - arguments: - - "@annotation_reader" - - "@Odandb\\DoctrineCiphersweetEncryptionBundle\\Encryptors\\CiphersweetEncryptor" - - "@Odandb\\DoctrineCiphersweetEncryptionBundle\\Services\\IndexableFieldsService" - - "@Odandb\\DoctrineCiphersweetEncryptionBundle\\Services\\PropertyHydratorService" - tags: - - { name: doctrine.event_subscriber } - - ParagonIE\CipherSweet\KeyProvider\StringProvider: - class: ParagonIE\CipherSweet\KeyProvider\StringProvider - public: true - arguments: ["%env(DOCTRINE_CIPHERSWEET_KEY)%"] - - ParagonIE\CipherSweet\CipherSweet: - class: ParagonIE\CipherSweet\CipherSweet - public: true - arguments: ["@ParagonIE\\CipherSweet\\KeyProvider\\StringProvider"] - - Odandb\DoctrineCiphersweetEncryptionBundle\Encryptors\CiphersweetEncryptor: - class: Odandb\DoctrineCiphersweetEncryptionBundle\Encryptors\CiphersweetEncryptor - public: true - arguments: ["@ParagonIE\\CipherSweet\\CipherSweet"] - - Odandb\DoctrineCiphersweetEncryptionBundle\Services\IndexableFieldsService: - class: Odandb\DoctrineCiphersweetEncryptionBundle\Services\IndexableFieldsService - public: true - arguments: - - "@annotation_reader" - - "@Doctrine\\ORM\\EntityManagerInterface" - - "@Odandb\\DoctrineCiphersweetEncryptionBundle\\Services\\IndexesGenerator" - - Odandb\DoctrineCiphersweetEncryptionBundle\Command\GenerateIndexesCommand: - class: Odandb\DoctrineCiphersweetEncryptionBundle\Command\GenerateIndexesCommand - public: true - arguments: - - "@Odandb\\DoctrineCiphersweetEncryptionBundle\\Services\\IndexableFieldsService" - tags: - - { name: console.command } - - Odandb\DoctrineCiphersweetEncryptionBundle\Command\FieldIndexPlannerCommand: - class: Odandb\DoctrineCiphersweetEncryptionBundle\Command\FieldIndexPlannerCommand - public: true - tags: - - { name: console.command } - - Odandb\DoctrineCiphersweetEncryptionBundle\Command\EncryptionKeyStringProviderGenerator: - class: Odandb\DoctrineCiphersweetEncryptionBundle\Command\EncryptionKeyStringProviderGenerator - public: true - tags: - - { name: console.command } - - Odandb\DoctrineCiphersweetEncryptionBundle\Services\IndexesGenerators\TokenizerGenerator: - class: Odandb\DoctrineCiphersweetEncryptionBundle\Services\IndexesGenerators\TokenizerGenerator - tags: - - { name: 'odb.index_generator', key: 'TokenizerGenerator' } - - Odandb\DoctrineCiphersweetEncryptionBundle\Services\IndexesGenerators\ValueStartingByGenerator: - class: Odandb\DoctrineCiphersweetEncryptionBundle\Services\IndexesGenerators\ValueStartingByGenerator - tags: - - { name: 'odb.index_generator', key: 'ValueStartingByGenerator'} - - Odandb\DoctrineCiphersweetEncryptionBundle\Services\IndexesGenerators\ValueEndingByGenerator: - class: Odandb\DoctrineCiphersweetEncryptionBundle\Services\IndexesGenerators\ValueEndingByGenerator - tags: - - { name: 'odb.index_generator', key: 'ValueEndingByGenerator' } - - Odandb\DoctrineCiphersweetEncryptionBundle\Services\IndexesGenerator: - class: Odandb\DoctrineCiphersweetEncryptionBundle\Services\IndexesGenerator - public: true - arguments: - - !tagged_locator { tag: 'odb.index_generator', index_by: 'key' } - - "@Odandb\\DoctrineCiphersweetEncryptionBundle\\Encryptors\\EncryptorInterface" - tags: - - { name: container.service_subscriber } - - Odandb\DoctrineCiphersweetEncryptionBundle\Services\PropertyHydratorService: - class: Odandb\DoctrineCiphersweetEncryptionBundle\Services\PropertyHydratorService - arguments: - - '@property_info' - - '@property_accessor' - - Odandb\DoctrineCiphersweetEncryptionBundle\Encryptors\EncryptorInterface: - class: Odandb\DoctrineCiphersweetEncryptionBundle\Encryptors\CiphersweetEncryptor - public: true - arguments: - - "@ParagonIE\\CipherSweet\\CipherSweet" diff --git a/src/Services/IndexableFieldsService.php b/src/Services/IndexableFieldsService.php index 0a89f16..fd21a9e 100644 --- a/src/Services/IndexableFieldsService.php +++ b/src/Services/IndexableFieldsService.php @@ -2,34 +2,37 @@ declare(strict_types=1); - namespace Odandb\DoctrineCiphersweetEncryptionBundle\Services; - -use Doctrine\ORM\EntityRepository; -use Odandb\DoctrineCiphersweetEncryptionBundle\Configuration\EncryptedField; use Odandb\DoctrineCiphersweetEncryptionBundle\Configuration\IndexableField; -use Odandb\DoctrineCiphersweetEncryptionBundle\Encryptors\EncryptorInterface; use Odandb\DoctrineCiphersweetEncryptionBundle\Entity\IndexedEntityInterface; use Odandb\DoctrineCiphersweetEncryptionBundle\Exception\MissingPropertyFromReflectionException; use Doctrine\Common\Annotations\Reader; use Doctrine\ORM\EntityManagerInterface; +use Odandb\DoctrineCiphersweetEncryptionBundle\Exception\UndefinedGeneratorException; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; class IndexableFieldsService { public const INDEXABLE_ANN_NAME = IndexableField::class; - private Reader $annReader; + /** @deprecated */ + private ?Reader $annReader; private EntityManagerInterface $em; private IndexesGenerator $indexesGenerator; + private PropertyAccessorInterface $propertyAccessor; - public function __construct(Reader $annReader, EntityManagerInterface $em, IndexesGenerator $generator) + public function __construct(?Reader $annReader, EntityManagerInterface $em, IndexesGenerator $generator, PropertyAccessorInterface $propertyAccessor) { $this->annReader = $annReader; $this->em = $em; $this->indexesGenerator = $generator; + $this->propertyAccessor = $propertyAccessor; } + /** + * Chunks all data ID of the entity + */ public function getChunksForMultiThread(string $className, int $chuncksLength): array { $repo = $this->em->getRepository($className); @@ -41,22 +44,28 @@ public function getChunksForMultiThread(string $className, int $chuncksLength): return array_chunk(array_column($result, 'id'), $chuncksLength); } - public function buildContext(string $className, ?array $fieldnames): array + /** + * @param null|array $fieldNames + * + * @return array + * + * @throws MissingPropertyFromReflectionException + */ + public function buildContext(string $className, ?array $fieldNames): array { $contexts = []; $classMetadata = $this->em->getClassMetadata($className); - if ($fieldnames === [] || $fieldnames === null) { - $fieldnames = array_map( + if (empty($fieldNames)) { + $fieldNames = array_map( static function (\ReflectionProperty $refProperty): string {return $refProperty->name;}, $classMetadata->getReflectionProperties() ); } - foreach ($fieldnames as $fieldname) { + foreach ($fieldNames as $fieldname) { $refProperty = $classMetadata->getReflectionProperty($fieldname); - if ($refProperty === null) { throw new MissingPropertyFromReflectionException(sprintf("No refProperty found for fieldname %s", $fieldname)); } @@ -66,7 +75,7 @@ static function (\ReflectionProperty $refProperty): string {return $refProperty- $indexableAnnotationConfig = $refAttribute->newInstance(); } - if (null === $indexableAnnotationConfig) { + if (null === $indexableAnnotationConfig && null !== $this->annReader) { $indexableAnnotationConfig = $this->annReader->getPropertyAnnotation($refProperty, self::INDEXABLE_ANN_NAME); if (PHP_VERSION_ID >= 80000) { trigger_deprecation( @@ -85,20 +94,23 @@ static function (\ReflectionProperty $refProperty): string {return $refProperty- return $contexts; } + /** + * Remove all (or by ids) the search possibilities of an entity field + * + * @param array $fieldsContexts + * @param null|array $ids + */ public function purgeFiltersForContextAndIds(array $fieldsContexts, ?array $ids): void { - /** - * @var \ReflectionProperty $refProperty - * @var IndexableField $indexableAnnotationConfig - */ foreach($fieldsContexts as ['refProperty' => $refProperty, 'indexableConfig' => $indexableAnnotationConfig]) { $qb = $this->em->createQueryBuilder() ->delete() - ->from($indexableAnnotationConfig->indexesEntityClass, 'f'); - $qb->where('f.fieldname=:fieldname') - ->setParameter('fieldname', $refProperty->name); + ->from($indexableAnnotationConfig->indexesEntityClass, 'f') + ->where('f.fieldname=:fieldname') + ->setParameter('fieldname', $refProperty->name) + ; - if ($ids !== null && $ids !== []) { + if (!empty($ids)) { $qb->andWhere('f.targetEntity IN (:ids)') ->setParameter('ids', $ids); } @@ -108,13 +120,14 @@ public function purgeFiltersForContextAndIds(array $fieldsContexts, ?array $ids) } /** - * @throws \ReflectionException - * @throws \Odandb\DoctrineCiphersweetEncryptionBundle\Exception\UndefinedGeneratorException + * Generate and save all (or by ids) the search possibilities of an entity + * + * @param null|array $ids + * @param array $fieldsContexts */ public function handleFilterableFieldsForChunck(string $className, ?array $ids, array $fieldsContexts, bool $needsToComputeChangeset = false): void { - $criteria = $ids !== null && $ids !== [] ? ['id' => $ids] : []; - $chunck = $this->em->getRepository($className)->findBy($criteria); + $chunck = $this->em->getRepository($className)->findBy(!empty($ids) ? ['id' => $ids] : []); foreach ($chunck as $entity) { $this->handleIndexableFieldsForEntity($entity, $fieldsContexts, $needsToComputeChangeset); $this->em->flush(); @@ -122,61 +135,17 @@ public function handleFilterableFieldsForChunck(string $className, ?array $ids, } /** - * Permet de générer les valeurs indexables pour une entité et un contexte donné. + * Generate and save the search possibilities of an entity field * - * @param object $entity - * @param array $fieldsContexts - * @return array - * @throws \Odandb\DoctrineCiphersweetEncryptionBundle\Exception\UndefinedGeneratorException - */ - public function generateIndexableValuesForEntity(object $entity, array $fieldsContexts): array - { - $searchIndexes = []; - - foreach ($fieldsContexts as ['refProperty' => $refProperty, 'indexableConfig' => $indexableAnnotationConfig]) { - $value = $refProperty->getValue($entity); - if ($value === null || $value === '') { - continue; - } - - $cleanValue = $value; - $valueCleanerMethod = $indexableAnnotationConfig->valuePreprocessMethod; - if ($valueCleanerMethod !== null && (method_exists($entity, $valueCleanerMethod) || method_exists(get_class($entity), $valueCleanerMethod))) { - $cleanValue = $entity->$valueCleanerMethod($value); - } - - // On appelle le service de génération des index de filtre qui va créer la collection de pattern possibles - // en fonction de la ou des méthodes renseignées en annotation - // Puis récupérer chaque "blind_index" associé à enregistrer en base - $indexesMethods = $indexableAnnotationConfig->indexesGenerationMethods; - - $indexesToEncrypt = $this->indexesGenerator->generateAndEncryptFilters($cleanValue, $indexesMethods); - $indexesToEncrypt [] = $value; - $indexesToEncrypt = array_unique($indexesToEncrypt); - - $searchIndexes[$refProperty->getName()] = $indexesToEncrypt; - } - - return $searchIndexes; - } - - /** - * @param object $entity - * @param array{'refProperty': \ReflectionProperty, 'indexableConfig': IndexableField} $fieldsContexts - * @param bool $needsToComputeChangeset + * @param array{refProperty: \ReflectionProperty, indexableConfig: IndexableField} $fieldsContexts * - * @throws \Odandb\DoctrineCiphersweetEncryptionBundle\Exception\UndefinedGeneratorException - * @throws \ReflectionException + * @throws UndefinedGeneratorException|\ReflectionException */ public function handleIndexableFieldsForEntity(object $entity, array $fieldsContexts, bool $needsToComputeChangeset = false): void { + $className = get_class($entity); $searchIndexes = $this->generateIndexableValuesForEntity($entity, $fieldsContexts); - /** - * @var \ReflectionProperty $refProperty - * @var EncryptedField $annotationConfig - * @var IndexableField $indexableAnnotationConfig - */ foreach ($fieldsContexts as ['refProperty' => $refProperty, 'indexableConfig' => $indexableAnnotationConfig]) { if (!isset($searchIndexes[$refProperty->getName()])) { continue; @@ -184,9 +153,9 @@ public function handleIndexableFieldsForEntity(object $entity, array $fieldsCont $indexesToEncrypt = $searchIndexes[$refProperty->getName()]; - $indexes = $this->indexesGenerator->generateBlindIndexesFromPossibleValues(get_class($entity), $refProperty->getName(), $indexesToEncrypt, $indexableAnnotationConfig->fastIndexing); + $indexes = $this->indexesGenerator->generateBlindIndexesFromPossibleValues($className, $refProperty->getName(), $indexesToEncrypt, $indexableAnnotationConfig->fastIndexing); - // On crée les instances d'objet filtre et on les associe à l'entité parente + // We create the filter object instances and associate them to the parent entity $indexEntities = []; $indexEntityClass = $indexableAnnotationConfig->indexesEntityClass; @@ -201,13 +170,57 @@ public function handleIndexableFieldsForEntity(object $entity, array $fieldsCont $indexEntities [] = $indexEntity; $this->em->persist($indexEntity); + if ($needsToComputeChangeset) { $this->em->getUnitOfWork()->computeChangeSet($classMetadata, $indexEntity); } } } + $setter = 'set' . $refClass->getShortName(); - $entity->$setter($indexEntities); + if ($this->propertyAccessor->isWritable($entity, $setter)) { + $this->propertyAccessor->setValue($entity, $setter, $indexEntities); + } } } + + /** + * Generate the search possibilities of an entity field + * + * @param array{refProperty: \ReflectionProperty, indexableConfig: IndexableField} $fieldsContexts + * + * @return array> + * + * @throws UndefinedGeneratorException + */ + public function generateIndexableValuesForEntity(object $entity, array $fieldsContexts): array + { + $searchIndexes = []; + + foreach ($fieldsContexts as ['refProperty' => $refProperty, 'indexableConfig' => $indexableAnnotationConfig]) { + $value = $refProperty->getValue($entity); + if ($value === null || $value === '') { + continue; + } + + $cleanValue = $value; + $valueCleanerMethod = $indexableAnnotationConfig->valuePreprocessMethod; + if ($valueCleanerMethod !== null && method_exists($entity, $valueCleanerMethod)) { + $cleanValue = $entity->$valueCleanerMethod($value); + } + + // We call the filter index generation service which will create the collection of possible patterns + // according to the method(s) specified in the annotation + // Then retrieve each associated "blind_index" to save in database + $indexesMethods = $indexableAnnotationConfig->indexesGenerationMethods; + + $indexesToEncrypt = $this->indexesGenerator->generateAndEncryptFilters($cleanValue, $indexesMethods); + $indexesToEncrypt[] = $value; + $indexesToEncrypt = array_unique($indexesToEncrypt); + + $searchIndexes[$refProperty->getName()] = $indexesToEncrypt; + } + + return $searchIndexes; + } } diff --git a/src/Services/IndexesGenerator.php b/src/Services/IndexesGenerator.php index 78d7a80..4ce3126 100644 --- a/src/Services/IndexesGenerator.php +++ b/src/Services/IndexesGenerator.php @@ -2,45 +2,33 @@ declare(strict_types=1); - namespace Odandb\DoctrineCiphersweetEncryptionBundle\Services; - use Odandb\DoctrineCiphersweetEncryptionBundle\Encryptors\EncryptorInterface; use Odandb\DoctrineCiphersweetEncryptionBundle\Exception\UndefinedGeneratorException; use Odandb\DoctrineCiphersweetEncryptionBundle\Services\IndexesGenerators\IndexesGeneratorInterface; -use Odandb\DoctrineCiphersweetEncryptionBundle\Services\IndexesGenerators\ValueStartingByGenerator; -use Odandb\DoctrineCiphersweetEncryptionBundle\Services\IndexesGenerators\ValueEndingByGenerator; -use Psr\Container\ContainerInterface; -use Symfony\Contracts\Service\ServiceSubscriberInterface; +use Symfony\Component\DependencyInjection\ServiceLocator; -class IndexesGenerator implements ServiceSubscriberInterface +class IndexesGenerator { protected EncryptorInterface $encryptor; - protected ContainerInterface $container; + protected ServiceLocator $container; - public function __construct(ContainerInterface $container, EncryptorInterface $encryptor) + public function __construct(ServiceLocator $container, EncryptorInterface $encryptor) { $this->container = $container; $this->encryptor = $encryptor; } /** - * @required + * Generates all possible search for the value + * + * @param string[] $methods + * + * @return string[] + * + * @throws UndefinedGeneratorException */ - public function setContainer(ContainerInterface $container): void - { - $this->container = $container; - } - - public static function getSubscribedServices(): array - { - return [ - 'ValueStartingByGenerator' => '?'.ValueStartingByGenerator::class, - 'ValueEndingByGenerator' => '?'.ValueEndingByGenerator::class, - ]; - } - public function generateAndEncryptFilters(string $value, array $methods): array { $possibleValuesAr = [$value]; @@ -53,7 +41,7 @@ public function generateAndEncryptFilters(string $value, array $methods): array } $generator = $this->container->get($method); - if ($generator instanceof IndexesGeneratorInterface === false) { + if (!$generator instanceof IndexesGeneratorInterface) { throw new \TypeError(sprintf("The generator is not an instance of %s", IndexesGeneratorInterface::class)); } @@ -65,13 +53,13 @@ public function generateAndEncryptFilters(string $value, array $methods): array } /** - * @param string $entityName - * @param string $fieldname + * Generates all blind indexes for the all possible values + * * @param string[] $possibleValues - * @param bool $fastIndexing - * @return array + * + * @return array */ - public function generateBlindIndexesFromPossibleValues(string $entityName, string $fieldname, array $possibleValues, bool $fastIndexing): array + public function generateBlindIndexesFromPossibleValues(string $entityName, string $fieldName, array $possibleValues, bool $fastIndexing): array { $possibleValues = array_unique($possibleValues); @@ -80,7 +68,7 @@ public function generateBlindIndexesFromPossibleValues(string $entityName, strin if ($pvalue === '' || $pvalue === null) { continue; } - $indexes[] = $this->encryptor->getBlindIndex($entityName, $fieldname, $pvalue, EncryptorInterface::DEFAULT_FILTER_BITS, $fastIndexing); + $indexes[] = $this->encryptor->getBlindIndex($entityName, $fieldName, $pvalue, EncryptorInterface::DEFAULT_FILTER_BITS, $fastIndexing); } return $indexes; diff --git a/src/Services/IndexesGenerators/IndexesGeneratorInterface.php b/src/Services/IndexesGenerators/IndexesGeneratorInterface.php index bf2856f..c0c6f57 100644 --- a/src/Services/IndexesGenerators/IndexesGeneratorInterface.php +++ b/src/Services/IndexesGenerators/IndexesGeneratorInterface.php @@ -2,11 +2,15 @@ declare(strict_types=1); - namespace Odandb\DoctrineCiphersweetEncryptionBundle\Services\IndexesGenerators; - +/** + * @method static string getIndexKey() + */ interface IndexesGeneratorInterface { + /** + * @return array + */ public function generate(string $value): array; } diff --git a/src/Services/IndexesGenerators/TokenizerGenerator.php b/src/Services/IndexesGenerators/TokenizerGenerator.php index 92d6f76..547166b 100644 --- a/src/Services/IndexesGenerators/TokenizerGenerator.php +++ b/src/Services/IndexesGenerators/TokenizerGenerator.php @@ -2,19 +2,18 @@ declare(strict_types=1); - namespace Odandb\DoctrineCiphersweetEncryptionBundle\Services\IndexesGenerators; - -class TokenizerGenerator +class TokenizerGenerator implements IndexesGeneratorInterface { - /** - * @param string $string - * @return array - */ - public function generate(string $string): array + public function generate(string $value): array + { + $value = trim(preg_replace(array('/[^a-zA-Z0-9\-]/', '/\s+/'), ' ', $value)); + return explode(' ', $value); + } + + public static function getIndexKey(): string { - $string = trim(preg_replace(array('/[^a-zA-Z0-9\-]/', '/\s+/'), ' ', $string)); - return explode(' ', $string); + return 'TokenizerGenerator'; } } diff --git a/src/Services/IndexesGenerators/ValueEndingByGenerator.php b/src/Services/IndexesGenerators/ValueEndingByGenerator.php index 16cecfd..32d3339 100644 --- a/src/Services/IndexesGenerators/ValueEndingByGenerator.php +++ b/src/Services/IndexesGenerators/ValueEndingByGenerator.php @@ -2,15 +2,10 @@ declare(strict_types=1); - namespace Odandb\DoctrineCiphersweetEncryptionBundle\Services\IndexesGenerators; class ValueEndingByGenerator implements IndexesGeneratorInterface { - /** - * @param string $value - * @return array - */ public function generate(string $value): array { $possibleValues = []; @@ -21,4 +16,9 @@ public function generate(string $value): array return $possibleValues; } + + public static function getIndexKey(): string + { + return 'ValueEndingByGenerator'; + } } diff --git a/src/Services/IndexesGenerators/ValueStartingByGenerator.php b/src/Services/IndexesGenerators/ValueStartingByGenerator.php index 96e86ea..7d8e15f 100644 --- a/src/Services/IndexesGenerators/ValueStartingByGenerator.php +++ b/src/Services/IndexesGenerators/ValueStartingByGenerator.php @@ -2,7 +2,6 @@ declare(strict_types=1); - namespace Odandb\DoctrineCiphersweetEncryptionBundle\Services\IndexesGenerators; class ValueStartingByGenerator implements IndexesGeneratorInterface @@ -17,4 +16,9 @@ public function generate(string $value): array return $possibleValues; } + + public static function getIndexKey(): string + { + return 'ValueStartingByGenerator'; + } } diff --git a/src/Services/PropertyHydratorService.php b/src/Services/PropertyHydratorService.php index 1b9f25b..d9c7857 100644 --- a/src/Services/PropertyHydratorService.php +++ b/src/Services/PropertyHydratorService.php @@ -2,11 +2,8 @@ declare(strict_types=1); - namespace Odandb\DoctrineCiphersweetEncryptionBundle\Services; - -use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; use Symfony\Component\PropertyInfo\Type; @@ -14,20 +11,16 @@ class PropertyHydratorService { private PropertyInfoExtractorInterface $propertyInfoExtractor; - private PropertyAccessorInterface $propertyAccessor; - public function __construct(PropertyInfoExtractorInterface $propertyInfoExtractor, PropertyAccessorInterface $propertyAccessor = null) + public function __construct(PropertyInfoExtractorInterface $propertyInfoExtractor, PropertyAccessorInterface $propertyAccessor) { $this->propertyInfoExtractor = $propertyInfoExtractor; - $this->propertyAccessor = $propertyAccessor ?? PropertyAccess::createPropertyAccessor(); + $this->propertyAccessor = $propertyAccessor; } /** - * @param object $entity - * @param string|null $propertyName * @param mixed $value - * @return string */ public function getMappedFieldValueAsString(object $entity, ?string $propertyName, $value): string { @@ -38,22 +31,19 @@ public function getMappedFieldValueAsString(object $entity, ?string $propertyNam return (string) $value; } - /** - * @param object $entity - * @param string $value - * @param string|null $propertyName - */ public function setValueToMappedField(object $entity, string $value, ?string $propertyName): void { - if ($propertyName !== null) { - $propertyInfoType = $this->propertyInfoExtractor->getTypes(get_class($entity), $propertyName)[0]; - $targetType = $propertyInfoType->getBuiltinType(); + if ($propertyName === null) { + return; + } - if ($targetType !== Type::BUILTIN_TYPE_STRING) { - settype($value, $targetType); - } + $propertyInfoType = $this->propertyInfoExtractor->getTypes(get_class($entity), $propertyName)[0]; + $targetType = $propertyInfoType->getBuiltinType(); - $this->propertyAccessor->setValue($entity, $propertyName, $value); + if ($targetType !== Type::BUILTIN_TYPE_STRING) { + settype($value, $targetType); } + + $this->propertyAccessor->setValue($entity, $propertyName, $value); } } diff --git a/src/Subscribers/DoctrineCiphersweetSubscriber.php b/src/Subscribers/DoctrineCiphersweetSubscriber.php index 2410f79..701b4aa 100644 --- a/src/Subscribers/DoctrineCiphersweetSubscriber.php +++ b/src/Subscribers/DoctrineCiphersweetSubscriber.php @@ -2,40 +2,54 @@ declare(strict_types=1); - namespace Odandb\DoctrineCiphersweetEncryptionBundle\Subscribers; +use Doctrine\Bundle\DoctrineBundle\EventSubscriber\EventSubscriberInterface; +use Doctrine\ORM\Event\PostLoadEventArgs; +use Doctrine\Persistence\ObjectManager; use Odandb\DoctrineCiphersweetEncryptionBundle\Configuration\EncryptedField; use Odandb\DoctrineCiphersweetEncryptionBundle\Configuration\IndexableField; use Odandb\DoctrineCiphersweetEncryptionBundle\Encryptors\EncryptorInterface; use Odandb\DoctrineCiphersweetEncryptionBundle\Services\IndexableFieldsService; use Odandb\DoctrineCiphersweetEncryptionBundle\Services\PropertyHydratorService; use Doctrine\Common\Annotations\Reader; -use Doctrine\Common\EventSubscriber; use Doctrine\ORM\EntityManagerInterface; -use Doctrine\ORM\Event\LifecycleEventArgs; use Doctrine\ORM\Event\OnClearEventArgs; use Doctrine\ORM\Event\OnFlushEventArgs; use Doctrine\ORM\Event\PostFlushEventArgs; use Doctrine\ORM\Events; +use Symfony\Contracts\Service\ResetInterface; -/** - * - */ -class DoctrineCiphersweetSubscriber implements EventSubscriber +class DoctrineCiphersweetSubscriber implements EventSubscriberInterface, ResetInterface { public const ENCRYPTED_ANN_NAME = EncryptedField::class; public const INDEXABLE_ANN_NAME = IndexableField::class; - private EncryptorInterface $encryptor; + /** @deprecated */ private Reader $annReader; + private EncryptorInterface $encryptor; + private IndexableFieldsService $indexableFieldsService; + private PropertyHydratorService $propertyHydratorService; - public array $_originalValues = []; + /** + * Caches the original encrypt value of an entity field + * + * @var array> + */ + private array $_originalValues = []; + /** + * Cache the entities SPL ID that have already been decrypted + * + * @var array + */ private array $decodedRegistry = []; + /** * Caches information on an entity's encrypted fields in an array keyed on * the entity's class name. The value will be a list of Reflected fields that are encrypted. + * + * @var array */ private array $encryptedFieldCache = []; @@ -43,18 +57,18 @@ class DoctrineCiphersweetSubscriber implements EventSubscriber * Before flushing the objects out to the database, we modify their password value to the * encrypted value. Since we want the password to remain decrypted on the entity after a flush, * we have to write the decrypted value back to the entity. + * + * @var array}> */ private array $postFlushDecryptQueue = []; - private array $entitiesToEncrypt = []; - - private IndexableFieldsService $indexableFieldsService; - - private PropertyHydratorService $propertyHydratorService; - /** - * Initialization of subscriber. + * Entity that remains to be encrypted (converting an existing field to encryption) + * + * @var array */ + private array $entitiesToEncrypt = []; + public function __construct( Reader $annReader, EncryptorInterface $encryptorClass, @@ -68,12 +82,49 @@ public function __construct( $this->propertyHydratorService = $propertyHydratorService; } + /** + * {@inheritdoc} + */ + public function getSubscribedEvents(): array + { + return [ + Events::postLoad, + Events::onFlush, + Events::postFlush, + Events::onClear, + ]; + } + + public function reset(): void + { + $this->_originalValues = []; + $this->decodedRegistry = []; + $this->encryptedFieldCache = []; + $this->postFlushDecryptQueue = []; + $this->entitiesToEncrypt = []; + } + + /** + * Listen a postLoad lifecycle event. Checking and decrypt entities which have `EncryptedField` annotations/attributes. + */ + public function postLoad(PostLoadEventArgs $args): void + { + $entity = $args->getObject(); + if ($this->hasInDecodedRegistry($entity)) { + return; + } + + if ($this->processFields($entity, $args->getObjectManager(), false)) { + $this->addToDecodedRegistry($entity); + } + } + /** * Encrypt the password before it is written to the database. */ public function onFlush(OnFlushEventArgs $args): void { - $em = $args->getEntityManager(); + $em = $args->getObjectManager(); $unitOfWork = $em->getUnitOfWork(); $this->postFlushDecryptQueue = []; @@ -95,6 +146,31 @@ public function onFlush(OnFlushEventArgs $args): void } } + /** + * After we have persisted the entities, we want to have the + * decrypted information available once more. + */ + public function postFlush(PostFlushEventArgs $args): void + { + $unitOfWork = $args->getObjectManager()->getUnitOfWork(); + + foreach ($this->postFlushDecryptQueue as $pair) { + $fieldPairs = $pair['fields']; + $entity = $pair['entity']; + $oid = spl_object_id($entity); + + foreach ($fieldPairs as $fieldPair) { + /** @var \ReflectionProperty $field */ + $field = $fieldPair['field']; + $field->setValue($entity, $fieldPair['value']); + $unitOfWork->setOriginalEntityProperty($oid, $field->getName(), $fieldPair['value']); + } + + $this->addToDecodedRegistry($entity); + } + $this->postFlushDecryptQueue = []; + } + public function onClear(OnClearEventArgs $args): void { unset($this->_originalValues, $this->decodedRegistry, $this->encryptedFieldCache, $this->postFlushDecryptQueue); @@ -116,7 +192,6 @@ private function entityOnFlush(object $entity, EntityManagerInterface $em): void $objId = spl_object_id($entity); $fields = []; - foreach ($this->getEncryptedFields($entity, $em) as $field) { $fields[$field->getName()] = [ 'field' => $field, @@ -132,22 +207,18 @@ private function entityOnFlush(object $entity, EntityManagerInterface $em): void } /** - * @param object $entity - * @param EntityManagerInterface $em - * * @return \ReflectionProperty[] */ - private function getEncryptedFields(object $entity, EntityManagerInterface $em): array + private function getEncryptedFields(object $entity, ObjectManager $em): array { $className = \get_class($entity); - if (isset($this->encryptedFieldCache[$className])) { return $this->encryptedFieldCache[$className]; } $meta = $em->getClassMetadata($className); - $encryptedFields = []; + $encryptedFields = []; foreach ($meta->getReflectionProperties() as $refProperty) { if (PHP_VERSION_ID >= 80000 && isset($refProperty->getAttributes(self::ENCRYPTED_ANN_NAME)[0])) { $refProperty->setAccessible(true); @@ -171,15 +242,13 @@ private function getEncryptedFields(object $entity, EntityManagerInterface $em): } } - $this->encryptedFieldCache[$className] = $encryptedFields; - - return $encryptedFields; + return $this->encryptedFieldCache[$className] = $encryptedFields; } /** * Process (encrypt/decrypt) entities fields. */ - public function processFields(object $entity, EntityManagerInterface $em, $isEncryptOperation = true, $force = null): bool + public function processFields(object $entity, ObjectManager $em, bool $isEncryptOperation = true, bool $force = false): bool { $properties = $this->getEncryptedFields($entity, $em); $unitOfWork = $em->getUnitOfWork(); @@ -189,8 +258,7 @@ public function processFields(object $entity, EntityManagerInterface $em, $isEnc $entityClassName = $em->getClassMetadata(get_class($entity))->getName(); foreach ($properties as $refProperty) { - $value = $refProperty->getValue($entity) ?? ''; - + $value = $refProperty->getValue($entity); if ($value === null) { continue; } @@ -200,8 +268,7 @@ public function processFields(object $entity, EntityManagerInterface $em, $isEnc if ($isEncryptOperation) { $value = $this->handleEncryptOperation($entity, $oid, $value, $refProperty, $context, $force); } else { - $oldValue = $value; - if (!$this->isValueEncrypted($oldValue)) { + if (!$this->isValueEncrypted($value)) { $this->entitiesToEncrypt[$oid] = $entity; } $value = $this->handleDecryptOperation($oid, $value, $refProperty, $context); @@ -216,13 +283,20 @@ public function processFields(object $entity, EntityManagerInterface $em, $isEnc if (!$isEncryptOperation && !\defined('_DONOTENCRYPT')) { //we don't want the object to be dirty immediately after reading - $unitOfWork->setOriginalEntityProperty(spl_object_id($entity), $refProperty->getName(), $value); + $unitOfWork->setOriginalEntityProperty($oid, $refProperty->getName(), $value); } } return !empty($properties); } + /** + * @return array{ + * annotationConfig: array{blindIndex: bool, filterBits: int, mappedTypedProperty: null|string}, + * indexableAnnotation: null|IndexableField, + * entityClassName: string + * } + */ private function buildContext(string $entityClassName, \ReflectionProperty $refProperty): array { $annotationConfig = null; @@ -274,22 +348,15 @@ private function buildContext(string $entityClassName, \ReflectionProperty $refP } /** - * @param object $entity - * @param int $oid * @param mixed $value - * @param \ReflectionProperty $refProperty - * @param array $context - * @param string|null $force - * @return mixed|string|null - * - * @throws \Odandb\DoctrineCiphersweetEncryptionBundle\Exception\UndefinedGeneratorException - * @throws \ReflectionException + * @param array{ + * annotationConfig: array{blindIndex: bool, filterBits: int, mappedTypedProperty: null|string}, + * indexableAnnotation: null|IndexableField, + * entityClassName: string + * } $context */ - private function handleEncryptOperation(object $entity, int $oid, $value, \ReflectionProperty $refProperty, array $context, ?string $force = null) + private function handleEncryptOperation(object $entity, int $oid, $value, \ReflectionProperty $refProperty, array $context, bool $force): ?string { - /** - * @var null|IndexableField $indexableAnnotationConfig - */ [ 'annotationConfig' => [ 'blindIndex' => $storeBlindIndex, @@ -305,39 +372,33 @@ private function handleEncryptOperation(object $entity, int $oid, $value, \Refle return null; } - if ('encrypt' === $force) { - $originalValue = $value; + // Force encryption + if ($force) { $value = $this->storeValue($entity, $refProperty, $value, $storeBlindIndex, $filterBits, $indexableAnnotationConfig->fastIndexing ?? EncryptorInterface::DEFAULT_FAST_INDEXING); - $this->storeIndexes($entity, $refProperty, $indexableAnnotationConfig, $originalValue); - } else { - if (isset($this->_originalValues[$oid][$refProperty->getName()])) { - $oldValue = $this->_originalValues[$oid][$refProperty->getName()]; + $this->storeIndexes($entity, $refProperty, $indexableAnnotationConfig); - if ($this->isValueEncrypted($oldValue)) { - $oldValue = $this->encryptor->decrypt($entityClassName, $refProperty->getName(), $oldValue, $filterBits, $indexableAnnotationConfig->fastIndexing ?? EncryptorInterface::DEFAULT_FAST_INDEXING); - } - } else { - $oldValue = null; - } + return $value; + } - if (($this->isValueEncrypted($oldValue) && $oldValue === $value) || (null === $oldValue && null === $value)) { - $value = $oldValue; - } else { - $originalValue = $value; - $value = $this->storeValue($entity, $refProperty, $value, $storeBlindIndex, $filterBits, $indexableAnnotationConfig->fastIndexing ?? EncryptorInterface::DEFAULT_FAST_INDEXING); - $this->storeIndexes($entity, $refProperty, $indexableAnnotationConfig, $originalValue); + // Get the original value + $oldValue = null; + if (isset($this->_originalValues[$oid][$refProperty->getName()])) { + $oldValue = $this->_originalValues[$oid][$refProperty->getName()]; + if ($this->isValueEncrypted($oldValue)) { + $oldValue = $this->encryptor->decrypt($entityClassName, $refProperty->getName(), $oldValue, $filterBits, $indexableAnnotationConfig->fastIndexing ?? EncryptorInterface::DEFAULT_FAST_INDEXING); } } + if (!$this->isValueEncrypted($oldValue) || $oldValue !== $value) { + $value = $this->storeValue($entity, $refProperty, $value, $storeBlindIndex, $filterBits, $indexableAnnotationConfig->fastIndexing ?? EncryptorInterface::DEFAULT_FAST_INDEXING); + $this->storeIndexes($entity, $refProperty, $indexableAnnotationConfig); + } + return $value; } /** - * @param int $oid * @param mixed $value - * @param \ReflectionProperty $refProperty - * @param array $context - * @return string */ private function handleDecryptOperation(int $oid, $value, \ReflectionProperty $refProperty, array $context): string { @@ -361,30 +422,13 @@ private function handleDecryptOperation(int $oid, $value, \ReflectionProperty $r return $value; } - /** - * @param null|string $value - * @return bool - */ private function isValueEncrypted(?string $value): bool { return $value !== null && strpos($value, $this->encryptor->getPrefix()) === 0; } - /** - * @param object $entity - * @param \ReflectionProperty $refProperty - * @param $value - * @param bool $storeBlindIndex - * @param int $filterBits - * @param bool $fastIndexing - * @return mixed - */ - private function storeValue(object $entity, \ReflectionProperty $refProperty, $value, bool $storeBlindIndex, int $filterBits, bool $fastIndexing = true) + private function storeValue(object $entity, \ReflectionProperty $refProperty, string $value, bool $storeBlindIndex, int $filterBits, bool $fastIndexing = true) { - if ($value === '') { - return ''; - } - [$value, $indexes] = $this->encryptor->prepareForStorage($entity, $refProperty->getName(), $value, $storeBlindIndex, $filterBits, $fastIndexing); if ($storeBlindIndex === true) { @@ -398,54 +442,19 @@ private function storeValue(object $entity, \ReflectionProperty $refProperty, $v } /** - * @param object $entity - * @param \ReflectionProperty $refProperty - * @param IndexableField|null $indexableAnnotationConfig - * @param mixed $value - * @throws \Odandb\DoctrineCiphersweetEncryptionBundle\Exception\UndefinedGeneratorException - * @throws \ReflectionException + * Generate and save indexable value */ - private function storeIndexes(object $entity, \ReflectionProperty $refProperty, ?IndexableField $indexableAnnotationConfig, $value): void + private function storeIndexes(object $entity, \ReflectionProperty $refProperty, ?IndexableField $indexableAnnotationConfig): void { if ($indexableAnnotationConfig === null) { return; } - $autoRefresh = $indexableAnnotationConfig->autoRefresh; - if ($autoRefresh === false) { + if (!$indexableAnnotationConfig->autoRefresh) { return; } - if (is_string($value) === false) { - throw new \TypeError("Value is supposed to be of type string in order to build related indexes."); - } - - $this->indexableFieldsService->handleIndexableFieldsForEntity($entity, ['refProperty' => $refProperty, 'indexableConfig' => $indexableAnnotationConfig], true); - } - - /** - * After we have persisted the entities, we want to have the - * decrypted information available once more. - */ - public function postFlush(PostFlushEventArgs $args): void - { - $unitOfWork = $args->getObjectManager()->getUnitOfWork(); - - foreach ($this->postFlushDecryptQueue as $pair) { - $fieldPairs = $pair['fields']; - $entity = $pair['entity']; - $oid = spl_object_id($entity); - - foreach ($fieldPairs as $fieldPair) { - /** @var \ReflectionProperty $field */ - $field = $fieldPair['field']; - $field->setValue($entity, $fieldPair['value']); - $unitOfWork->setOriginalEntityProperty($oid, $field->getName(), $fieldPair['value']); - } - - $this->addToDecodedRegistry($entity); - } - $this->postFlushDecryptQueue = []; + $this->indexableFieldsService->handleIndexableFieldsForEntity($entity, [['refProperty' => $refProperty, 'indexableConfig' => $indexableAnnotationConfig]], true); } /** @@ -453,23 +462,11 @@ public function postFlush(PostFlushEventArgs $args): void * * @param object $entity Some doctrine entity */ - private function addToDecodedRegistry($entity): void + private function addToDecodedRegistry(object $entity): void { $this->decodedRegistry[spl_object_id($entity)] = true; } - /** - * Listen a postLoad lifecycle event. Checking and decrypt entities - * which have @EncryptedField annotations. - */ - public function postLoad(LifecycleEventArgs $args): void - { - $entity = $args->getObject(); - if (!$this->hasInDecodedRegistry($entity) && $this->processFields($entity, $args->getObjectManager(), false)) { - $this->addToDecodedRegistry($entity); - } - } - /** * Check if we have entity in decoded registry. * @@ -479,17 +476,4 @@ private function hasInDecodedRegistry(object $entity): bool { return isset($this->decodedRegistry[spl_object_id($entity)]); } - - /** - * {@inheritdoc} - */ - public function getSubscribedEvents(): array - { - return [ - Events::postLoad, - Events::onFlush, - Events::postFlush, - Events::onClear, - ]; - } } diff --git a/tests/App/.gitignore b/tests/App/.gitignore new file mode 100644 index 0000000..3fd2129 --- /dev/null +++ b/tests/App/.gitignore @@ -0,0 +1,2 @@ +/data.sqlite +/var diff --git a/tests/App/Controller/TestController.php b/tests/App/Controller/TestController.php new file mode 100644 index 0000000..ac1bd5f --- /dev/null +++ b/tests/App/Controller/TestController.php @@ -0,0 +1,60 @@ +em = $em; + } + + public function entityList(): JsonResponse + { + $entities = $this->em->createQueryBuilder() + ->select('e') + ->from(MyEntityAttribute::class, 'e') + ->getQuery() + ->getResult() + ; + + $data = []; + /** @var MyEntityAttribute $entity */ + foreach ($entities as $key => $entity) { + $data[$key]['accountName'] = $entity->getAccountName(); + $data[$key]['accountNumber'] = $entity->getAccountNumberType(); + } + + return new JsonResponse(['data' => $data]); + } + + public function createEntity(): JsonResponse + { + $entity = new MyEntityAttribute('test', 1305); + + $this->em->persist($entity); + $this->em->flush(); + + return new JsonResponse(); + } + + public function updateEntity(MyEntityAttribute $entity): JsonResponse + { + $entity->setAccountNumberType(1994); + + $this->em->flush(); + + return new JsonResponse(); + } +} diff --git a/tests/Encryptors/CiphersweetEncryptorObservable.php b/tests/App/Encryptors/CiphersweetEncryptorObservable.php similarity index 66% rename from tests/Encryptors/CiphersweetEncryptorObservable.php rename to tests/App/Encryptors/CiphersweetEncryptorObservable.php index 7e3572f..c84c25e 100644 --- a/tests/Encryptors/CiphersweetEncryptorObservable.php +++ b/tests/App/Encryptors/CiphersweetEncryptorObservable.php @@ -2,13 +2,12 @@ declare(strict_types=1); - -namespace Odandb\DoctrineCiphersweetEncryptionBundle\Tests\Encryptors; - +namespace Odandb\DoctrineCiphersweetEncryptionBundle\Tests\App\Encryptors; use Odandb\DoctrineCiphersweetEncryptionBundle\Encryptors\CiphersweetEncryptor; +use Odandb\DoctrineCiphersweetEncryptionBundle\Encryptors\EncryptorInterface; -class CiphersweetEncryptorObservable extends CiphersweetEncryptor +class CiphersweetEncryptorObservable extends CiphersweetEncryptor implements EncryptorInterface { public array $callsCount = [ 'encrypt' => 0, @@ -21,9 +20,9 @@ protected function doEncrypt(string $entitClassName, string $fieldName, string $ return parent::doEncrypt($entitClassName, $fieldName, $string, $index, $filterBits, $fastIndexing); } - protected function doDecrypt(string $entity_classname, string $fieldName, string $string): string + protected function doDecrypt(string $entityClassName, string $fieldName, string $string): string { $this->callsCount['decrypt']++; - return parent::doDecrypt($entity_classname, $fieldName, $string); + return parent::doDecrypt($entityClassName, $fieldName, $string); } } diff --git a/tests/App/Kernel.php b/tests/App/Kernel.php index fde2026..b981d6d 100644 --- a/tests/App/Kernel.php +++ b/tests/App/Kernel.php @@ -2,52 +2,34 @@ declare(strict_types=1); - namespace Odandb\DoctrineCiphersweetEncryptionBundle\Tests\App; -use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; +use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; +use Odandb\DoctrineCiphersweetEncryptionBundle\OdandbDoctrineCiphersweetEncryptionBundle; +use Symfony\Bundle\FrameworkBundle\FrameworkBundle; use Symfony\Component\Config\Loader\LoaderInterface; -use Symfony\Component\Config\Resource\FileResource; -use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Kernel as BaseKernel; class Kernel extends BaseKernel { - use MicroKernelTrait; - - private const CONFIG_EXTS = '.{yaml,yml}'; - public function registerBundles(): iterable { - $contents = require $this->getProjectDir().'/config/bundles.php'; - foreach ($contents as $class => $envs) { - if ($envs[$this->environment] ?? $envs['all'] ?? false) { - yield new $class(); - } - } + return [ + new FrameworkBundle(), + new DoctrineBundle(), + new OdandbDoctrineCiphersweetEncryptionBundle(), + ]; } public function getProjectDir(): string { - return \dirname(__DIR__).'/App'; + return __DIR__; } - protected function configureContainer(ContainerBuilder $container, LoaderInterface $loader): void + public function registerContainerConfiguration(LoaderInterface $loader): void { - $container->addResource(new FileResource($this->getProjectDir().'/config/bundles.php')); - $container->setParameter('container.dumper.inline_class_loader', \PHP_VERSION_ID < 70400 || $this->debug); - $container->setParameter('container.dumper.inline_factories', true); - - $loader->load($this->getProjectDir().'/config/services'.self::CONFIG_EXTS, 'glob'); - $loader->load($this->getProjectDir().'/config/{packages}/*'.self::CONFIG_EXTS, 'glob'); - - if (PHP_VERSION_ID >= 80000) { - $loader->load($this->getProjectDir().'/config/doctrine80'.self::CONFIG_EXTS, 'glob'); - } else { - $loader->load($this->getProjectDir().'/config/doctrine74'.self::CONFIG_EXTS, 'glob'); - } + $loader->load(__DIR__ . '/config.yaml'); - $confDir = $this->getProjectDir().'/../../src/Resources/config'; - $loader->load($confDir.'/encryption-services'.self::CONFIG_EXTS, 'glob'); + $loader->load(__DIR__ . '/doctrine.yaml'); } } diff --git a/tests/App/Model/MyEntityAttribute.php b/tests/App/Model/MyEntityAttribute.php new file mode 100644 index 0000000..9d5445a --- /dev/null +++ b/tests/App/Model/MyEntityAttribute.php @@ -0,0 +1,97 @@ +accountName = $accountName; + $this->accountNumberType = $accountNumberType; + $this->accountNumber = null !== $this->accountNumberType ? (string) $this->accountNumberType : null; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getAccountName(): string + { + return $this->accountName; + } + + public static function cleanAccountNumber(string $value): string + { + return trim($value); + } + + public function setAccountName(string $accountName): void + { + $this->accountName = $accountName; + } + + public function getAccountNameBi(): string + { + return $this->accountNameBi; + } + + public function setAccountNameBi(string $accountNameBi): self + { + $this->accountNameBi = $accountNameBi; + + return $this; + } + + public function getAccountNumber(): string + { + return $this->accountNumber; + } + + public function setAccountNumber(string $accountNumber): self + { + $this->accountNumber = $accountNumber; + + return $this; + } + + public function getAccountNumberType(): int + { + return $this->accountNumberType; + } + + public function setAccountNumberType(int $accountNumberType): self + { + $this->accountNumberType = $accountNumberType; + $this->accountNumber = (string) $this->accountNumberType; + + return $this; + } +} diff --git a/tests/App/Model/MyEntityAttributeIndexes.php b/tests/App/Model/MyEntityAttributeIndexes.php new file mode 100644 index 0000000..1d41503 --- /dev/null +++ b/tests/App/Model/MyEntityAttributeIndexes.php @@ -0,0 +1,22 @@ +getParameterOption(['--env', '-e'], null, true)) { - putenv('APP_ENV='.$_SERVER['APP_ENV'] = $_ENV['APP_ENV'] = $env); -} - -if ($input->hasParameterOption('--no-debug', true)) { - putenv('APP_DEBUG='.$_SERVER['APP_DEBUG'] = $_ENV['APP_DEBUG'] = '0'); -} - -require dirname(__DIR__) . '/config/bootstrap.php'; - -if ($_SERVER['APP_DEBUG']) { - umask(0000); - - if (class_exists(Debug::class)) { - Debug::enable(); - } -} - -$kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']); -$application = new Application($kernel); -$application->run($input); diff --git a/tests/App/config.yaml b/tests/App/config.yaml new file mode 100644 index 0000000..bafc2ad --- /dev/null +++ b/tests/App/config.yaml @@ -0,0 +1,24 @@ +imports: + - { resource: services.yaml } + +framework: + secret: '123456789' + http_method_override: false + router: + utf8: true + resource: '%kernel.project_dir%/routing.yaml' + strict_requirements: ~ + test: true + profiler: + collect: false + cache: + pools: + doctrine.result_cache_pool: + adapter: cache.app + doctrine.system_cache_pool: + adapter: cache.system + +doctrine: + dbal: + driver: 'pdo_sqlite' + path: '%kernel.project_dir%/data.sqlite' diff --git a/tests/App/config/bootstrap.php b/tests/App/config/bootstrap.php deleted file mode 100644 index 6ac7ee1..0000000 --- a/tests/App/config/bootstrap.php +++ /dev/null @@ -1,25 +0,0 @@ -=1.2) -// if (is_array($env = @include dirname(__DIR__).'/.env.local.php') && (!isset($env['APP_ENV']) || ($_SERVER['APP_ENV'] ?? $_ENV['APP_ENV'] ?? $env['APP_ENV']) === $env['APP_ENV'])) { -// foreach ($env as $k => $v) { -// $_ENV[$k] = $_ENV[$k] ?? (isset($_SERVER[$k]) && 0 !== strpos($k, 'HTTP_') ? $_SERVER[$k] : $v); -// } -// } elseif (!class_exists(Dotenv::class)) { -// throw new RuntimeException('Please run "composer require symfony/dotenv" to load the ".env" files configuring the application.'); -// } else { -// // load all the .env files -// (new Dotenv(false))->loadEnv(dirname(__DIR__).'/.env'); -// } - -$_SERVER += $_ENV; -$_SERVER['APP_ENV'] = $_ENV['APP_ENV'] = ($_SERVER['APP_ENV'] ?? $_ENV['APP_ENV'] ?? null) ?: 'dev'; -$_SERVER['APP_DEBUG'] ??= $_ENV['APP_DEBUG'] ?? 'prod' !== $_SERVER['APP_ENV']; -$_SERVER['APP_DEBUG'] = $_ENV['APP_DEBUG'] = (int) $_SERVER['APP_DEBUG'] || filter_var($_SERVER['APP_DEBUG'], FILTER_VALIDATE_BOOLEAN) ? '1' : '0'; diff --git a/tests/App/config/bundles.php b/tests/App/config/bundles.php deleted file mode 100644 index 9475f30..0000000 --- a/tests/App/config/bundles.php +++ /dev/null @@ -1,7 +0,0 @@ - ['all' => true], - Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], - Odandb\DoctrineCiphersweetEncryptionBundle\OdandbDoctrineCiphersweetEncryptionBundle::class => ['all' => true], -]; diff --git a/tests/App/config/doctrine74.yaml b/tests/App/config/doctrine74.yaml deleted file mode 100644 index 3e3419e..0000000 --- a/tests/App/config/doctrine74.yaml +++ /dev/null @@ -1,27 +0,0 @@ -doctrine: - dbal: - default_connection: default - connections: - default: - # configure these for your database server - driver: 'pdo_sqlite' - charset: utf8mb4 - default_table_options: - charset: utf8mb4 - collate: utf8mb4_unicode_ci - url: 'sqlite:///%kernel.project_dir%/var/data.db' - orm: - default_entity_manager: default - auto_generate_proxy_classes: true - entity_managers: - default: - connection: default - naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware - auto_mapping: true - mappings: - Annotation: - is_bundle: false - type: annotation - dir: '%kernel.project_dir%/../Model/Annotations' - prefix: 'Odandb\DoctrineCiphersweetEncryptionBundle\Tests\Model\Annotations' - alias: Annotation diff --git a/tests/App/config/doctrine80.yaml b/tests/App/config/doctrine80.yaml deleted file mode 100644 index c388fb0..0000000 --- a/tests/App/config/doctrine80.yaml +++ /dev/null @@ -1,33 +0,0 @@ -doctrine: - dbal: - default_connection: default - connections: - default: - # configure these for your database server - driver: 'pdo_sqlite' - charset: utf8mb4 - default_table_options: - charset: utf8mb4 - collate: utf8mb4_unicode_ci - url: 'sqlite:///%kernel.project_dir%/var/data.db' - orm: - default_entity_manager: default - auto_generate_proxy_classes: true - entity_managers: - default: - connection: default - naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware - auto_mapping: true - mappings: - Annotation: - is_bundle: false - type: annotation - dir: '%kernel.project_dir%/../Model/Annotations' - prefix: 'Odandb\DoctrineCiphersweetEncryptionBundle\Tests\Model\Annotations' - alias: Annotation - Attribute: - is_bundle: false - type: attribute - dir: '%kernel.project_dir%/../Model/Attributes' - prefix: 'Odandb\DoctrineCiphersweetEncryptionBundle\Tests\Model\Attributes' - alias: Attribute diff --git a/tests/App/config/packages/framework.yaml b/tests/App/config/packages/framework.yaml deleted file mode 100644 index 2ee7eb4..0000000 --- a/tests/App/config/packages/framework.yaml +++ /dev/null @@ -1,2 +0,0 @@ -framework: - test: true diff --git a/tests/App/config/packages/routing.yaml b/tests/App/config/packages/routing.yaml deleted file mode 100644 index 5ed9bbb..0000000 --- a/tests/App/config/packages/routing.yaml +++ /dev/null @@ -1,3 +0,0 @@ -framework: - router: - utf8: true diff --git a/tests/App/config/services.yaml b/tests/App/config/services.yaml deleted file mode 100644 index 6f7c8ed..0000000 --- a/tests/App/config/services.yaml +++ /dev/null @@ -1,4 +0,0 @@ -services: - _defaults: - autowire: true - autoconfigure: true diff --git a/tests/App/doctrine.yaml b/tests/App/doctrine.yaml new file mode 100644 index 0000000..c745a20 --- /dev/null +++ b/tests/App/doctrine.yaml @@ -0,0 +1,19 @@ +doctrine: + orm: + default_entity_manager: default + auto_generate_proxy_classes: true + naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware + auto_mapping: true + mappings: + Attribute: + is_bundle: false + type: attribute + dir: '%kernel.project_dir%/Model' + prefix: 'Odandb\DoctrineCiphersweetEncryptionBundle\Tests\App\Model' + alias: Attribute + query_cache_driver: + type: pool + pool: doctrine.system_cache_pool + result_cache_driver: + type: pool + pool: doctrine.result_cache_pool diff --git a/tests/App/routing.yaml b/tests/App/routing.yaml new file mode 100644 index 0000000..ca8ca3d --- /dev/null +++ b/tests/App/routing.yaml @@ -0,0 +1,14 @@ +list_entities: + path: /entities + controller: Odandb\DoctrineCiphersweetEncryptionBundle\Tests\App\Controller\TestController::entityList + methods: GET + +create_entity: + path: /entity + controller: Odandb\DoctrineCiphersweetEncryptionBundle\Tests\App\Controller\TestController::createEntity + methods: POST + +update_entity: + path: /entity/{id} + controller: Odandb\DoctrineCiphersweetEncryptionBundle\Tests\App\Controller\TestController::updateEntity + methods: PUT diff --git a/tests/App/services.yaml b/tests/App/services.yaml new file mode 100644 index 0000000..4a02a86 --- /dev/null +++ b/tests/App/services.yaml @@ -0,0 +1,19 @@ +services: + _defaults: + autowire: true + autoconfigure: true + + # Controller + Odandb\DoctrineCiphersweetEncryptionBundle\Tests\App\Controller\: + resource: 'Controller' + tags: [ 'controller.service_arguments' ] + + # Encrypt + Odandb\DoctrineCiphersweetEncryptionBundle\Tests\App\Encryptors\CiphersweetEncryptorObservable: + arguments: + - '@encryption.paragon.cipher_sweet' + Odandb\DoctrineCiphersweetEncryptionBundle\Encryptors\EncryptorInterface: '@Odandb\DoctrineCiphersweetEncryptionBundle\Tests\App\Encryptors\CiphersweetEncryptorObservable' + + # Repository + Odandb\DoctrineCiphersweetEncryptionBundle\Tests\App\Repository\MyEntityAttributeIndexesRepository: ~ + Odandb\DoctrineCiphersweetEncryptionBundle\Tests\App\Repository\MyEntityAttributeRepository: ~ diff --git a/tests/Functional/FunctionalTest.php b/tests/Functional/FunctionalTest.php new file mode 100644 index 0000000..edd8fb2 --- /dev/null +++ b/tests/Functional/FunctionalTest.php @@ -0,0 +1,55 @@ +client = self::createClient(); + + $this->encryptor = static::getContainer()->get(EncryptorInterface::class); + } + + public function testEntityListDecrypt(): void + { + static::getContainer()->get('services_resetter')->reset(); + + $this->client->request('GET', '/entities'); + + self::assertResponseIsSuccessful(); + self::assertSame(8, $this->encryptor->callsCount['decrypt'], 'Decrypt method is not called the required number of times'); // 4 entities loaded with 2 fields + + $json = json_decode($this->client->getResponse()->getContent(), true); + self::assertIsArray($json['data']); + self::assertIsInt($json['data'][0]['accountNumber']); + } + + public function testCreateEntityEncrypt(): void + { + static::getContainer()->get('services_resetter')->reset(); + + $this->client->request('POST', '/entity'); + + self::assertResponseIsSuccessful(); + self::assertSame(2, $this->encryptor->callsCount['encrypt'], 'Encrypt method is not called the required number of times'); // 1 entity loaded with 2 fields + } + + public function testUpdateEntityDecryptAndEncrypt(): void + { + static::getContainer()->get('services_resetter')->reset(); + + $this->client->request('PUT', '/entity/1'); + + self::assertResponseIsSuccessful(); + self::assertSame(2, $this->encryptor->callsCount['decrypt'], 'Decrypt method is not called the required number of times'); // 1 entity loaded with 2 fields + self::assertSame(1, $this->encryptor->callsCount['encrypt'], 'Encrypt method is not called the required number of times'); // 1 field edited + } +} diff --git a/tests/Model/Annotations/MyEntity.php b/tests/Model/Annotations/MyEntity.php deleted file mode 100644 index d312f3b..0000000 --- a/tests/Model/Annotations/MyEntity.php +++ /dev/null @@ -1,66 +0,0 @@ -accountName = $accountName; - } - - public function getId(): ?int - { - return $this->id; - } - - public function getAccountName(): string - { - return $this->accountName; - } - - public function setAccountName(string $accountName): void - { - $this->accountName = $accountName; - } - - public function getAccountNameBi(): string - { - return $this->accountNameBi; - } - - public function setAccountNameBi(string $accountNameBi): self - { - $this->accountNameBi = $accountNameBi; - - return $this; - } -} diff --git a/tests/Model/Attributes/MyEntityAttribute.php b/tests/Model/Attributes/MyEntityAttribute.php deleted file mode 100644 index 98c65a6..0000000 --- a/tests/Model/Attributes/MyEntityAttribute.php +++ /dev/null @@ -1,59 +0,0 @@ -accountName = $accountName; - } - - public function getId(): ?int - { - return $this->id; - } - - public function getAccountName(): string - { - return $this->accountName; - } - - public function setAccountName(string $accountName): void - { - $this->accountName = $accountName; - } - - public function getAccountNameBi(): string - { - return $this->accountNameBi; - } - - public function setAccountNameBi(string $accountNameBi): self - { - $this->accountNameBi = $accountNameBi; - - return $this; - } -} diff --git a/tests/Repository/MyEntityRepository.php b/tests/Repository/MyEntityRepository.php deleted file mode 100644 index f4fd165..0000000 --- a/tests/Repository/MyEntityRepository.php +++ /dev/null @@ -1,19 +0,0 @@ -encryptor = new CiphersweetEncryptorObservable($engine); } - public function testGetBlindIndex() + public function testGetBlindIndex(): void { $bi = $this->encryptor->getBlindIndex('my_entity', 'account_name', 'test'); $this->assertSame(8, mb_strlen($bi)); } - public function testPrepareForStorage() + public function testPrepareForStorage(): void { - $this->encryptor->prepareForStorage(new MyEntity('132456'), 'account_name', 'test1'); - $this->encryptor->prepareForStorage(new MyEntity('132456'), 'account_name', 'test1'); - $this->encryptor->prepareForStorage(new MyEntity('132456'), 'account_name', 'test1'); - $result = $this->encryptor->prepareForStorage(new MyEntity('132456'), 'account_name', 'test1'); + $this->encryptor->prepareForStorage(new MyEntityAttribute('132456'), 'account_name', 'test1'); + $result = $this->encryptor->prepareForStorage(new MyEntityAttribute('132456'), 'account_name', 'test1'); + $this->assertSame(2, count($result)); $this->assertSame(65, mb_strlen($result[0])); $this->assertSame(8, mb_strlen($result[1]['account_name_bi'])); - $this->assertSame(1, $this->encryptor->callsCount['encrypt']); } - public function testGetPrefix() + public function testGetPrefix(): void { $this->assertSame('nacl:', $this->encryptor->getPrefix()); } - public function testDecrypt() + public function testDecrypt(): void { - [$encryptedString] = $this->encryptor->prepareForStorage(new MyEntity('132456'), 'account_name', 'test'); + [$encryptedString] = $this->encryptor->prepareForStorage(new MyEntityAttribute('132456'), 'account_name', 'test'); - $this->encryptor->decrypt(MyEntity::class, 'account_name', $encryptedString); - $this->encryptor->decrypt(MyEntity::class, 'account_name', $encryptedString); - $this->encryptor->decrypt(MyEntity::class, 'account_name', $encryptedString); + $this->encryptor->decrypt(MyEntityAttribute::class, 'account_name', $encryptedString); + $result = $this->encryptor->decrypt(MyEntityAttribute::class, 'account_name', $encryptedString); - $result = $this->encryptor->decrypt(MyEntity::class, 'account_name', $encryptedString); $this->assertSame('test', $result); $this->assertSame(0, $this->encryptor->callsCount['decrypt'], 'doDecrypt is never called because cache is set upon prepareForStorage call'); } diff --git a/tests/Unit/Services/IndexesGenerators/TokenizerGeneratorTest.php b/tests/Unit/Services/IndexesGenerators/TokenizerGeneratorTest.php index 82092de..4ded0c2 100644 --- a/tests/Unit/Services/IndexesGenerators/TokenizerGeneratorTest.php +++ b/tests/Unit/Services/IndexesGenerators/TokenizerGeneratorTest.php @@ -7,8 +7,7 @@ class TokenizerGeneratorTest extends TestCase { - - public function testGenerate() + public function testGenerate(): void { $generator = new TokenizerGenerator(); diff --git a/tests/Unit/Services/IndexesGenerators/ValueEndingByGeneratorTest.php b/tests/Unit/Services/IndexesGenerators/ValueEndingByGeneratorTest.php index 7bf2d35..e5275d1 100644 --- a/tests/Unit/Services/IndexesGenerators/ValueEndingByGeneratorTest.php +++ b/tests/Unit/Services/IndexesGenerators/ValueEndingByGeneratorTest.php @@ -7,8 +7,7 @@ class ValueEndingByGeneratorTest extends TestCase { - - public function testGenerate() + public function testGenerate(): void { $generator = new ValueEndingByGenerator(); diff --git a/tests/Unit/Services/IndexesGenerators/ValueStartingByGeneratorTest.php b/tests/Unit/Services/IndexesGenerators/ValueStartingByGeneratorTest.php index a2e16f1..f007557 100644 --- a/tests/Unit/Services/IndexesGenerators/ValueStartingByGeneratorTest.php +++ b/tests/Unit/Services/IndexesGenerators/ValueStartingByGeneratorTest.php @@ -7,8 +7,7 @@ class ValueStartingByGeneratorTest extends TestCase { - - public function testGenerate() + public function testGenerate(): void { $generator = new ValueStartingByGenerator(); diff --git a/tests/Unit/Subscribers/DoctrineCiphersweetSubscriberTest.php b/tests/Unit/Subscribers/DoctrineCiphersweetSubscriberTest.php index f139ce1..545eef3 100644 --- a/tests/Unit/Subscribers/DoctrineCiphersweetSubscriberTest.php +++ b/tests/Unit/Subscribers/DoctrineCiphersweetSubscriberTest.php @@ -5,8 +5,7 @@ use Doctrine\ORM\EntityManagerInterface; use Odandb\DoctrineCiphersweetEncryptionBundle\Encryptors\EncryptorInterface; use Odandb\DoctrineCiphersweetEncryptionBundle\Subscribers\DoctrineCiphersweetSubscriber; -use Odandb\DoctrineCiphersweetEncryptionBundle\Tests\Model\Annotations\MyEntity; -use Odandb\DoctrineCiphersweetEncryptionBundle\Tests\Model\Attributes\MyEntityAttribute; +use Odandb\DoctrineCiphersweetEncryptionBundle\Tests\App\Model\MyEntityAttribute; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; class DoctrineCiphersweetSubscriberTest extends KernelTestCase @@ -15,35 +14,17 @@ class DoctrineCiphersweetSubscriberTest extends KernelTestCase private ?EncryptorInterface $encryptor; private ?DoctrineCiphersweetSubscriber $service; - public function setUp(): void + protected function setUp(): void { static::bootKernel(); $this->em = static::getContainer()->get(EntityManagerInterface::class); $this->encryptor = static::getContainer()->get(EncryptorInterface::class); - $this->service = static::getContainer()->get(DoctrineCiphersweetSubscriber::class); - } - - /** - * @group legacy - */ - public function testProcessFieldsAnnotations() - { - $entity = new MyEntity('test'); - $this->service->processFields($entity, $this->em); - - $this->assertStringStartsWith($this->encryptor->getPrefix(), $entity->getAccountName()); - - $this->service->processFields($entity, $this->em, false); - $this->assertSame('test', $entity->getAccountName()); + $this->service = static::getContainer()->get('encryption.subscriber'); } public function testProcessFieldsAttributes() { - if (PHP_VERSION_ID < 80000) { - $this->markTestSkipped('require PHP 8.0'); - } - $entity = new MyEntityAttribute('test'); $this->service->processFields($entity, $this->em); diff --git a/tests/bootstrap.php b/tests/bootstrap.php deleted file mode 100644 index f34a1f6..0000000 --- a/tests/bootstrap.php +++ /dev/null @@ -1,14 +0,0 @@ -get('doctrine:schema:drop')->run(new StringInput('--force --quiet'), $output); +$application->get('doctrine:schema:create')->run(new StringInput('--quiet'), $output); + +$em = $kernel->getContainer()->get('doctrine')->getManager(); +$entities = []; +for ($i = 0; $i < 4; ++$i) { + $entities[] = $entity = new MyEntityAttribute('ODB' . $i, $i); + $em->persist($entity); +} +$em->flush();