diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 34a1440..dabea00 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -66,6 +66,12 @@ parameters: count: 1 path: src/Extension/Cryptography/Cipher/OpensslCipherKeyFactory.php + - + message: '#^Cannot access property \$nameToField on mixed\.$#' + identifier: property.nonObject + count: 1 + path: src/Extension/Cryptography/LegacyCryptographyMetadataEnricher.php + - message: '#^Method Patchlevel\\Hydrator\\Guesser\\BuiltInGuesser\:\:guess\(\) has parameter \$type with generic class Symfony\\Component\\TypeInfo\\Type\\ObjectType but does not specify its types\: T$#' identifier: missingType.generics diff --git a/src/Extension/Cryptography/CryptographyExtension.php b/src/Extension/Cryptography/CryptographyExtension.php index 83963d1..5dbffa0 100644 --- a/src/Extension/Cryptography/CryptographyExtension.php +++ b/src/Extension/Cryptography/CryptographyExtension.php @@ -14,6 +14,7 @@ final class CryptographyExtension implements Extension public function __construct( private readonly Cryptographer $cryptography, private readonly PayloadCryptographer|null $legacyCryptographer = null, + private readonly bool $legacyMetadataMapping = false, ) { } @@ -22,6 +23,10 @@ public function configure(StackHydratorBuilder $builder): void $builder->addMetadataEnricher(new CryptographyMetadataEnricher(), 64); $builder->addMiddleware(new CryptographyMiddleware($this->cryptography), 64); + if ($this->legacyMetadataMapping) { + $builder->addMetadataEnricher(new LegacyCryptographyMetadataEnricher(), 63); + } + if ($this->legacyCryptographer === null) { return; } diff --git a/src/Extension/Cryptography/LegacyCryptographyMetadataEnricher.php b/src/Extension/Cryptography/LegacyCryptographyMetadataEnricher.php new file mode 100644 index 0000000..126dac1 --- /dev/null +++ b/src/Extension/Cryptography/LegacyCryptographyMetadataEnricher.php @@ -0,0 +1,82 @@ + $subjectIdMapping */ + $subjectIdMapping = isset($classMetadata->extras[SubjectIdFieldMapping::class]) + ? $classMetadata->extras[SubjectIdFieldMapping::class]->nameToField + : []; + + foreach ($classMetadata->properties as $property) { + $attributeReflectionList = $property->reflection->getAttributes(DataSubjectId::class); + + if ($attributeReflectionList) { + if (array_key_exists(self::SUBJECT_ID, $subjectIdMapping)) { + throw new DuplicateSubjectIdIdentifier( + $classMetadata->className, + $classMetadata->propertyForField($subjectIdMapping[self::SUBJECT_ID])->propertyName, + $property->propertyName, + self::SUBJECT_ID, + ); + } + + $subjectIdMapping[self::SUBJECT_ID] = $property->fieldName; + } + + $sensitiveDataInfo = $this->sensitiveDataInfo($property->reflection); + + if (!$sensitiveDataInfo) { + continue; + } + + if (in_array($property->fieldName, $subjectIdMapping, true)) { + throw new SubjectIdAndSensitiveDataConflict($classMetadata->className, $property->propertyName); + } + + if (isset($property->extras[SensitiveDataInfo::class])) { + throw new PersonalDataAndSensitiveDataOnSameProperty($classMetadata->className, $property->propertyName); + } + + $property->extras[SensitiveDataInfo::class] = $sensitiveDataInfo; + } + + if ($subjectIdMapping === []) { + return; + } + + $classMetadata->extras[SubjectIdFieldMapping::class] = new SubjectIdFieldMapping($subjectIdMapping); + } + + private function sensitiveDataInfo(ReflectionProperty $reflectionProperty): SensitiveDataInfo|null + { + $attributeReflectionList = $reflectionProperty->getAttributes(PersonalData::class); + + if ($attributeReflectionList === []) { + return null; + } + + $attribute = $attributeReflectionList[0]->newInstance(); + + return new SensitiveDataInfo( + self::SUBJECT_ID, + $attribute->fallbackCallable !== null ? ($attribute->fallbackCallable)(...) : $attribute->fallback, + ); + } +} diff --git a/src/Extension/Cryptography/PersonalDataAndSensitiveDataOnSameProperty.php b/src/Extension/Cryptography/PersonalDataAndSensitiveDataOnSameProperty.php new file mode 100644 index 0000000..4d3c7fc --- /dev/null +++ b/src/Extension/Cryptography/PersonalDataAndSensitiveDataOnSameProperty.php @@ -0,0 +1,26 @@ +guessers); - krsort($this->metadataEnrichers); - krsort($this->middlewares); - $metadataFactory = new EnrichingMetadataFactory( new AttributeMetadataFactory( - guesser: new ChainGuesser(array_merge(...$this->guessers)), + guesser: new ChainGuesser($this->guessers()), ), - array_merge(...$this->metadataEnrichers), + $this->metadataEnrichers(), ); if ($this->cache instanceof CacheItemPoolInterface) { @@ -102,8 +98,37 @@ public function build(): StackHydrator return new StackHydrator( $metadataFactory, - array_merge(...$this->middlewares), + $this->middlewares(), $this->defaultLazy, ); } + + public function defaultLazy(): bool + { + return $this->defaultLazy; + } + + /** @return list */ + public function middlewares(): array + { + krsort($this->middlewares); + + return array_merge(...$this->middlewares); + } + + /** @return list */ + public function guessers(): array + { + krsort($this->guessers); + + return array_merge(...$this->guessers); + } + + /** @return list */ + public function metadataEnrichers(): array + { + krsort($this->metadataEnrichers); + + return array_merge(...$this->metadataEnrichers); + } } diff --git a/tests/Unit/Extension/Cryptography/CryptographyExtensionTest.php b/tests/Unit/Extension/Cryptography/CryptographyExtensionTest.php new file mode 100644 index 0000000..d526b7b --- /dev/null +++ b/tests/Unit/Extension/Cryptography/CryptographyExtensionTest.php @@ -0,0 +1,71 @@ +createMock(Cryptographer::class); + + $extension = new CryptographyExtension($cryptographer); + $extension->configure($builder); + + $middlewares = $builder->middlewares(); + self::assertCount(1, $middlewares); + self::assertInstanceOf(CryptographyMiddleware::class, $middlewares[0]); + + $metadataEnrichers = $builder->metadataEnrichers(); + self::assertCount(1, $metadataEnrichers); + self::assertInstanceOf(CryptographyMetadataEnricher::class, $metadataEnrichers[0]); + } + + public function testConfigureWithLegacyMetadataMapping(): void + { + $builder = new StackHydratorBuilder(); + $cryptographer = $this->createMock(Cryptographer::class); + + $extension = new CryptographyExtension($cryptographer, legacyMetadataMapping: true); + $extension->configure($builder); + + $metadataEnrichers = $builder->metadataEnrichers(); + self::assertCount(2, $metadataEnrichers); + self::assertInstanceOf(CryptographyMetadataEnricher::class, $metadataEnrichers[0]); + self::assertInstanceOf(LegacyCryptographyMetadataEnricher::class, $metadataEnrichers[1]); + } + + public function testConfigureWithLegacyCryptographerAndMetadataMapping(): void + { + $builder = new StackHydratorBuilder(); + $cryptographer = $this->createMock(Cryptographer::class); + $legacyCryptographer = $this->createMock(PayloadCryptographer::class); + + $extension = new CryptographyExtension($cryptographer, $legacyCryptographer, true); + $extension->configure($builder); + + $middlewares = $builder->middlewares(); + self::assertCount(2, $middlewares); + self::assertInstanceOf(LegacyCryptographyDecryptMiddleware::class, $middlewares[0]); + self::assertInstanceOf(CryptographyMiddleware::class, $middlewares[1]); + + $metadataEnrichers = $builder->metadataEnrichers(); + self::assertCount(2, $metadataEnrichers); + self::assertInstanceOf(CryptographyMetadataEnricher::class, $metadataEnrichers[0]); + self::assertInstanceOf(LegacyCryptographyMetadataEnricher::class, $metadataEnrichers[1]); + } +} diff --git a/tests/Unit/Extension/Cryptography/LegacyCryptographyMetadataEnricherTest.php b/tests/Unit/Extension/Cryptography/LegacyCryptographyMetadataEnricherTest.php new file mode 100644 index 0000000..9f83001 --- /dev/null +++ b/tests/Unit/Extension/Cryptography/LegacyCryptographyMetadataEnricherTest.php @@ -0,0 +1,142 @@ +metadata($event::class, new LegacyCryptographyMetadataEnricher()); + + self::assertArrayHasKey(SubjectIdFieldMapping::class, $metadata->extras); + $subjectIdFieldMapping = $metadata->extras[SubjectIdFieldMapping::class]; + self::assertInstanceOf(SubjectIdFieldMapping::class, $subjectIdFieldMapping); + self::assertSame(['legacy' => '_id'], $subjectIdFieldMapping->nameToField); + + $property = $metadata->propertyForField('_name'); + self::assertArrayHasKey(SensitiveDataInfo::class, $property->extras); + self::assertEquals(new SensitiveDataInfo('legacy', 'fallback'), $property->extras[SensitiveDataInfo::class]); + } + + public function testSubjectIdAndSensitiveDataConflict(): void + { + $event = new class ('legacy', 'id', 'name') { + public function __construct( + #[LegacyDataSubjectId] + public string $legacyId, + #[DataSubjectId] + #[PersonalData] + public string $id, + public string $name, + ) { + } + }; + + $this->expectException(SubjectIdAndSensitiveDataConflict::class); + + $this->metadata( + $event::class, + new CryptographyMetadataEnricher(), + new LegacyCryptographyMetadataEnricher(), + ); + } + + public function testMultipleDataSubjectIdWithSameIdentifier(): void + { + $event = new class ('legacyId', 'id', 'name') { + public function __construct( + #[LegacyDataSubjectId] + public string $legacyId, + #[DataSubjectId(name: 'legacy')] + public string $id, + public string $name, + ) { + } + }; + + $this->expectException(DuplicateSubjectIdIdentifier::class); + + $this->metadata( + $event::class, + new CryptographyMetadataEnricher(), + new LegacyCryptographyMetadataEnricher(), + ); + } + + public function testPersonalDataAndSensitiveDataOnSameProperty(): void + { + $event = new class ('id', 'name') { + public function __construct( + #[LegacyDataSubjectId] + public string $id, + #[SensitiveData] + #[PersonalData] + public string $name, + ) { + } + }; + + $this->expectException(PersonalDataAndSensitiveDataOnSameProperty::class); + + $this->metadata( + $event::class, + new CryptographyMetadataEnricher(), + new LegacyCryptographyMetadataEnricher(), + ); + } + + public function testNoLegacyAttributes(): void + { + $event = new class { + }; + + $metadata = $this->metadata($event::class, new LegacyCryptographyMetadataEnricher()); + + self::assertArrayNotHasKey(SubjectIdFieldMapping::class, $metadata->extras); + } + + /** @param class-string $class */ + private function metadata(string $class, MetadataEnricher ...$enricherList): ClassMetadata + { + $metadata = (new AttributeMetadataFactory())->metadata($class); + + foreach ($enricherList as $enricher) { + $enricher->enrich($metadata); + } + + return $metadata; + } +} diff --git a/tests/Unit/StackHydratorBuilderTest.php b/tests/Unit/StackHydratorBuilderTest.php index 774a86d..bb4b9e8 100644 --- a/tests/Unit/StackHydratorBuilderTest.php +++ b/tests/Unit/StackHydratorBuilderTest.php @@ -39,6 +39,7 @@ public function testAddMiddlewareWithPriority(): void $middlewares = $reflection->getValue($hydrator); self::assertSame([$middleware2, $middleware1], $middlewares); + self::assertSame([$middleware2, $middleware1], $builder->middlewares()); } public function testAddMetadataEnricherWithPriority(): void @@ -61,6 +62,7 @@ public function testAddMetadataEnricherWithPriority(): void $enrichers = $reflection->getValue($enrichingMetadataFactory); self::assertSame([$enricher2, $enricher1], $enrichers); + self::assertSame([$enricher2, $enricher1], $builder->metadataEnrichers()); } public function testAddGuesserWithPriority(): void @@ -93,6 +95,7 @@ public function testAddGuesserWithPriority(): void $guessers = $reflection->getValue($guesser); self::assertSame([$guesser2, $guesser1], $guessers); + self::assertSame([$guesser2, $guesser1], $builder->guessers()); } public function testEnableDefaultLazy(): void @@ -104,6 +107,7 @@ public function testEnableDefaultLazy(): void $reflection = new ReflectionProperty(StackHydrator::class, 'defaultLazy'); self::assertTrue($reflection->getValue($hydrator)); + self::assertTrue($builder->defaultLazy()); } public function testUseExtension(): void