diff --git a/CHANGELOG.md b/CHANGELOG.md index 42cbfc7..33f6e4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,46 @@ All notable changes to this project will be documented in this file, in reverse chronological order by release. +## 2.7.0 - 2018-05-01 + +### Added + +- [#23](https://github.com/zendframework/zend-permissions-acl/pull/23) adds a new assertion, `ExpressionAssertion`, to allow programatically or + automatically (from configuration) building standard comparison assertions + using a variety of operators, including `=` (`==`), `!=`, `<`, `<=`, `>`, + `>=`, `===`, `!==`, `in` (`in_array`), `!in` (`! in_array`), `regex` + (`preg_match`), and `!regex` (`! preg_match`). See https://docs.zendframework.com/zend-permissions-acl/expression/ + for details on usage. + +- [#3](https://github.com/zendframework/zend-permissions-acl/pull/3) adds two new interfaces designed to allow creation of ownership-based assertions + easier: + + - `Zend\Permissions\Acl\ProprietaryInterface` is applicable to both roles and + resources, and provides the method `getOwnerId()` for retrieving the owner + role of an object. + + - `Zend\Permissions\Acl\Assertion\OwnershipAssertion` ensures that the owner + of a proprietary resource matches that of the role. + + See https://docs.zendframework.com/zend-permissions-acl/ownership/ for details + on usage. + +### Changed + +- Nothing. + +### Deprecated + +- Nothing. + +### Removed + +- Nothing. + +### Fixed + +- Nothing. + ## 2.6.1 - 2018-05-01 ### Added diff --git a/composer.json b/composer.json index 0b5f755..fc40fde 100644 --- a/composer.json +++ b/composer.json @@ -41,8 +41,8 @@ }, "extra": { "branch-alias": { - "dev-master": "2.6.x-dev", - "dev-develop": "2.7.x-dev" + "dev-master": "2.7.x-dev", + "dev-develop": "2.8.x-dev" } }, "scripts": { diff --git a/composer.lock b/composer.lock index 72c662c..2e54989 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c4a7f676cf69c2596abe9f68386c51ac", + "content-hash": "ee45bb6b33c3a5cdd93c74705598236d", "packages": [], "packages-dev": [ { diff --git a/docs/book/expression.md b/docs/book/expression.md new file mode 100644 index 0000000..c4a92e3 --- /dev/null +++ b/docs/book/expression.md @@ -0,0 +1,195 @@ +# Expression Assertions + +- Since 2.7.0 + +Many custom assertions are doing basic comparisons: + +- Equality of a role property to a value or property of the resource. +- Other comparisons (`>`, `<`, `in_array`, etc.) of a role property to a value + or values (potentially a property of the resource). +- Regular expressions. + +While these can be easily accommodated by the `CallbackAssertion`, such +assertions have one notable problem: they cannot be easily serialized. + +To facilitate such assertions, we now provide +`Zend\Permissions\Acl\Assertion\ExpressionAssertion`. This class provides two +static factory methods for creating an instance, each expecting the following: + +- The left operand +- An operator +- The right operand + +When the assertion is executed, it uses the operator to determine how to compare +the two operands, and thus answer the assertion. + +## Operands + +The operands can be any PHP value. + +Additionally, they can be an associative array containing the key +`ExpressionAssertion::OPERAND_CONTEXT_PROPERTY` (`__context`), with a string +value. + +That value can be one of the following: + +- A string matching the values "acl", "privilege", "role", or "resource", with + the latter two being most common. When one of these is provided, the + corresponding argument to the `assert()` method will be used. + +- A dot-separated string with the first segment being one of the above values, + and the second being a property or field of that object. The + `ExpressionAssertion` will test for: + + - a method matching `get()` + - a method matching `is()` + - a public property named `` + + in that specific order. In the first two cases, `` will be normalized + to WordCase when creating the method name to test. + +## Operators + +`ExpressionAssertion` supports the following operators: + +```php + const OPERATOR_EQ = '='; + const OPERATOR_NEQ = '!='; + const OPERATOR_LT = '<'; + const OPERATOR_LTE = '<='; + const OPERATOR_GT = '>'; + const OPERATOR_GTE = '>='; + const OPERATOR_IN = 'in'; + const OPERATOR_NIN = '!in'; + const OPERATOR_REGEX = 'regex'; + const OPERATOR_NREGEX = '!regex'; + const OPERATOR_SAME = '==='; + const OPERATOR_NSAME = '!=='; +``` + +In most cases, these will operate using the operators as listed above, with the +following exceptions: + +- `OPERATOR_EQ` will use `==` as the comparison operator; `OPERATOR_NEQ` will + likewise use `!=`. +- `OPERATOR_IN` and `OPERATOR_NIN` use `in_array()` (with the latter negating + the result), both doing strict comparisons. The right hand operand is expected + to be the array in which to look for results, and the left hand operand is + expected to be the needle to look for. +- `OPERATOR_REGEX` and `OPERATOR_NREGEX` will perform a `preg_match()` + operation, using the right hand operand as the regular expression, and the + left hand operand as the value to compare. + +## Constructors + +The constructor of `ExpressionAssertion` is private. Instead, you will use one +of two static methods in order to create instances: + +- `fromProperties($left, $operator, $right)` +- `fromArray(array $expression)` (expects keys for "left", "operator", and "right") + +When creating expressions manually, the first is generally the best choice. When +storing expressions in configuration or a database, the latter is useful, as you +can pass a row of data at a time to the method to get expression instances. + +## Examples + +First, we'll define both a role and a resource: + +```php +namespace Blog\Entity; + +use Zend\Permissions\Acl\Resource\ResourceInterface; +use Zend\Permissions\Acl\Role\RoleInterface; + +class BlogPost implements ResourceInterface +{ + public $title; + + public $shortDescription; + + public $content; + + public $author; + + public function __construct(array $data = []) + { + foreach ($data as $property => $value) { + $this->$property = $value; + } + } + + public function getResourceId() + { + return 'blogPost'; + } + + public function getShortDescription() + { + return $this->shortDescription; + } + + public function getAuthorName() + { + return $this->author ? $this->author->username : ''; + } +} + +class User implements RoleInterface +{ + public $username; + + public $role = 'guest'; + + public $age; + + public function __construct(array $data = []) + { + foreach ($data as $property => $value) { + $this->$property = $value; + } + } + + public function getRoleId() + { + return $this->role; + } + + public function isAdult() + { + return $this->age >= 18; + } +} +``` + +Next, let's define some assertions. + +```php +use Zend\Permissions\Acl\Assertion\ExpressionAssertion; + +// Username of role must be "test": +// Will access $username property on the role instance. +$isTestUser = ExpressionAssertion::fromProperties( + [ExpressionAssertion::OPERAND_CONTEXT_PROPERTY => 'role.username'], + '===', + 'test' +); + + +// Role must be at least 18 years old: +// Will execute `isAdult()` on the role instance. +$isOfLegalAge = ExpressionAssertion::fromProperties( + [ExpressionAssertion::OPERAND_CONTEXT_PROPERTY => 'role.adult'], + '===', + true +); + +// Must have edited text: +// Will do a regex comparison on the shortDescription of the blog post +// to ensure we do not have filler text. +$isEditedDescription = ExpressionAssertion::fromArray([ + 'left' => [ExpressionAssertion::OPERAND_CONTEXT_PROPERTY => 'resource.shortDescription'], + 'operator' => '!regex', + 'right' => '/lorem ipsum/i', +]); +``` diff --git a/docs/book/ownership.md b/docs/book/ownership.md new file mode 100644 index 0000000..a675e2b --- /dev/null +++ b/docs/book/ownership.md @@ -0,0 +1,126 @@ +# Ownership Assertions + +- Since 2.7.0 + +When setting up permissions for an application, site owners common will want to +allow roles to manipulate resources owned by the user with that role. For +example, a blog author should have permission to _write_ new posts, and also to +_modify_ his or her **own** posts, but **not** posts of other authors. + +To accomodate this use case, we provide two interfaces: + +- **`Zend\Acl\ProprietaryInterface`** is applicable to _resources_ and _roles_. + It provides information about the _owner_ of an object. Objects implementing + this interface are used in conjunction with the `OwnershipAssertion`. + +- **`Zend\Acl\Assertion\OwnershipAssertion`** ensures that a resource is owned + by a specific role by comparing it to owners provided by + `ProprietaryInterface` implementations. + +### Example + +Consider the following entities: + +```php +namespace MyApp\Entity; + +use Zend\Permissions\Acl\ProprietaryInterface; +use Zend\Permissions\Acl\Resource\ResourceInterface; +use Zend\Permissions\Acl\Role\RoleInterface; + +class User implements RoleInterface, ProprietaryInterface +{ + protected $id; + + protected $role = 'guest'; + + public function __construct($id, $role) + { + $this->id = $id; + $this->role = $role; + } + + public function getRoleId() + { + return $this->role; + } + + public function getOwnerId() + { + return $this->id; + } +} + +class BlogPost implements ResourceInterface, ProprietaryInterface +{ + public $author = null; + + public function getResourceId() + { + return 'blogPost'; + } + + public function getOwnerId() + { + if ($this->author === null) { + return null; + } + + return $this->author->getOwnerId(); + } +} +``` + +The `User` marks itself as an _owner_ by implementing `ProprietaryInterface`; +its `getOwnerId()` method will return the user identifier provided during +instantiation. + +A `BlogPost` marks itself as a resource and an _owner_ by also implementing +`ProprietaryInterface`; in its case, it returns the author identifier, if +present, but `null` otherwise. + +Now let's wire these up into an ACL: + +```php +namespace MyApp; + +use MyApp\Entity; +use Zend\Permissions\Acl\Acl; +use Zend\Permissions\Acl\Assertion\OwnershipAssertion; + +$acl = new Acl(); +$acl->addRole('guest'); +$acl->addRole('member', 'guest'); +$acl->addRole('author', 'member'); +$acl->addRole('admin'); + +$acl->addResource('blogPost'); +$acl->addResource('comment'); + +$acl->allow('guest', 'blogPost', 'view'); +$acl->allow('guest', 'comment', array('view', 'submit')); +$acl->allow('author', 'blogPost', 'write'); +$acl->allow('author', 'blogPost', 'edit', new OwnershipAssertion()); +$acl->allow('admin'); + +$author1 = new User(1, 'author'); +$author2 = new User(2, 'author'); + +$blogPost = new BlogPost(); +$blogPost->author = $author1; +``` + +The takeaways from the above should be: + +- An `author` can _write_ blog posts, and _edit_ posts it owns. +- `$author1` and `$author2` are both authors. +- `$author1` is the author of `$blogPost`. + +Knowing these facts, we can expect the following assertion results: + +```php +$acl->isAllowed($author1, 'blogPost', 'write'); // true +$acl->isAllowed($author1, $blogPost, 'edit'); // true +$acl->isAllowed($author2, 'blogPost', 'write'); // true +$acl->isAllowed($author2, $blogPost, 'edit'); // false +``` diff --git a/mkdocs.yml b/mkdocs.yml index b3fd8ff..9019ef5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -4,8 +4,11 @@ pages: - index.md - 'Theory and Usage': usage.md - 'Refining ACLs': refining.md - - 'Advanced Usage': advanced.md + - Reference: + - 'Ownership Assertions': ownership.md + - 'Expression Assertions': expression.md + - 'Advanced Usage': advanced.md site_name: zend-permissions-acl -site_description: Zend\Permissions.acl +site_description: 'Provides a lightweight and flexible access control list (ACL) implementation for privileges management' repo_url: 'https://github.com/zendframework/zend-permissions-acl' copyright: 'Copyright (c) 2005-2018 Zend Technologies USA Inc.' diff --git a/src/Assertion/ExpressionAssertion.php b/src/Assertion/ExpressionAssertion.php new file mode 100644 index 0000000..df18997 --- /dev/null +++ b/src/Assertion/ExpressionAssertion.php @@ -0,0 +1,349 @@ +'; + const OPERATOR_GTE = '>='; + const OPERATOR_IN = 'in'; + const OPERATOR_NIN = '!in'; + const OPERATOR_REGEX = 'regex'; + const OPERATOR_NREGEX = '!regex'; + const OPERATOR_SAME = '==='; + const OPERATOR_NSAME = '!=='; + + /** + * @var array + */ + private static $validOperators = [ + self::OPERATOR_EQ, + self::OPERATOR_NEQ, + self::OPERATOR_LT, + self::OPERATOR_LTE, + self::OPERATOR_GT, + self::OPERATOR_GTE, + self::OPERATOR_IN, + self::OPERATOR_NIN, + self::OPERATOR_REGEX, + self::OPERATOR_NREGEX, + self::OPERATOR_SAME, + self::OPERATOR_NSAME, + ]; + + /** + * @var mixed + */ + private $left; + + /** + * @var string + */ + private $operator; + + /** + * @var mixed + */ + private $right; + + /** + * Constructor + * + * Note that the constructor is marked private; use `fromProperties()` or + * `fromArray()` to create an instance. + * + * @param mixed|array $left See the class description for valid values. + * @param string $operator One of the OPERATOR constants (or their values) + * @param mixed|array $right See the class description for valid values. + */ + private function __construct($left, $operator, $right) + { + $this->left = $left; + $this->operator = $operator; + $this->right = $right; + } + + /** + * @param mixed|array $left See the class description for valid values. + * @param string $operator One of the OPERATOR constants (or their values) + * @param mixed|array $right See the class description for valid values. + * @return self + * @throws InvalidAssertionException if either operand is invalid. + * @throws InvalidAssertionException if the operator is not supported. + */ + public static function fromProperties($left, $operator, $right) + { + $operator = strtolower($operator); + + self::validateOperand($left); + self::validateOperator($operator); + self::validateOperand($right); + + return new self($left, $operator, $right); + } + + /** + * @param array $expression Must contain the following keys: + * - left: the left-hand side of the expression + * - operator: the operator to use for the comparison + * - right: the right-hand side of the expression + * See the class description for valid values for the left and right + * hand side values. + * @return self + * @throws InvalidAssertionException if missing one of the required keys. + * @throws InvalidAssertionException if either operand is invalid. + * @throws InvalidAssertionException if the operator is not supported. + */ + public static function fromArray(array $expression) + { + $required = ['left', 'operator', 'right']; + + if (count(array_intersect_key($expression, array_flip($required))) < count($required)) { + throw new InvalidAssertionException( + "Expression assertion requires 'left', 'operator' and 'right' to be supplied" + ); + } + + return self::fromProperties( + $expression['left'], + $expression['operator'], + $expression['right'] + ); + } + + /** + * @param mixed|array $operand + * @throws InvalidAssertionException if the operand is invalid. + */ + private static function validateOperand($operand) + { + if (is_array($operand) && isset($operand[self::OPERAND_CONTEXT_PROPERTY])) { + if (! is_string($operand[self::OPERAND_CONTEXT_PROPERTY])) { + throw new InvalidAssertionException('Expression assertion context operand must be string'); + } + } + } + + /** + * @param string $operand + * @throws InvalidAssertionException if the operator is not supported. + */ + private static function validateOperator($operator) + { + if (! in_array($operator, self::$validOperators, true)) { + throw new InvalidAssertionException('Provided expression assertion operator is not supported'); + } + } + + /** + * {@inheritDoc} + */ + public function assert(Acl $acl, RoleInterface $role = null, ResourceInterface $resource = null, $privilege = null) + { + return $this->evaluate([ + 'acl' => $acl, + 'role' => $role, + 'resource' => $resource, + 'privilege' => $privilege, + ]); + } + + /** + * @param array $context Contains the acl, privilege, role, and resource + * being tested currently. + * @return bool + */ + private function evaluate(array $context) + { + $left = $this->getLeftValue($context); + $right = $this->getRightValue($context); + + return static::evaluateExpression($left, $this->operator, $right); + } + + /** + * @param array $context Contains the acl, privilege, role, and resource + * being tested currently. + * @return mixed + */ + private function getLeftValue(array $context) + { + return $this->resolveOperandValue($this->left, $context); + } + + /** + * @param array $context Contains the acl, privilege, role, and resource + * being tested currently. + * @return mixed + */ + private function getRightValue(array $context) + { + return $this->resolveOperandValue($this->right, $context); + } + + /** + * @param mixed|array + * @param array $context Contains the acl, privilege, role, and resource + * being tested currently. + * @return mixed + * @throws RuntimeException if object cannot be resolved in context. + * @throws RuntimeException if property cannot be resolved. + */ + private function resolveOperandValue($operand, array $context) + { + if (! is_array($operand) || ! isset($operand[self::OPERAND_CONTEXT_PROPERTY])) { + return $operand; + } + + $contextProperty = $operand[self::OPERAND_CONTEXT_PROPERTY]; + + if (strpos($contextProperty, '.') !== false) { // property path? + list($objectName, $objectField) = explode('.', $contextProperty, 2); + return $this->getObjectFieldValue($context, $objectName, $objectField); + } + + if (! isset($context[$contextProperty])) { + throw new RuntimeException(sprintf( + "'%s' is not available in the assertion context", + $contextProperty + )); + } + + return $context[$contextProperty]; + } + + /** + * @param array $context Contains the acl, privilege, role, and resource + * being tested currently. + * @param string $objectName Name of object in context to use. + * @param string $field + * @return mixed + * @throws RuntimeException if object cannot be resolved in context. + * @throws RuntimeException if property cannot be resolved. + */ + private function getObjectFieldValue(array $context, $objectName, $field) + { + if (! isset($context[$objectName])) { + throw new RuntimeException(sprintf( + "'%s' is not available in the assertion context", + $objectName + )); + } + + $object = $context[$objectName]; + $accessors = ['get', 'is']; + $fieldAccessor = false === strpos($field, '_') + ? $field + : str_replace(' ', '', ucwords(str_replace('_', ' ', $field))); + + foreach ($accessors as $accessor) { + $accessor .= $fieldAccessor; + + if (method_exists($object, $accessor)) { + return $object->$accessor(); + } + } + + if (! $this->propertyExists($object, $field)) { + throw new RuntimeException(sprintf( + "'%s' property cannot be resolved on the '%s' object", + $field, + $objectName + )); + } + + return $object->$field; + } + + /** + * @param mixed $left + * @param string $right + * @param mixed $right + * @throws RuntimeException if operand is not supported. + */ + private static function evaluateExpression($left, $operator, $right) + { + switch ($operator) { + case self::OPERATOR_EQ: + return $left == $right; + case self::OPERATOR_NEQ: + return $left != $right; + case self::OPERATOR_LT: + return $left < $right; + case self::OPERATOR_LTE: + return $left <= $right; + case self::OPERATOR_GT: + return $left > $right; + case self::OPERATOR_GTE: + return $left >= $right; + case self::OPERATOR_IN: + return in_array($left, $right); + case self::OPERATOR_NIN: + return ! in_array($left, $right); + case self::OPERATOR_REGEX: + return (bool) preg_match($right, $left); + case self::OPERATOR_NREGEX: + return ! (bool) preg_match($right, $left); + case self::OPERATOR_SAME: + return $left === $right; + case self::OPERATOR_NSAME: + return $left !== $right; + } + } + + /** + * @param object $object + * @param string $field + * @return mixed + */ + private function propertyExists($object, $property) + { + if (! property_exists($object, $property)) { + return false; + } + + $r = new ReflectionProperty($object, $property); + return $r->isPublic(); + } +} diff --git a/src/Assertion/OwnershipAssertion.php b/src/Assertion/OwnershipAssertion.php new file mode 100644 index 0000000..dcd1100 --- /dev/null +++ b/src/Assertion/OwnershipAssertion.php @@ -0,0 +1,34 @@ +getOwnerId() === null) { + return true; + } + + return ($resource->getOwnerId() === $role->getOwnerId()); + } +} diff --git a/src/ProprietaryInterface.php b/src/ProprietaryInterface.php new file mode 100644 index 0000000..00a388a --- /dev/null +++ b/src/ProprietaryInterface.php @@ -0,0 +1,22 @@ +acl->addRole('area') - ->getRole('area'); - $this->assertInstanceOf('Zend\Permissions\Acl\Role\RoleInterface', $role); + $role = $this->acl + ->addRole('area') + ->getRole('area'); + $this->assertInstanceOf(Role\RoleInterface::class, $role); $this->assertEquals('area', $role->getRoleId()); } @@ -73,8 +72,9 @@ public function testRoleAddAndGetOneByString() public function testRoleRegistryRemoveOne() { $roleGuest = new Role\GenericRole('guest'); - $this->acl->addRole($roleGuest) - ->removeRole($roleGuest); + $this->acl + ->addRole($roleGuest) + ->removeRole($roleGuest); $this->assertFalse($this->acl->hasRole($roleGuest)); } @@ -97,8 +97,9 @@ public function testRoleRegistryRemoveOneNonExistent() public function testRoleRegistryRemoveAll() { $roleGuest = new Role\GenericRole('guest'); - $this->acl->addRole($roleGuest) - ->removeRoleAll(); + $this->acl + ->addRole($roleGuest) + ->removeRoleAll(); $this->assertFalse($this->acl->hasRole($roleGuest)); } @@ -120,10 +121,8 @@ public function testRoleRegistryAddInheritsNonExistent() */ public function testRoleRegistryAddNotRole() { - $this->expectException( - InvalidArgumentException::class, - 'addRole() expects $role to be of type Zend\Permissions\Acl\Role' - ); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('addRole() expects $role to be of type Zend\Permissions\Acl\Role'); $this->acl->addRole(new \stdClass, 'guest'); } @@ -165,9 +164,10 @@ public function testRoleRegistryInherits() $roleMember = new Role\GenericRole('member'); $roleEditor = new Role\GenericRole('editor'); $roleRegistry = new Role\Registry(); - $roleRegistry->add($roleGuest) - ->add($roleMember, $roleGuest->getRoleId()) - ->add($roleEditor, $roleMember); + $roleRegistry + ->add($roleGuest) + ->add($roleMember, $roleGuest->getRoleId()) + ->add($roleEditor, $roleMember); $this->assertEmpty($roleRegistry->getParents($roleGuest)); $roleMemberParents = $roleRegistry->getParents($roleMember); $this->assertCount(1, $roleMemberParents); @@ -197,9 +197,10 @@ public function testRoleRegistryInheritsMultipleArray() $roleParent2 = new Role\GenericRole('parent2'); $roleChild = new Role\GenericRole('child'); $roleRegistry = new Role\Registry(); - $roleRegistry->add($roleParent1) - ->add($roleParent2) - ->add($roleChild, [$roleParent1, $roleParent2]); + $roleRegistry + ->add($roleParent1) + ->add($roleParent2) + ->add($roleChild, [$roleParent1, $roleParent2]); $roleChildParents = $roleRegistry->getParents($roleChild); $this->assertCount(2, $roleChildParents); $i = 1; @@ -227,7 +228,8 @@ public function testRoleRegistryInheritsMultipleTraversable() $roleParent2 = new Role\GenericRole('parent2'); $roleChild = new Role\GenericRole('child'); $roleRegistry = new Role\Registry(); - $roleRegistry->add($roleParent1) + $roleRegistry + ->add($roleParent1) ->add($roleParent2) ->add( $roleChild, @@ -259,8 +261,9 @@ public function testRoleRegistryDuplicate() $roleGuest = new Role\GenericRole('guest'); $roleRegistry = new Role\Registry(); $this->expectException(InvalidArgumentException::class, 'already exists'); - $roleRegistry->add($roleGuest) - ->add($roleGuest); + $roleRegistry + ->add($roleGuest) + ->add($roleGuest); } /** @@ -274,8 +277,9 @@ public function testRoleRegistryDuplicateId() $roleGuest2 = new Role\GenericRole('guest'); $roleRegistry = new Role\Registry(); $this->expectException(InvalidArgumentException::class, 'already exists'); - $roleRegistry->add($roleGuest1) - ->add($roleGuest2); + $roleRegistry + ->add($roleGuest1) + ->add($roleGuest2); } /** @@ -286,8 +290,9 @@ public function testRoleRegistryDuplicateId() public function testResourceAddAndGetOne() { $resourceArea = new Resource\GenericResource('area'); - $resource = $this->acl->addResource($resourceArea) - ->getResource($resourceArea->getResourceId()); + $resource = $this->acl + ->addResource($resourceArea) + ->getResource($resourceArea->getResourceId()); $this->assertEquals($resourceArea, $resource); $resource = $this->acl->getResource($resourceArea); $this->assertEquals($resourceArea, $resource); @@ -298,9 +303,10 @@ public function testResourceAddAndGetOne() */ public function testResourceAddAndGetOneByString() { - $resource = $this->acl->addResource('area') - ->getResource('area'); - $this->assertInstanceOf('Zend\Permissions\Acl\Resource\ResourceInterface', $resource); + $resource = $this->acl + ->addResource('area') + ->getResource('area'); + $this->assertInstanceOf(Resource\ResourceInterface::class, $resource); $this->assertEquals('area', $resource->getResourceId()); } @@ -312,8 +318,9 @@ public function testResourceAddAndGetOneByString() public function testResourceAddAndGetOneWithAddResourceMethod() { $resourceArea = new Resource\GenericResource('area'); - $resource = $this->acl->addResource($resourceArea) - ->getResource($resourceArea->getResourceId()); + $resource = $this->acl + ->addResource($resourceArea) + ->getResource($resourceArea->getResourceId()); $this->assertEquals($resourceArea, $resource); $resource = $this->acl->getResource($resourceArea); $this->assertEquals($resourceArea, $resource); @@ -327,8 +334,9 @@ public function testResourceAddAndGetOneWithAddResourceMethod() public function testResourceRemoveOne() { $resourceArea = new Resource\GenericResource('area'); - $this->acl->addResource($resourceArea) - ->removeResource($resourceArea); + $this->acl + ->addResource($resourceArea) + ->removeResource($resourceArea); $this->assertFalse($this->acl->hasResource($resourceArea)); } @@ -339,7 +347,8 @@ public function testResourceRemoveOne() */ public function testResourceRemoveOneNonExistent() { - $this->expectException(ExceptionInterface::class, 'not found'); + $this->expectException(ExceptionInterface::class); + $this->expectExceptionMessage('not found'); $this->acl->removeResource('nonexistent'); } @@ -351,8 +360,9 @@ public function testResourceRemoveOneNonExistent() public function testResourceRemoveAll() { $resourceArea = new Resource\GenericResource('area'); - $this->acl->addResource($resourceArea) - ->removeResourceAll(); + $this->acl + ->addResource($resourceArea) + ->removeResourceAll(); $this->assertFalse($this->acl->hasResource($resourceArea)); } @@ -363,7 +373,8 @@ public function testResourceRemoveAll() */ public function testResourceAddInheritsNonExistent() { - $this->expectException(InvalidArgumentException::class, 'does not exist'); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('does not exist'); $this->acl->addResource(new Resource\GenericResource('area'), 'nonexistent'); } @@ -374,11 +385,9 @@ public function testResourceAddInheritsNonExistent() */ public function testResourceRegistryAddNotResource() { - $this->expectException( - InvalidArgumentException::class, - 'addResource() expects $resource to be of type Zend\Permissions\Acl\Resource' - ); - $this->acl->addResource(new \stdClass); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('addResource() expects $resource to be of type Zend\Permissions\Acl\Resource'); + $this->acl->addResource(new stdClass); } /** @@ -393,8 +402,8 @@ public function testResourceInheritsNonExistent() try { $this->acl->inheritsResource('nonexistent', $resourceArea); $this->fail( - 'Expected Zend\Permissions\Acl\Exception\ExceptionInterface not ' - . 'thrown upon specifying a non-existent child Resource' + 'Expected Zend\Permissions\Acl\Exception\ExceptionInterface not' + . ' thrown upon specifying a non-existent child Resource' ); } catch (Acl\Exception\ExceptionInterface $e) { $this->assertContains('not found', $e->getMessage()); @@ -420,9 +429,10 @@ public function testResourceInherits() $resourceCity = new Resource\GenericResource('city'); $resourceBuilding = new Resource\GenericResource('building'); $resourceRoom = new Resource\GenericResource('room'); - $this->acl->addResource($resourceCity) - ->addResource($resourceBuilding, $resourceCity->getResourceId()) - ->addResource($resourceRoom, $resourceBuilding); + $this->acl + ->addResource($resourceCity) + ->addResource($resourceBuilding, $resourceCity->getResourceId()) + ->addResource($resourceRoom, $resourceBuilding); $this->assertTrue($this->acl->inheritsResource($resourceBuilding, $resourceCity, true)); $this->assertTrue($this->acl->inheritsResource($resourceRoom, $resourceBuilding, true)); $this->assertTrue($this->acl->inheritsResource($resourceRoom, $resourceCity)); @@ -440,10 +450,14 @@ public function testResourceInherits() */ public function testResourceDuplicate() { - $this->expectException(ExceptionInterface::class, 'already exists'); $resourceArea = new Resource\GenericResource('area'); - $this->acl->addResource($resourceArea) - ->addResource($resourceArea); + + $this->expectException(ExceptionInterface::class); + $this->expectExceptionMessage('already exists'); + + $this->acl + ->addResource($resourceArea) + ->addResource($resourceArea); } /** @@ -453,11 +467,15 @@ public function testResourceDuplicate() */ public function testResourceDuplicateId() { - $this->expectException(ExceptionInterface::class, 'already exists'); $resourceArea1 = new Resource\GenericResource('area'); $resourceArea2 = new Resource\GenericResource('area'); - $this->acl->addResource($resourceArea1) - ->addResource($resourceArea2); + + $this->expectException(ExceptionInterface::class); + $this->expectExceptionMessage('already exists'); + + $this->acl + ->addResource($resourceArea1) + ->addResource($resourceArea2); } /** @@ -578,8 +596,10 @@ public function testPrivileges() $this->assertTrue($this->acl->isAllowed(null, null, 'p2')); $this->assertTrue($this->acl->isAllowed(null, null, 'p3')); $this->assertFalse($this->acl->isAllowed(null, null, 'p4')); + $this->acl->deny(null, null, 'p1'); $this->assertFalse($this->acl->isAllowed(null, null, 'p1')); + $this->acl->deny(null, null, ['p2', 'p3']); $this->assertFalse($this->acl->isAllowed(null, null, 'p2')); $this->assertFalse($this->acl->isAllowed(null, null, 'p3')); @@ -594,6 +614,7 @@ public function testPrivilegeAssert() { $this->acl->allow(null, null, 'somePrivilege', new TestAsset\MockAssertion(true)); $this->assertTrue($this->acl->isAllowed(null, null, 'somePrivilege')); + $this->acl->allow(null, null, 'somePrivilege', new TestAsset\MockAssertion(false)); $this->assertFalse($this->acl->isAllowed(null, null, 'somePrivilege')); } @@ -618,9 +639,11 @@ public function testRoleDefaultDeny() public function testRoleDefaultRuleSet() { $roleGuest = new Role\GenericRole('guest'); - $this->acl->addRole($roleGuest) - ->allow($roleGuest); + $this->acl + ->addRole($roleGuest) + ->allow($roleGuest); $this->assertTrue($this->acl->isAllowed($roleGuest)); + $this->acl->deny($roleGuest); $this->assertFalse($this->acl->isAllowed($roleGuest)); } @@ -645,9 +668,12 @@ public function testRoleDefaultPrivilegeDeny() public function testRoleDefaultRuleSetPrivilege() { $roleGuest = new Role\GenericRole('guest'); - $this->acl->addRole($roleGuest) - ->allow($roleGuest); + + $this->acl + ->addRole($roleGuest) + ->allow($roleGuest); $this->assertTrue($this->acl->isAllowed($roleGuest, null, 'somePrivilege')); + $this->acl->deny($roleGuest); $this->assertFalse($this->acl->isAllowed($roleGuest, null, 'somePrivilege')); } @@ -660,8 +686,9 @@ public function testRoleDefaultRuleSetPrivilege() public function testRolePrivilegeAllow() { $roleGuest = new Role\GenericRole('guest'); - $this->acl->addRole($roleGuest) - ->allow($roleGuest, null, 'somePrivilege'); + $this->acl + ->addRole($roleGuest) + ->allow($roleGuest, null, 'somePrivilege'); $this->assertTrue($this->acl->isAllowed($roleGuest, null, 'somePrivilege')); } @@ -673,9 +700,10 @@ public function testRolePrivilegeAllow() public function testRolePrivilegeDeny() { $roleGuest = new Role\GenericRole('guest'); - $this->acl->addRole($roleGuest) - ->allow($roleGuest) - ->deny($roleGuest, null, 'somePrivilege'); + $this->acl + ->addRole($roleGuest) + ->allow($roleGuest) + ->deny($roleGuest, null, 'somePrivilege'); $this->assertFalse($this->acl->isAllowed($roleGuest, null, 'somePrivilege')); } @@ -687,14 +715,17 @@ public function testRolePrivilegeDeny() public function testRolePrivileges() { $roleGuest = new Role\GenericRole('guest'); - $this->acl->addRole($roleGuest) - ->allow($roleGuest, null, ['p1', 'p2', 'p3']); + $this->acl + ->addRole($roleGuest) + ->allow($roleGuest, null, ['p1', 'p2', 'p3']); $this->assertTrue($this->acl->isAllowed($roleGuest, null, 'p1')); $this->assertTrue($this->acl->isAllowed($roleGuest, null, 'p2')); $this->assertTrue($this->acl->isAllowed($roleGuest, null, 'p3')); $this->assertFalse($this->acl->isAllowed($roleGuest, null, 'p4')); + $this->acl->deny($roleGuest, null, 'p1'); $this->assertFalse($this->acl->isAllowed($roleGuest, null, 'p1')); + $this->acl->deny($roleGuest, null, ['p2', 'p3']); $this->assertFalse($this->acl->isAllowed($roleGuest, null, 'p2')); $this->assertFalse($this->acl->isAllowed($roleGuest, null, 'p3')); @@ -708,9 +739,11 @@ public function testRolePrivileges() public function testRolePrivilegeAssert() { $roleGuest = new Role\GenericRole('guest'); - $this->acl->addRole($roleGuest) - ->allow($roleGuest, null, 'somePrivilege', new TestAsset\MockAssertion(true)); + $this->acl + ->addRole($roleGuest) + ->allow($roleGuest, null, 'somePrivilege', new TestAsset\MockAssertion(true)); $this->assertTrue($this->acl->isAllowed($roleGuest, null, 'somePrivilege')); + $this->acl->allow($roleGuest, null, 'somePrivilege', new TestAsset\MockAssertion(false)); $this->assertFalse($this->acl->isAllowed($roleGuest, null, 'somePrivilege')); } @@ -771,8 +804,9 @@ public function testRemoveDefaultAllowNonExistent() */ public function testRemoveDefaultDenyNonExistent() { - $this->acl->allow() - ->removeDeny(); + $this->acl + ->allow() + ->removeDeny(); $this->assertTrue($this->acl->isAllowed()); } @@ -784,13 +818,14 @@ public function testRemoveDefaultDenyNonExistent() */ public function testRoleDefaultAllowRuleWithResourceDenyRule() { - $this->acl->addRole(new Role\GenericRole('guest')) - ->addRole(new Role\GenericRole('staff'), 'guest') - ->addResource(new Resource\GenericResource('area1')) - ->addResource(new Resource\GenericResource('area2')) - ->deny() - ->allow('staff') - ->deny('staff', ['area1', 'area2']); + $this->acl + ->addRole(new Role\GenericRole('guest')) + ->addRole(new Role\GenericRole('staff'), 'guest') + ->addResource(new Resource\GenericResource('area1')) + ->addResource(new Resource\GenericResource('area2')) + ->deny() + ->allow('staff') + ->deny('staff', ['area1', 'area2']); $this->assertFalse($this->acl->isAllowed('staff', 'area1')); } @@ -802,11 +837,12 @@ public function testRoleDefaultAllowRuleWithResourceDenyRule() */ public function testRoleDefaultAllowRuleWithPrivilegeDenyRule() { - $this->acl->addRole(new Role\GenericRole('guest')) - ->addRole(new Role\GenericRole('staff'), 'guest') - ->deny() - ->allow('staff') - ->deny('staff', null, ['privilege1', 'privilege2']); + $this->acl + ->addRole(new Role\GenericRole('guest')) + ->addRole(new Role\GenericRole('staff'), 'guest') + ->deny() + ->allow('staff') + ->deny('staff', null, ['privilege1', 'privilege2']); $this->assertFalse($this->acl->isAllowed('staff', null, 'privilege1')); } @@ -821,6 +857,7 @@ public function testRulesRemove() $this->assertFalse($this->acl->isAllowed()); $this->assertTrue($this->acl->isAllowed(null, null, 'privilege1')); $this->assertTrue($this->acl->isAllowed(null, null, 'privilege2')); + $this->acl->removeAllow(null, null, 'privilege1'); $this->assertFalse($this->acl->isAllowed(null, null, 'privilege1')); $this->assertTrue($this->acl->isAllowed(null, null, 'privilege2')); @@ -833,8 +870,9 @@ public function testRulesRemove() */ public function testRuleRoleRemove() { - $this->acl->addRole(new Role\GenericRole('guest')) - ->allow('guest'); + $this->acl + ->addRole(new Role\GenericRole('guest')) + ->allow('guest'); $this->assertTrue($this->acl->isAllowed('guest')); $this->acl->removeRole('guest'); try { @@ -856,9 +894,11 @@ public function testRuleRoleRemove() */ public function testRuleRoleRemoveAll() { - $this->acl->addRole(new Role\GenericRole('guest')) - ->allow('guest'); + $this->acl + ->addRole(new Role\GenericRole('guest')) + ->allow('guest'); $this->assertTrue($this->acl->isAllowed('guest')); + $this->acl->removeRoleAll(); try { $this->acl->isAllowed('guest'); @@ -879,8 +919,9 @@ public function testRuleRoleRemoveAll() */ public function testRulesResourceRemove() { - $this->acl->addResource(new Resource\GenericResource('area')) - ->allow(null, 'area'); + $this->acl + ->addResource(new Resource\GenericResource('area')) + ->allow(null, 'area'); $this->assertTrue($this->acl->isAllowed(null, 'area')); $this->acl->removeResource('area'); try { @@ -902,8 +943,9 @@ public function testRulesResourceRemove() */ public function testRulesResourceRemoveAll() { - $this->acl->addResource(new Resource\GenericResource('area')) - ->allow(null, 'area'); + $this->acl + ->addResource(new Resource\GenericResource('area')) + ->allow(null, 'area'); $this->assertTrue($this->acl->isAllowed(null, 'area')); $this->acl->removeResourceAll(); try { @@ -927,10 +969,11 @@ public function testRulesResourceRemoveAll() public function testCMSExample() { // Add some roles to the Role registry - $this->acl->addRole(new Role\GenericRole('guest')) - ->addRole(new Role\GenericRole('staff'), 'guest') // staff inherits permissions from guest - ->addRole(new Role\GenericRole('editor'), 'staff') // editor inherits permissions from staff - ->addRole(new Role\GenericRole('administrator')); + $this->acl + ->addRole(new Role\GenericRole('guest')) + ->addRole(new Role\GenericRole('staff'), 'guest') // staff inherits permissions from guest + ->addRole(new Role\GenericRole('editor'), 'staff') // editor inherits permissions from staff + ->addRole(new Role\GenericRole('administrator')); // Guest may only view content $this->acl->allow('guest', null, 'view'); @@ -987,12 +1030,13 @@ public function testCMSExample() $this->assertTrue($this->acl->isAllowed('administrator')); // Some checks on specific areas, which inherit access controls from the root ACL node - $this->acl->addResource(new Resource\GenericResource('newsletter')) - ->addResource(new Resource\GenericResource('pending'), 'newsletter') - ->addResource(new Resource\GenericResource('gallery')) - ->addResource(new Resource\GenericResource('profiles', 'gallery')) - ->addResource(new Resource\GenericResource('config')) - ->addResource(new Resource\GenericResource('hosts'), 'config'); + $this->acl + ->addResource(new Resource\GenericResource('newsletter')) + ->addResource(new Resource\GenericResource('pending'), 'newsletter') + ->addResource(new Resource\GenericResource('gallery')) + ->addResource(new Resource\GenericResource('profiles', 'gallery')) + ->addResource(new Resource\GenericResource('config')) + ->addResource(new Resource\GenericResource('hosts'), 'config'); $this->assertTrue($this->acl->isAllowed('guest', 'pending', 'view')); $this->assertTrue($this->acl->isAllowed('staff', 'profiles', 'revise')); $this->assertTrue($this->acl->isAllowed('staff', 'pending', 'view')); @@ -1093,9 +1137,10 @@ public function testCMSExample() */ public function testRoleInheritanceSupportsCheckingOnlyParents() { - $this->acl->addRole(new Role\GenericRole('grandparent')) - ->addRole(new Role\GenericRole('parent'), 'grandparent') - ->addRole(new Role\GenericRole('child'), 'parent'); + $this->acl + ->addRole(new Role\GenericRole('grandparent')) + ->addRole(new Role\GenericRole('parent'), 'grandparent') + ->addRole(new Role\GenericRole('child'), 'parent'); $this->assertFalse($this->acl->inheritsRole('child', 'grandparent', true)); } @@ -1153,13 +1198,13 @@ public function testAclInternalDFSMethodsBehaveProperly() */ public function testAclAssertionsGetProperRoleWhenInheritenceIsUsed() { - $acl = $this->loadUseCase1(); + $acl = $this->loadStandardUseCase(); $user = new Role\GenericRole('publisher'); $blogPost = new Resource\GenericResource('blogPost'); /** - * @var ZendTest\Permissions\Acl\UseCase1\UserIsBlogPostOwnerAssertion + * @var ZendTest\Permissions\Acl\StandardUseCase\UserIsBlogPostOwnerAssertion */ $assertion = $acl->customAssertion; @@ -1174,15 +1219,15 @@ public function testAclAssertionsGetProperRoleWhenInheritenceIsUsed() */ public function testAclAssertionsGetOriginalIsAllowedObjects() { - $acl = $this->loadUseCase1(); + $acl = $this->loadStandardUseCase(); - $user = new TestAsset\UseCase1\User(); - $blogPost = new TestAsset\UseCase1\BlogPost(); + $user = new TestAsset\StandardUseCase\User(); + $blogPost = new TestAsset\StandardUseCase\BlogPost(); $this->assertTrue($acl->isAllowed($user, $blogPost, 'view')); /** - * @var ZendTest\Permissions\Acl\UseCase1\UserIsBlogPostOwnerAssertion + * @var ZendTest\Permissions\Acl\StandardUseCase\UserIsBlogPostOwnerAssertion */ $assertion = $acl->customAssertion; @@ -1194,12 +1239,12 @@ public function testAclAssertionsGetOriginalIsAllowedObjects() // check to see if the last assertion has the proper objects $this->assertInstanceOf( - 'ZendTest\Permissions\Acl\TestAsset\UseCase1\User', + TestAsset\StandardUseCase\User::class, $assertion->lastAssertRole, 'Assertion did not receive proper role object' ); $this->assertInstanceOf( - 'ZendTest\Permissions\Acl\TestAsset\UseCase1\BlogPost', + TestAsset\StandardUseCase\BlogPost::class, $assertion->lastAssertResource, 'Assertion did not receive proper resource object' ); @@ -1207,11 +1252,11 @@ public function testAclAssertionsGetOriginalIsAllowedObjects() /** * - * @return Zend_Acl_UseCase1_Acl + * @return TestAsset\StandardUseCase\Acl */ - protected function loadUseCase1() + protected function loadStandardUseCase() { - return new TestAsset\UseCase1\Acl(); + return new TestAsset\StandardUseCase\Acl(); } /** diff --git a/test/Assertion/ExpressionAssertionTest.php b/test/Assertion/ExpressionAssertionTest.php new file mode 100644 index 0000000..abfde97 --- /dev/null +++ b/test/Assertion/ExpressionAssertionTest.php @@ -0,0 +1,401 @@ +assertInstanceOf(ExpressionAssertion::class, $assertion); + } + + public function testFromArrayCreation() + { + $assertion = ExpressionAssertion::fromArray([ + 'left' => 'foo', + 'operator' => ExpressionAssertion::OPERATOR_EQ, + 'right' => 'bar' + ]); + + $this->assertInstanceOf(ExpressionAssertion::class, $assertion); + } + + public function testExceptionIsRaisedInCaseOfInvalidExpressionArray() + { + $this->expectException(InvalidAssertionException::class); + $this->expectExceptionMessage("Expression assertion requires 'left', 'operator' and 'right' to be supplied"); + + ExpressionAssertion::fromArray(['left' => 'test', 'foo' => 'bar']); + } + + public function testExceptionIsRaisedInCaseOfInvalidExpressionContextOperandType() + { + $this->expectException(InvalidAssertionException::class); + $this->expectExceptionMessage('Expression assertion context operand must be string'); + + ExpressionAssertion::fromProperties( + [ExpressionAssertion::OPERAND_CONTEXT_PROPERTY => 123], + ExpressionAssertion::OPERATOR_IN, + 'test' + ); + } + + public function testExceptionIsRaisedInCaseOfInvalidExpressionOperator() + { + $this->expectException(InvalidAssertionException::class); + $this->expectExceptionMessage('Provided expression assertion operator is not supported'); + + ExpressionAssertion::fromProperties( + 'test', + 'invalid', + 'test' + ); + } + + /** + * @dataProvider getExpressions + */ + public function testExpressionsEvaluation(array $expression, $role, $resource, $privilege, $expectedAssert) + { + $assertion = ExpressionAssertion::fromArray($expression); + + $this->assertThat( + $assertion->assert(new Acl(), $role, $resource, $privilege), + $expectedAssert ? $this->isTrue() : $this->isFalse() + ); + } + + public function getExpressions() + { + $author3 = new User([ + 'username' => 'author3', + ]); + $post3 = new BlogPost([ + 'author' => $author3, + ]); + + return [ + 'equality' => [ + 'expression' => [ + 'left' => [ExpressionAssertion::OPERAND_CONTEXT_PROPERTY => 'role.username'], + 'operator' => ExpressionAssertion::OPERATOR_EQ, + 'right' => 'test', + ], + 'role' => new User([ + 'username' => 'test', + ]), + 'resource' => new BlogPost(), + 'privilege' => 'read', + 'assert' => true, + ], + 'inequality' => [ + 'expression' => [ + 'left' => [ExpressionAssertion::OPERAND_CONTEXT_PROPERTY => 'role.username'], + 'operator' => ExpressionAssertion::OPERATOR_NEQ, + 'right' => 'test', + ], + 'role' => new User([ + 'username' => 'foobar', + ]), + 'resource' => new BlogPost(), + 'privilege' => 'read', + 'assert' => true, + ], + 'boolean-equality' => [ + 'expression' => [ + 'left' => [ExpressionAssertion::OPERAND_CONTEXT_PROPERTY => 'role.username'], + 'operator' => ExpressionAssertion::OPERATOR_EQ, + 'right' => true, + ], + 'role' => $author3, + 'resource' => $post3, + 'privilege' => 'read', + 'assert' => true, + ], + 'greater-than' => [ + 'expression' => [ + 'left' => [ExpressionAssertion::OPERAND_CONTEXT_PROPERTY => 'role.age'], + 'operator' => ExpressionAssertion::OPERATOR_GT, + 'right' => 20, + ], + 'role' => new User([ + 'username' => 'foobar', + 'age' => 15, + ]), + 'resource' => new BlogPost(), + 'privilege' => 'read', + 'assert' => false, + ], + 'greater-than-or-equal' => [ + 'expression' => [ + 'left' => [ExpressionAssertion::OPERAND_CONTEXT_PROPERTY => 'role.age'], + 'operator' => ExpressionAssertion::OPERATOR_GTE, + 'right' => 20, + ], + 'role' => new User([ + 'username' => 'foobar', + 'age' => 20, + ]), + 'resource' => new BlogPost(), + 'privilege' => 'read', + 'assert' => true, + ], + 'less-than' => [ + 'expression' => [ + 'left' => [ExpressionAssertion::OPERAND_CONTEXT_PROPERTY => 'role.age'], + 'operator' => ExpressionAssertion::OPERATOR_LT, + 'right' => 30, + ], + 'role' => new User([ + 'username' => 'foobar', + 'age' => 20, + ]), + 'resource' => new BlogPost(), + 'privilege' => 'read', + 'assert' => true, + ], + 'less-than-or-equal' => [ + 'expression' => [ + 'left' => [ExpressionAssertion::OPERAND_CONTEXT_PROPERTY => 'role.age'], + 'operator' => ExpressionAssertion::OPERATOR_LTE, + 'right' => 30, + ], + 'role' => new User([ + 'username' => 'foobar', + 'age' => 30, + ]), + 'resource' => new BlogPost(), + 'privilege' => 'read', + 'assert' => true, + ], + 'in' => [ + 'expression' => [ + 'left' => [ExpressionAssertion::OPERAND_CONTEXT_PROPERTY => 'role.username'], + 'operator' => ExpressionAssertion::OPERATOR_IN, + 'right' => ['foo', 'bar'], + ], + 'role' => new User([ + 'username' => 'test', + ]), + 'resource' => new BlogPost(), + 'privilege' => 'read', + 'assert' => false, + ], + 'not-in' => [ + 'expression' => [ + 'left' => [ExpressionAssertion::OPERAND_CONTEXT_PROPERTY => 'role.username'], + 'operator' => ExpressionAssertion::OPERATOR_NIN, + 'right' => ['foo', 'bar'], + ], + 'role' => new User([ + 'username' => 'test', + ]), + 'resource' => new BlogPost(), + 'privilege' => 'read', + 'assert' => true, + ], + 'regex' => [ + 'expression' => [ + 'left' => [ExpressionAssertion::OPERAND_CONTEXT_PROPERTY => 'role.username'], + 'operator' => ExpressionAssertion::OPERATOR_REGEX, + 'right' => '/foobar/', + ], + 'role' => new User([ + 'username' => 'test', + ]), + 'resource' => new BlogPost(), + 'privilege' => 'read', + 'assert' => false, + ], + 'REGEX' => [ + 'expression' => [ + 'left' => [ExpressionAssertion::OPERAND_CONTEXT_PROPERTY => 'resource.shortDescription'], + 'operator' => 'REGEX', + 'right' => '/ipsum/', + ], + 'role' => new User([ + 'username' => 'test', + ]), + 'resource' => new BlogPost([ + 'title' => 'Test', + 'content' => 'lorem ipsum dolor sit amet', + 'shortDescription' => 'lorem ipsum' + ]), + 'privilege' => 'read', + 'assert' => true, + ], + 'nregex' => [ + 'expression' => [ + 'left' => [ExpressionAssertion::OPERAND_CONTEXT_PROPERTY => 'role.username'], + 'operator' => ExpressionAssertion::OPERATOR_NREGEX, + 'right' => '/barbaz/', + ], + 'role' => new User([ + 'username' => 'test', + ]), + 'resource' => new BlogPost(), + 'privilege' => 'read', + 'assert' => true, + ], + 'same' => [ + 'expression' => [ + 'left' => [ExpressionAssertion::OPERAND_CONTEXT_PROPERTY => 'role.username'], + 'operator' => ExpressionAssertion::OPERATOR_SAME, + 'right' => 'test', + ], + 'role' => new User([ + 'username' => 'test', + ]), + 'resource' => new BlogPost(), + 'privilege' => 'read', + 'assert' => true, + ], + 'not-same' => [ + 'expression' => [ + 'left' => [ExpressionAssertion::OPERAND_CONTEXT_PROPERTY => 'role.username'], + 'operator' => ExpressionAssertion::OPERATOR_NSAME, + 'right' => 'test', + ], + 'role' => new User([ + 'username' => 'foobar', + ]), + 'resource' => new BlogPost(), + 'privilege' => 'read', + 'assert' => true, + ], + 'equality-calculated-property' => [ + 'expression' => [ + 'left' => [ExpressionAssertion::OPERAND_CONTEXT_PROPERTY => 'role.adult'], + 'operator' => ExpressionAssertion::OPERATOR_EQ, + 'right' => true, + ], + 'role' => new User([ + 'username' => 'test', + 'age' => 30, + ]), + 'resource' => new BlogPost(), + 'privilege' => 'read', + 'assert' => true, + ], + 'privilege' => [ + 'expression' => [ + 'left' => [ExpressionAssertion::OPERAND_CONTEXT_PROPERTY => 'privilege'], + 'operator' => ExpressionAssertion::OPERATOR_EQ, + 'right' => 'read', + ], + 'role' => new User([ + 'username' => 'test', + ]), + 'resource' => new BlogPost(), + 'privilege' => 'update', + 'assert' => false, + ], + ]; + } + + public function testExceptionIsRaisedInCaseOfUnknownContextOperand() + { + $assertion = ExpressionAssertion::fromProperties( + [ExpressionAssertion::OPERAND_CONTEXT_PROPERTY => 'foobar'], + ExpressionAssertion::OPERATOR_EQ, + 'test' + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("'foobar' is not available in the assertion context"); + + $assertion->assert(new Acl(), new User(), new BlogPost(), 'read'); + } + + public function testExceptionIsRaisedInCaseOfUnknownContextOperandContainingPropertyPath() + { + $assertion = ExpressionAssertion::fromProperties( + [ExpressionAssertion::OPERAND_CONTEXT_PROPERTY => 'foo.bar'], + ExpressionAssertion::OPERATOR_EQ, + 'test' + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("'foo' is not available in the assertion context"); + + $assertion->assert(new Acl(), new User(), new BlogPost(), 'read'); + } + + public function testExceptionIsRaisedIfContextObjectPropertyCannotBeResolved() + { + $assertion = ExpressionAssertion::fromProperties( + [ExpressionAssertion::OPERAND_CONTEXT_PROPERTY => 'role.age123'], + ExpressionAssertion::OPERATOR_EQ, + 30 + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("'age123' property cannot be resolved on the 'role' object"); + + $assertion->assert(new Acl(), new User(), new BlogPost(), 'read'); + } + + public function testExceptionIsRaisedInCaseThatAssertHasBeenInvokedWithoutPassingContext() + { + $assertion = ExpressionAssertion::fromProperties( + [ExpressionAssertion::OPERAND_CONTEXT_PROPERTY => 'role.username'], + ExpressionAssertion::OPERATOR_EQ, + 'test' + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("'role' is not available in the assertion context"); + + $assertion->assert(new Acl()); + } + + public function testSerialization() + { + $assertion = ExpressionAssertion::fromProperties( + 'foo', + ExpressionAssertion::OPERATOR_EQ, + 'bar' + ); + + $serializedAssertion = serialize($assertion); + + $this->assertContains('left', $serializedAssertion); + $this->assertContains('foo', $serializedAssertion); + $this->assertContains('operator', $serializedAssertion); + $this->assertContains('=', $serializedAssertion); + $this->assertContains('right', $serializedAssertion); + $this->assertContains('bar', $serializedAssertion); + } + + public function testSerializationShouldNotSerializeAssertContext() + { + $assertion = ExpressionAssertion::fromProperties( + 'foo', + ExpressionAssertion::OPERATOR_EQ, + 'bar' + ); + + $serializedAssertion = serialize($assertion); + + $this->assertNotContains('assertContext', $serializedAssertion); + } +} diff --git a/test/Assertion/OwnershipAssertionTest.php b/test/Assertion/OwnershipAssertionTest.php new file mode 100644 index 0000000..8a35948 --- /dev/null +++ b/test/Assertion/OwnershipAssertionTest.php @@ -0,0 +1,59 @@ +assertTrue($acl->isAllowed('guest', 'blogPost', 'view')); + $this->assertFalse($acl->isAllowed('guest', 'blogPost', 'delete')); + } + + public function testAssertPassesIfResourceIsNotProprietary() + { + $acl = new OwnershipUseCase\Acl(); + + $author = new OwnershipUseCase\Author1(); + + $this->assertTrue($acl->isAllowed($author, 'comment', 'view')); + $this->assertFalse($acl->isAllowed($author, 'comment', 'delete')); + } + + public function testAssertPassesIfResourceDoesNotHaveOwner() + { + $acl = new OwnershipUseCase\Acl(); + + $author = new OwnershipUseCase\Author1(); + + $blogPost = new OwnershipUseCase\BlogPost(); + $blogPost->author = null; + + $this->assertTrue($acl->isAllowed($author, 'blogPost', 'write')); + $this->assertTrue($acl->isAllowed($author, $blogPost, 'edit')); + } + + public function testAssertFailsIfResourceHasOwnerOtherThanRoleOwner() + { + $acl = new OwnershipUseCase\Acl(); + + $author1 = new OwnershipUseCase\Author1(); + $author2 = new OwnershipUseCase\Author2(); + + $blogPost = new OwnershipUseCase\BlogPost(); + $blogPost->author = $author1; + + $this->assertTrue($acl->isAllowed($author2, 'blogPost', 'write')); + $this->assertFalse($acl->isAllowed($author2, $blogPost, 'edit')); + } +} diff --git a/test/TestAsset/ExpressionUseCase/BlogPost.php b/test/TestAsset/ExpressionUseCase/BlogPost.php new file mode 100644 index 0000000..70d31d5 --- /dev/null +++ b/test/TestAsset/ExpressionUseCase/BlogPost.php @@ -0,0 +1,43 @@ + $value) { + $this->$property = $value; + } + } + + public function getResourceId() + { + return 'blogPost'; + } + + public function getShortDescription() + { + return $this->shortDescription; + } + + public function getAuthorName() + { + return $this->author ? $this->author->username : ''; + } +} diff --git a/test/TestAsset/ExpressionUseCase/User.php b/test/TestAsset/ExpressionUseCase/User.php new file mode 100644 index 0000000..afbaa19 --- /dev/null +++ b/test/TestAsset/ExpressionUseCase/User.php @@ -0,0 +1,36 @@ + $value) { + $this->$property = $value; + } + } + + public function getRoleId() + { + return $this->role; + } + + public function isAdult() + { + return $this->age >= 18; + } +} diff --git a/test/TestAsset/OwnershipUseCase/Acl.php b/test/TestAsset/OwnershipUseCase/Acl.php new file mode 100644 index 0000000..25f5128 --- /dev/null +++ b/test/TestAsset/OwnershipUseCase/Acl.php @@ -0,0 +1,31 @@ +addRole('guest'); + $this->addRole('member', 'guest'); + $this->addRole('author', 'member'); + $this->addRole('admin'); + + $this->addResource(new BlogPost()); + $this->addResource(new Comment()); + + $this->allow('guest', 'blogPost', 'view'); + $this->allow('guest', 'comment', ['view', 'submit']); + $this->allow('author', 'blogPost', 'write'); + $this->allow('author', 'blogPost', 'edit', new OwnershipAssertion()); + $this->allow('admin'); + } +} diff --git a/test/TestAsset/OwnershipUseCase/Author1.php b/test/TestAsset/OwnershipUseCase/Author1.php new file mode 100644 index 0000000..d0474c7 --- /dev/null +++ b/test/TestAsset/OwnershipUseCase/Author1.php @@ -0,0 +1,15 @@ +author === null) { + return null; + } + + return $this->author->getOwnerId(); + } +} diff --git a/test/TestAsset/OwnershipUseCase/Comment.php b/test/TestAsset/OwnershipUseCase/Comment.php new file mode 100644 index 0000000..0c3c9c2 --- /dev/null +++ b/test/TestAsset/OwnershipUseCase/Comment.php @@ -0,0 +1,18 @@ +role; + } + + public function getOwnerId() + { + return $this->id; + } +} diff --git a/test/TestAsset/StandardUseCase/Acl.php b/test/TestAsset/StandardUseCase/Acl.php new file mode 100644 index 0000000..30a54fb --- /dev/null +++ b/test/TestAsset/StandardUseCase/Acl.php @@ -0,0 +1,34 @@ +customAssertion = new UserIsBlogPostOwnerAssertion(); + + $this->addRole(new GenericRole('guest')); + $this->addRole(new GenericRole('contributor'), 'guest'); + $this->addRole(new GenericRole('publisher'), 'contributor'); + $this->addRole(new GenericRole('admin')); + + $this->addResource(new GenericResource('blogPost')); + + $this->allow('guest', 'blogPost', 'view'); + $this->allow('contributor', 'blogPost', 'contribute'); + $this->allow('contributor', 'blogPost', 'modify', $this->customAssertion); + $this->allow('publisher', 'blogPost', 'publish'); + } +} diff --git a/test/TestAsset/StandardUseCase/BlogPost.php b/test/TestAsset/StandardUseCase/BlogPost.php new file mode 100644 index 0000000..55f97f0 --- /dev/null +++ b/test/TestAsset/StandardUseCase/BlogPost.php @@ -0,0 +1,20 @@ +role; + } +} diff --git a/test/TestAsset/StandardUseCase/UserIsBlogPostOwnerAssertion.php b/test/TestAsset/StandardUseCase/UserIsBlogPostOwnerAssertion.php new file mode 100644 index 0000000..c4ed4cf --- /dev/null +++ b/test/TestAsset/StandardUseCase/UserIsBlogPostOwnerAssertion.php @@ -0,0 +1,33 @@ +lastAssertRole = $user; + $this->lastAssertResource = $blogPost; + $this->lastAssertPrivilege = $privilege; + return $this->assertReturnValue; + } +} diff --git a/test/TestAsset/UseCase1/Acl.php b/test/TestAsset/UseCase1/Acl.php deleted file mode 100644 index eb74ebe..0000000 --- a/test/TestAsset/UseCase1/Acl.php +++ /dev/null @@ -1,31 +0,0 @@ -customAssertion = new UserIsBlogPostOwnerAssertion(); - - $this->addRole(new \Zend\Permissions\Acl\Role\GenericRole('guest')); - $this->addRole(new \Zend\Permissions\Acl\Role\GenericRole('contributor'), 'guest'); - $this->addRole(new \Zend\Permissions\Acl\Role\GenericRole('publisher'), 'contributor'); - $this->addRole(new \Zend\Permissions\Acl\Role\GenericRole('admin')); - $this->addResource(new \Zend\Permissions\Acl\Resource\GenericResource('blogPost')); - $this->allow('guest', 'blogPost', 'view'); - $this->allow('contributor', 'blogPost', 'contribute'); - $this->allow('contributor', 'blogPost', 'modify', $this->customAssertion); - $this->allow('publisher', 'blogPost', 'publish'); - } -} diff --git a/test/TestAsset/UseCase1/BlogPost.php b/test/TestAsset/UseCase1/BlogPost.php deleted file mode 100644 index 48975a4..0000000 --- a/test/TestAsset/UseCase1/BlogPost.php +++ /dev/null @@ -1,21 +0,0 @@ -role; - } -} diff --git a/test/TestAsset/UseCase1/UserIsBlogPostOwnerAssertion.php b/test/TestAsset/UseCase1/UserIsBlogPostOwnerAssertion.php deleted file mode 100644 index e31d5a6..0000000 --- a/test/TestAsset/UseCase1/UserIsBlogPostOwnerAssertion.php +++ /dev/null @@ -1,34 +0,0 @@ -lastAssertRole = $user; - $this->lastAssertResource = $blogPost; - $this->lastAssertPrivilege = $privilege; - return $this->assertReturnValue; - } -}