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, + ); + } }