From 9288a4b7415e8e72c51b92815c94e4bc31ee1ae4 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 avoids having to create / extend the Policy.yaml in projects 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 ++++++++++++ .../Classes/Security/Policy/PolicyService.php | 49 ++++++++++++++- .../Security/Policy/PolicyServiceTest.php | 63 +++++++++++++++++++ 3 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 Neos.Flow/Classes/Annotations/Policy.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/Security/Policy/PolicyService.php b/Neos.Flow/Classes/Security/Policy/PolicyService.php index b78229dad8..a34b21cce4 100644 --- a/Neos.Flow/Classes/Security/Policy/PolicyService.php +++ b/Neos.Flow/Classes/Security/Policy/PolicyService.php @@ -17,6 +17,8 @@ use Neos\Flow\Configuration\ConfigurationManager; use Neos\Flow\Configuration\Exception\InvalidConfigurationTypeException; use Neos\Flow\ObjectManagement\ObjectManagerInterface; +use Neos\Flow\Reflection\ReflectionService; +use Neos\Flow\Security\Authorization\Privilege\Method\MethodPrivilege; use Neos\Flow\Security\Authorization\Privilege\Parameter\PrivilegeParameterDefinition; use Neos\Flow\Security\Authorization\Privilege\PrivilegeTarget; use Neos\Flow\Security\Exception\NoSuchRoleException; @@ -47,7 +49,7 @@ class PolicyService /** * @var array */ - protected $policyConfiguration; + public $policyConfiguration; /** * @var PrivilegeTarget[] @@ -64,6 +66,11 @@ class PolicyService */ protected $objectManager; + /** + * @var ReflectionService + */ + protected $reflectionService; + /** * This object is created very early so we can't rely on AOP for the property injection * @@ -86,6 +93,16 @@ public function injectObjectManager(ObjectManagerInterface $objectManager): void $this->objectManager = $objectManager; } + /** + * This object is created very early so we can't rely on AOP for the property injection + * + * @param ReflectionService $reflectionService + */ + public function injectReflectionService(ReflectionService $reflectionService): void + { + $this->reflectionService = $reflectionService; + } + /** * Parses the global policy configuration and initializes roles and privileges accordingly * @@ -100,6 +117,7 @@ protected function initialize(): void } $this->policyConfiguration = $this->configurationManager->getConfiguration(ConfigurationManager::CONFIGURATION_TYPE_POLICY); + $this->policyConfiguration = $this->addPolicyConfigurationForAnnotations($this->policyConfiguration); $this->emitConfigurationLoaded($this->policyConfiguration); $this->initializePrivilegeTargets(); @@ -170,6 +188,35 @@ protected function initialize(): void $this->initialized = true; } + /** + * Add policy configuration for Flow\Policy annotations and attributes + */ + private function addPolicyConfigurationForAnnotations(array $policyConfiguration): array + { + $annotatedClasses = $this->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 + ]; + } + } + } + return $policyConfiguration; + } + /** * Initialized all configured privilege targets from the policy definitions * diff --git a/Neos.Flow/Tests/Unit/Security/Policy/PolicyServiceTest.php b/Neos.Flow/Tests/Unit/Security/Policy/PolicyServiceTest.php index 6f7bcd9885..f11ebe9718 100644 --- a/Neos.Flow/Tests/Unit/Security/Policy/PolicyServiceTest.php +++ b/Neos.Flow/Tests/Unit/Security/Policy/PolicyServiceTest.php @@ -11,14 +11,21 @@ * source code. */ +use Neos\Flow\Annotations\Policy; +use Neos\Flow\Aop\JoinPointInterface; use Neos\Flow\Configuration\ConfigurationManager; use Neos\Flow\ObjectManagement\ObjectManager; +use Neos\Flow\Reflection\ReflectionService; use Neos\Flow\Security\Authorization\Privilege\AbstractPrivilege; +use Neos\Flow\Security\Authorization\Privilege\Method\MethodPrivilege; +use Neos\Flow\Security\Authorization\Privilege\Method\MethodPrivilegeSubject; +use Neos\Flow\Security\Authorization\Privilege\PrivilegeInterface; use Neos\Flow\Security\Authorization\Privilege\PrivilegeTarget; use Neos\Flow\Security\Exception\NoSuchRoleException; use Neos\Flow\Security\Policy\PolicyService; use Neos\Flow\Security\Policy\Role; use Neos\Flow\Tests\UnitTestCase; +use ReflectionClass; /** * Testcase for for the PolicyService @@ -45,6 +52,11 @@ class PolicyServiceTest extends UnitTestCase */ protected $mockObjectManager; + /** + * @var ReflectionService|\PHPUnit\Framework\MockObject\MockObject + */ + protected $mockReflectionService; + /** * @var AbstractPrivilege|\PHPUnit\Framework\MockObject\MockObject */ @@ -63,6 +75,9 @@ protected function setUp(): void $this->mockObjectManager = $this->getMockBuilder(ObjectManager::class)->disableOriginalConstructor()->getMock(); $this->inject($this->policyService, 'objectManager', $this->mockObjectManager); + $this->mockReflectionService = $this->getMockBuilder(ReflectionService::class)->disableOriginalConstructor()->getMock(); + $this->inject($this->policyService, 'reflectionService', $this->mockReflectionService); + $this->mockPrivilege = $this->getAccessibleMock(AbstractPrivilege::class, ['matchesSubject'], [], '', false); } @@ -345,4 +360,52 @@ public function everybodyRoleCanHaveExplicitDenies() $everybodyRole = $this->policyService->getRole('Neos.Flow:Everybody'); self::assertTrue($everybodyRole->getPrivilegeForTarget('Some.PrivilegeTarget:Identifier')->isDenied()); } + + /** + * @test + */ + public function policyAnnotationsAreCreated() + { + $this->mockPolicyConfiguration = []; + + $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)]); + + + $class = new \ReflectionClass($this->policyService); + $method = $class->getMethod('addPolicyConfigurationForAnnotations'); + $method->setAccessible(true); + + $modifiedConfiguration = $method->invoke($this->policyService, []); + $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']]] + ] + ], + $modifiedConfiguration, + ); + } }