From 4e0f4fb31813299e6add424a2cf5f446dac2023b 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 ++++++++++++ .../Classes/Security/Policy/PolicyService.php | 49 ++++++++++++++- .../TheDefinitiveGuide/PartIII/Security.rst | 38 ++++++++++++ .../Security/Policy/PolicyServiceTest.php | 62 ++++++++++++++++++- 4 files changed, 188 insertions(+), 2 deletions(-) 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..b002847f97 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 + */ + protected 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/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/PolicyServiceTest.php b/Neos.Flow/Tests/Unit/Security/Policy/PolicyServiceTest.php index 6f7bcd9885..08276a3064 100644 --- a/Neos.Flow/Tests/Unit/Security/Policy/PolicyServiceTest.php +++ b/Neos.Flow/Tests/Unit/Security/Policy/PolicyServiceTest.php @@ -11,9 +11,13 @@ * source code. */ +use Neos\Flow\Annotations\Policy; 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\PrivilegeInterface; use Neos\Flow\Security\Authorization\Privilege\PrivilegeTarget; use Neos\Flow\Security\Exception\NoSuchRoleException; use Neos\Flow\Security\Policy\PolicyService; @@ -21,7 +25,7 @@ use Neos\Flow\Tests\UnitTestCase; /** - * Testcase for for the PolicyService + * Testcase for the PolicyService */ class PolicyServiceTest extends UnitTestCase { @@ -45,6 +49,11 @@ class PolicyServiceTest extends UnitTestCase */ protected $mockObjectManager; + /** + * @var ReflectionService|\PHPUnit\Framework\MockObject\MockObject + */ + protected $mockReflectionService; + /** * @var AbstractPrivilege|\PHPUnit\Framework\MockObject\MockObject */ @@ -63,6 +72,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 +357,52 @@ public function everybodyRoleCanHaveExplicitDenies() $everybodyRole = $this->policyService->getRole('Neos.Flow:Everybody'); self::assertTrue($everybodyRole->getPrivilegeForTarget('Some.PrivilegeTarget:Identifier')->isDenied()); } + + /** + * @test + */ + public function policyConfigurationIsCreatedForAnnotationsCreated() + { + $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)]); + + // make protected method accessible + $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, + ); + } }