Skip to content

Commit

Permalink
FEATURE: Add Flow\Policy Annotations/Attributes
Browse files Browse the repository at this point in the history
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
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: neos#2060
  • Loading branch information
mficzel committed Mar 3, 2024
1 parent 5b8bcba commit aa13a02
Show file tree
Hide file tree
Showing 3 changed files with 124 additions and 0 deletions.
41 changes: 41 additions & 0 deletions Neos.Flow/Classes/Annotations/Policy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);

namespace Neos\Flow\Annotations;

/*
* This file is part of the Neos.Flow package.
*
* (c) Contributors of the Neos Project - www.neos.io
*
* This package is Open Source Software. For the full copyright and license
* information, please view the LICENSE file which was distributed with this
* source code.
*/

use Doctrine\Common\Annotations\Annotation\NamedArgumentConstructor;
use Neos\Flow\Security\Authorization\Privilege\PrivilegeInterface;

/**
* Adds a policy configuration to a method
*
* This is a convenient way to add policies in project code
* but should not be used in libraries/packages that shall be
* configured for different use cases.
*
* @Annotation
* @NamedArgumentConstructor
* @Target({"METHOD"})
*/
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
final class Policy
{
public function __construct(
public readonly string $role,
public readonly string $permission = 'grant',
) {
if (!in_array($permission, [PrivilegeInterface::ABSTAIN, PrivilegeInterface::DENY, PrivilegeInterface::GRANT])) {
throw new \InvalidArgumentException(sprintf('Permission value "%s" is invalid. Allowed values are "%s", "%s" and "%s"', $this->permission, PrivilegeInterface::ABSTAIN, PrivilegeInterface::DENY, PrivilegeInterface::GRANT), 1614931217);
}
}
}
48 changes: 48 additions & 0 deletions Neos.Flow/Classes/Security/Policy/PolicyService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
*
Expand All @@ -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
*
Expand All @@ -100,6 +117,7 @@ protected function initialize(): void
}

$this->policyConfiguration = $this->configurationManager->getConfiguration(ConfigurationManager::CONFIGURATION_TYPE_POLICY);
$this->additionalConfigurationFromAnnotations();
$this->emitConfigurationLoaded($this->policyConfiguration);

$this->initializePrivilegeTargets();
Expand Down Expand Up @@ -170,6 +188,34 @@ protected function initialize(): void
$this->initialized = true;
}

/**
* Create configuration from Flow\Policy annotations and attributes
*/
public function additionalConfigurationFromAnnotations(): void
{
$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 . ':'. sha1($privilegeTargetMatcher);
$this->policyConfiguration['privilegeTargets'][MethodPrivilege::class][$privilegeTargetIdentifier] = ['matcher' => $privilegeTargetMatcher];
foreach ($annotations as $annotation) {
$this->policyConfiguration['roles'][$annotation->role]['privileges'][] = [
'privilegeTarget' => $privilegeTargetIdentifier,
'permission' => $annotation->permission
];
}
}
}
}

/**
* Initialized all configured privilege targets from the policy definitions
*
Expand All @@ -178,6 +224,8 @@ protected function initialize(): void
*/
protected function initializePrivilegeTargets(): void
{


if (!isset($this->policyConfiguration['privilegeTargets'])) {
return;
}
Expand Down
35 changes: 35 additions & 0 deletions Neos.Flow/Tests/Unit/Security/Policy/PolicyServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@
* 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\PrivilegeTarget;
use Neos\Flow\Security\Exception\NoSuchRoleException;
Expand Down Expand Up @@ -45,6 +47,11 @@ class PolicyServiceTest extends UnitTestCase
*/
protected $mockObjectManager;

/**
* @var ReflectionService|\PHPUnit\Framework\MockObject\MockObject
*/
protected $mockReflectionService;

/**
* @var AbstractPrivilege|\PHPUnit\Framework\MockObject\MockObject
*/
Expand All @@ -63,6 +70,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);
}

Expand Down Expand Up @@ -342,6 +352,31 @@ public function everybodyRoleCanHaveExplicitDenies()
],
];

$everybodyRole = $this->policyService->getRole('Neos.Flow:Everybody');
self::assertTrue($everybodyRole->getPrivilegeForTarget('Some.PrivilegeTarget:Identifier')->isDenied());
}

/**
* @test
*/
public function policyAnnotationsAreCreated()
{
$this->mockReflectionService->expects($this->once())
->method('getClassesContainingMethodsAnnotatedWith')
->with(Policy::class)
->willReturn(['Fake\Class']);

$this->mockReflectionService->expects($this->once())
->method('getMethodsAnnotatedWith')
->with('Fake\Class', Policy::class)
->willReturn(['methodA', 'methodB', 'methodC']);

$this->mockReflectionService->expects($this->once())
->method('getMethodAnnotations')
->with('Fake\Class', Policy::class)
->willReturn(['methodA', 'methodB', 'methodC']);


$everybodyRole = $this->policyService->getRole('Neos.Flow:Everybody');
self::assertTrue($everybodyRole->getPrivilegeForTarget('Some.PrivilegeTarget:Identifier')->isDenied());
}
Expand Down

0 comments on commit aa13a02

Please sign in to comment.