From 4e8b5c77a3ffe6cc2329d71d37f592b1db8533f4 Mon Sep 17 00:00:00 2001 From: Martin Ficzel Date: Sun, 3 Mar 2024 15:36:37 +0100 Subject: [PATCH] FEATURE: Add `Flow\Policy` Attribute/Annotation The `Flow\Policy` attribute allows to assign the required policies (mostly roles) directly on the affected method. This allows to avoid dealing with Policy.yaml in projects in simple cases where is sometimes is annoying to look up the exact syntax for that. Hint: While this is a very convenient way to add policies in project code it should not be used in libraries/packages that expect to be configured for the outside. In such cases the policy.yaml is still preferred as it is easier to overwrite. Usage: ```php use Neos\Flow\Mvc\Controller\ActionController; use Neos\Flow\Annotations as Flow; use Neos\Flow\Security\Authorization\Privilege\PrivilegeInterface; class ExampleController extends ActionController { /** * By assigning a policy with a role argument access to the method is granted to the specified role */ #[Flow\Policy(role: 'Neos.Flow:Everybody')] public function everybodyAction(): void { } /** * By specifying the permission in addition and the DENY and ABSTAIN can be configured aswell * Flow\Policy attributes can be assigned multiple times if multiple roles are to be configured */ #[Flow\Policy(role: 'Neos.Flow:Administrator', permission: PrivilegeInterface::GRANT)] #[Flow\Policy(role: 'Neos.Flow:Anonymous', permission: PrivilegeInterface::DENY)] public function adminButNotAnonymousAction(): void { } } ``` The package: `Meteko.PolicyAnnotation` by @sorenmalling implemented the same ideas earlier. Resolves: #2060 --- Neos.Flow/Classes/Annotations/Policy.php | 41 +++++++ Neos.Flow/Classes/Package.php | 4 + .../Policy/PolicyAnnotationService.php | 54 +++++++++ .../TheDefinitiveGuide/PartIII/Security.rst | 38 +++++++ .../Policy/PolicyAnnotationServiceTest.php | 106 ++++++++++++++++++ 5 files changed, 243 insertions(+) create mode 100644 Neos.Flow/Classes/Annotations/Policy.php create mode 100644 Neos.Flow/Classes/Security/Policy/PolicyAnnotationService.php create mode 100644 Neos.Flow/Tests/Unit/Security/Policy/PolicyAnnotationServiceTest.php diff --git a/Neos.Flow/Classes/Annotations/Policy.php b/Neos.Flow/Classes/Annotations/Policy.php new file mode 100644 index 0000000000..bb659da462 --- /dev/null +++ b/Neos.Flow/Classes/Annotations/Policy.php @@ -0,0 +1,41 @@ +permission, PrivilegeInterface::ABSTAIN, PrivilegeInterface::DENY, PrivilegeInterface::GRANT), 1614931217); + } + } +} diff --git a/Neos.Flow/Classes/Package.php b/Neos.Flow/Classes/Package.php index 94405c7a34..88f37fa094 100644 --- a/Neos.Flow/Classes/Package.php +++ b/Neos.Flow/Classes/Package.php @@ -26,6 +26,8 @@ use Neos\Flow\Security\Authentication\TokenInterface; use Neos\Flow\Security\Context; use Neos\Flow\Security\Cryptography\PrecomposedHashProvider; +use Neos\Flow\Security\Policy\PolicyAnnotationService; +use Neos\Flow\Security\Policy\PolicyService; /** * The Flow Package @@ -160,5 +162,7 @@ public function boot(Core\Bootstrap $bootstrap) $annotationsCacheFlusher = $bootstrap->getObjectManager()->get(AnnotationsCacheFlusher::class); $annotationsCacheFlusher->flushConfigurationCachesByCompiledClass($classNames); }); + + $dispatcher->connect(PolicyService::class, 'configurationLoaded', PolicyAnnotationService::class, 'ammendPolicyConfiguration'); } } diff --git a/Neos.Flow/Classes/Security/Policy/PolicyAnnotationService.php b/Neos.Flow/Classes/Security/Policy/PolicyAnnotationService.php new file mode 100644 index 0000000000..cc346024a7 --- /dev/null +++ b/Neos.Flow/Classes/Security/Policy/PolicyAnnotationService.php @@ -0,0 +1,54 @@ +reflectionService->getClassesContainingMethodsAnnotatedWith(Flow\Policy::class); + foreach ($annotatedClasses as $className) { + $annotatedMethods = $this->reflectionService->getMethodsAnnotatedWith($className, Flow\Policy::class); + // avoid methods beeing called multiple times when attributes are assigned more than once + $annotatedMethods = array_unique($annotatedMethods); + foreach ($annotatedMethods as $methodName) { + /** + * @var Flow\Policy[] $annotations + */ + $annotations = $this->reflectionService->getMethodAnnotations($className, $methodName, Flow\Policy::class); + $privilegeTargetMatcher = sprintf('method(%s->%s())', $className, $methodName); + $privilegeTargetIdentifier = 'FromPhpAttribute:' . (str_replace('\\', '.', $className)) . ':'. $methodName . ':'. md5($privilegeTargetMatcher); + $policyConfiguration['privilegeTargets'][MethodPrivilege::class][$privilegeTargetIdentifier] = ['matcher' => $privilegeTargetMatcher]; + foreach ($annotations as $annotation) { + $policyConfiguration['roles'][$annotation->role]['privileges'][] = [ + 'privilegeTarget' => $privilegeTargetIdentifier, + 'permission' => $annotation->permission + ]; + } + } + } + } +} diff --git a/Neos.Flow/Documentation/TheDefinitiveGuide/PartIII/Security.rst b/Neos.Flow/Documentation/TheDefinitiveGuide/PartIII/Security.rst index 82cfd01690..69eafb62d0 100644 --- a/Neos.Flow/Documentation/TheDefinitiveGuide/PartIII/Security.rst +++ b/Neos.Flow/Documentation/TheDefinitiveGuide/PartIII/Security.rst @@ -1105,6 +1105,44 @@ By defining privilege targets, all matched subjects (methods, entities, etc.) wi permissions to allow access to them for certain roles. The use of a DENY permission should be the ultimate last resort for edge cases. Be careful, there is no way to override a DENY permission, if you use it anyways! +Using policy attributes / annotations +------------------------------------- + +The ``Flow\Policy`` Attribute allows to specify method privileges directly by annotating the php code of the affected method. + +.. note:: + + While this is a very convenient way to add policies in project code it should not be used in libraries/packages + that expect to be configured for the outside. In such cases the policy.yaml is still preferred as it is easier + to overwrite. + +.. code-block:: php + + use Neos\Flow\Mvc\Controller\ActionController; + use Neos\Flow\Annotations as Flow; + use Neos\Flow\Security\Authorization\Privilege\PrivilegeInterface; + + class ExampleController extends ActionController + { + /** + * By assigning a policy with a role argument access to the method is granted to the specified role + */ + #[Flow\Policy(role: 'Neos.Flow:Everybody')] + public function everybodyAction(): void + { + } + + /** + * By specifying the permission in addition and the DENY and ABSTAIN can be configured aswell + * Flow\Policy attributes can be assigned multiple times if multiple roles are to be configured + */ + #[Flow\Policy(role: 'Neos.Flow:Administrator', permission: PrivilegeInterface::GRANT)] + #[Flow\Policy(role: 'Neos.Flow:Anonymous', permission: PrivilegeInterface::DENY)] + public function adminButNotAnonymousAction(): void + { + } + } + Using privilege parameters -------------------------- diff --git a/Neos.Flow/Tests/Unit/Security/Policy/PolicyAnnotationServiceTest.php b/Neos.Flow/Tests/Unit/Security/Policy/PolicyAnnotationServiceTest.php new file mode 100644 index 0000000000..8f83369a52 --- /dev/null +++ b/Neos.Flow/Tests/Unit/Security/Policy/PolicyAnnotationServiceTest.php @@ -0,0 +1,106 @@ +mockReflectionService = $this->getMockBuilder(ReflectionService::class)->disableOriginalConstructor()->getMock(); + $this->policyAnnotationService = new PolicyAnnotationService( + $this->mockReflectionService + ); + } + + /** + * @test + */ + public function policyConfigurationIsNotModifiedIfNoAnnotationsAreFound() + { + $this->mockReflectionService->expects($this->once()) + ->method('getClassesContainingMethodsAnnotatedWith') + ->with(Policy::class) + ->willReturn([]); + + $policyConfiguration = []; + + $this->policyAnnotationService->ammendPolicyConfiguration($policyConfiguration); + + $this->assertSame( + [], + $policyConfiguration, + ); + } + + /** + * @test + */ + public function policyConfigurationIsCreatedForAnnotationsCreated() + { + $this->mockReflectionService->expects($this->once()) + ->method('getClassesContainingMethodsAnnotatedWith') + ->with(Policy::class) + ->willReturn(['Vendor\Example']); + + $this->mockReflectionService->expects($this->once()) + ->method('getMethodsAnnotatedWith') + ->with('Vendor\Example', Policy::class) + ->willReturn(['annotatedMethod']); + + $this->mockReflectionService->expects($this->once()) + ->method('getMethodAnnotations') + ->with('Vendor\Example', 'annotatedMethod', Policy::class) + ->willReturn([new Policy('Neos.Flow:Administrator'), new Policy('Neos.Flow:Anonymous', PrivilegeInterface::DENY)]); + + $policyConfiguration = []; + + $this->policyAnnotationService->ammendPolicyConfiguration($policyConfiguration); + $expectedTargetId = 'FromPhpAttribute:Vendor.Example:annotatedMethod:' . md5('method(Vendor\Example->annotatedMethod())'); + + $this->assertSame( + [ + 'privilegeTargets' => [ + MethodPrivilege::class => [ + $expectedTargetId => [ + 'matcher' => 'method(Vendor\Example->annotatedMethod())' + ] + ] + ], + 'roles' => [ + 'Neos.Flow:Administrator' => ['privileges' => [['privilegeTarget'=> $expectedTargetId, 'permission' => 'grant']]], + 'Neos.Flow:Anonymous' => ['privileges' => [['privilegeTarget'=> $expectedTargetId, 'permission' => 'deny']]] + ] + ], + $policyConfiguration, + ); + } +}