Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 68 additions & 27 deletions Docs/Documentation/SimpleRbacAuthorize.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,49 +52,90 @@ The ```default_role``` will be used to set the role of the registered users by d
Permission rules syntax
-----------------

* Rules are evaluated top-down, first matching rule will apply
* Each rule is defined:
```php
[
'role' => 'REQUIRED_NAME_OF_THE_ROLE_OR_[]_OR_*',
'prefix' => 'OPTIONAL_PREFIX_USED_OR_[]_OR_*_DEFAULT_NULL',
'extension' => 'OPTIONAL_PREFIX_USED_OR_[]_OR_*_DEFAULT_NULL',
'plugin' => 'OPTIONAL_NAME_OF_THE_PLUGIN_OR_[]_OR_*_DEFAULT_NULL',
'controller' => 'REQUIRED_NAME_OF_THE_CONTROLLER_OR_[]_OR_*'
'action' => 'REQUIRED_NAME_OF_ACTION_OR_[]_OR_*',
'allowed' => 'OPTIONAL_BOOLEAN_OR_CALLABLE_OR_INSTANCE_OF_RULE_DEFAULT_TRUE'
]
```
* If no rule allowed = true is matched for a given user role and url, default return value will be false
* Note for Superadmin access (permission to access ALL THE THINGS in your app) there is a specific Authorize Object provided
* Permissions are evaluated top-down, first matching permission will apply
* Each permission is an associative array of rules with following structure: `'value_to_check' => 'expected_value'`
* `value_to_check` can be any key from user array or one of special keys:
* Routing params:
* `prefix`
* `plugin`
* `extension`
* `controller`
* `action`
* `role` - Alias/shortcut to field defined in `role_field` config value
* `allowed` - see below
* If you have a user field that overlaps with special keys (eg. `$user->allowed`) you can prepend `user.` to key to force matching from user array (eg. `user.allowed`)
* The keys can be placed in any order with exception of `allowed` which must be last one (see below)
* `value_to_check` can be prepended with `*` to match everything except `expected_value`
* `expected_value` can be one of following things:
* `*` will match absolutely everything
* A _string_/_integer_/_boolean_/etc - will match only the specified value
* An _array_ of strings/integers/booleans/etc (can be mixed). The rule will match if real value is equal to any of expected ones
* A callable/object (see below)
* If any of rules fail, the permission is discarded and the next one is evaluated
* A very special key `allowed` exists which has completely different behaviour:
* If `expected_value` is a callable/object then it's executed and the result is casted to boolean
* If `expected_value` is **not** a callable/object then it's simply casted to boolean
* The `*` is checked and if found the result is inverted
* The final boolean value is **the result of permission** checker. This means if it is `false` then no other permissions are checked and the user is denied access.
For this reason the `allowed` key must be placed at the end of permission since no other rules are executed after it

Permission Callbacks
-----------------
You could use a callback in your 'allowed' to process complex authentication, like
**Notes**:

* For Superadmin access (permission to access ALL THE THINGS in your app) there is a specific Authorize Object provided
* Permissions that do not have `controller` and/or `action` keys (or the inverted versions) are automatically discarded in order to prevent errors.
If you need to match all controllers/actions you can explicitly do `'contoller' => '*'`
* Key `user` (or the inverted version) is illegal (as it's impossible to match an array) and any permission containing it will be discarded
* If the permission is discarded for the reasons stated above, a debug message will be logged

**Permission Callbacks**: you could use a callback in your 'allowed' to process complex authentication, like
- ownership
- permissions stored in your database
- permission based on an external service API call

Example *ownership* callback, to allow users to edit their own Posts:

```php
'allowed' => function (array $user, $role, Request $request) {
$postId = Hash::get($request->params, 'pass.0');
$post = TableRegistry::get('Posts')->get($postId);
$userId = Hash::get($user, 'id');
'allowed' => function (array $user, $role, \Cake\Network\Request $request) {
$postId = \Cake\Utility\Hash::get($request->params, 'pass.0');
$post = \Cake\ORM\TableRegistry::get('Posts')->get($postId);
$userId = $user['id'];
if (!empty($post->user_id) && !empty($userId)) {
return $post->user_id === $userId;
}
return false;
}
```

Permission Rules
----------------
You could use an instance of the \CakeDC\Users\Auth\Rules\Rule interface to reuse your custom rules
Examples:
**Permission Rules**: If you see that you are duplicating logic in your callables, you can create rule class to re-use the logic.
For example, the above ownership callback is included in CakeDC\Users as `Owner` rule
```php
'allowed' => new Owner() //will pick by default the post id from the first pass param
'allowed' => new \CakeDC\Users\Auth\Rules\Owner() //will pick by default the post id from the first pass param
```
Check the [Owner Rule](OwnerRule.md) documentation for more details

Creating rule classes
---------------------

The only requirement is to implement `\CakeDC\Users\Auth\Rules\Rule` interface which has one method:

```php
class YourRule implements \CakeDC\Users\Auth\Rules\Rule
{
/**
* Check the current entity is owned by the logged in user
*
* @param array $user Auth array with the logged in data
* @param string $role role of the user
* @param Request $request current request, used to get a default table if not provided
* @return bool
*/
public function allowed(array $user, $role, Request $request)
{
// Your logic here
}
}
```

This logic can be anything: database, external auth, etc.

Also, if you are using DB, you can choose to extend `\CakeDC\Users\Auth\Rules\AbstractRule` since it provides convenience methods for reading from DB
15 changes: 9 additions & 6 deletions src/Auth/Rules/AbstractRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@
*/
namespace CakeDC\Users\Auth\Rules;

use Cake\Core\InstanceConfigTrait;
use Cake\Datasource\ModelAwareTrait;
use Cake\Network\Request;
use Cake\ORM\Locator\LocatorAwareTrait;
use Cake\ORM\Table;
use Cake\ORM\TableRegistry;
use Cake\Utility\Hash;
Expand All @@ -22,9 +25,9 @@
*/
abstract class AbstractRule implements Rule
{
use \Cake\Core\InstanceConfigTrait;
use \Cake\Datasource\ModelAwareTrait;
use \Cake\ORM\Locator\LocatorAwareTrait;
use InstanceConfigTrait;
use LocatorAwareTrait;
use ModelAwareTrait;

/**
* @var array default config
Expand All @@ -46,7 +49,7 @@ public function __construct($config = [])
* @param Request $request request
* @param mixed $table table
* @return \Cake\ORM\Table
* @throw OutOfBoundsException if table alias is empty
* @throws \OutOfBoundsException if table alias is empty
*/
protected function _getTable(Request $request, $table = null)
{
Expand All @@ -65,7 +68,7 @@ protected function _getTable(Request $request, $table = null)
*
* @param Request $request request
* @return Table
* @throws OutOfBoundsException if table alias can't be extracted from request
* @throws \OutOfBoundsException if table alias can't be extracted from request
*/
protected function _getTableFromRequest(Request $request)
{
Expand All @@ -88,7 +91,7 @@ protected function _getTableFromRequest(Request $request)
* @param string $role role of the user
* @param Request $request current request, used to get a default table if not provided
* @return bool
* @throws OutOfBoundsException if table is not found or it doesn't have the expected fields
* @throws \OutOfBoundsException if table is not found or it doesn't have the expected fields
*/
abstract public function allowed(array $user, $role, Request $request);
}
1 change: 0 additions & 1 deletion src/Auth/Rules/Rule.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ interface Rule
* @param string $role role of the user
* @param Request $request current request, used to get a default table if not provided
* @return bool
* @throws \OutOfBoundsException if table is not found or it doesn't have the expected fields
*/
public function allowed(array $user, $role, Request $request);
}
141 changes: 97 additions & 44 deletions src/Auth/SimpleRbacAuthorize.php
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ public function __construct(ComponentRegistry $registry, array $config = [])
parent::__construct($registry, $config);
$autoload = $this->config('autoload_config');
if ($autoload) {
$loadedPermissions = $this->_loadPermissions($autoload, 'default');
$loadedPermissions = $this->_loadPermissions($autoload);
$this->config('permissions', $loadedPermissions);
}
}
Expand Down Expand Up @@ -166,7 +166,7 @@ public function authorize($user, Request $request)
$role = Hash::get($user, $roleField);
}

$allowed = $this->_checkRules($user, $role, $request);
$allowed = $this->_checkPermissions($user, $role, $request);

return $allowed;
}
Expand All @@ -180,11 +180,11 @@ public function authorize($user, Request $request)
* @param Request $request request
* @return bool true if there is a match in permissions
*/
protected function _checkRules(array $user, $role, Request $request)
protected function _checkPermissions(array $user, $role, Request $request)
{
$permissions = $this->config('permissions');
foreach ($permissions as $permission) {
$allowed = $this->_matchRule($permission, $user, $role, $request);
$allowed = $this->_matchPermission($permission, $user, $role, $request);
if ($allowed !== null) {
return $allowed;
}
Expand All @@ -196,42 +196,77 @@ protected function _checkRules(array $user, $role, Request $request)
/**
* Match the rule for current permission
*
* @param array $permission configuration
* @param array $user current user
* @param string $role effective user role
* @param Request $request request
* @return bool if rule matched, null if rule not matched
* @param array $permission The permission configuration
* @param array $user Current user data
* @param string $role Effective user's role
* @param \Cake\Network\Request $request Current request
*
* @return null|bool Null if permission is discarded, boolean if a final result is produced
*/
protected function _matchRule($permission, $user, $role, $request)
protected function _matchPermission(array $permission, array $user, $role, Request $request)
{
$plugin = $request->plugin;
$controller = $request->controller;
$action = $request->action;
$prefix = null;
$extension = null;
if (!empty($request->params['prefix'])) {
$prefix = $request->params['prefix'];
$issetController = isset($permission['controller']) || isset($permission['*controller']);
$issetAction = isset($permission['action']) || isset($permission['*action']);
$issetUser = isset($permission['user']) || isset($permission['*user']);

if (!$issetController || !$issetAction) {
$this->log(
__d('CakeDC/Users', "Cannot evaluate permission when 'controller' and/or 'action' keys are absent"),
LogLevel::DEBUG
);

return false;
}
if (!empty($request->params['_ext'])) {
$extension = $request->params['_ext'];
if ($issetUser) {
$this->log(
__d('CakeDC/Users', "Permission key 'user' is illegal, cannot evaluate the permission"),
LogLevel::DEBUG
);

return false;
}
if ($this->_matchOrAsterisk($permission, 'role', $role) &&
$this->_matchOrAsterisk($permission, 'prefix', $prefix, true) &&
$this->_matchOrAsterisk($permission, 'plugin', $plugin, true) &&
$this->_matchOrAsterisk($permission, 'extension', $extension, true) &&
$this->_matchOrAsterisk($permission, 'controller', $controller) &&
$this->_matchOrAsterisk($permission, 'action', $action)) {
$allowed = Hash::get($permission, 'allowed');

if ($allowed === null) {
//allowed will be true by default
return true;
} elseif (is_callable($allowed)) {
return (bool)call_user_func($allowed, $user, $role, $request);
} elseif ($allowed instanceof Rule) {
return (bool)$allowed->allowed($user, $role, $request);
$permission += ['allowed' => true];
$userArr = ['user' => $user];
$reserved = [
'prefix' => Hash::get($request->params, 'prefix'),
'plugin' => $request->plugin,
'extension' => Hash::get($request->params, '_ext'),
'controller' => $request->controller,
'action' => $request->action,
'role' => $role
];

foreach ($permission as $key => $value) {
$inverse = $this->_startsWith($key, '*');
if ($inverse) {
$key = ltrim($key, '*');
}

if (is_callable($value)) {
$return = (bool)call_user_func($value, $user, $role, $request);
} elseif ($value instanceof Rule) {
$return = (bool)$value->allowed($user, $role, $request);
} elseif ($key === 'allowed') {
$return = (bool)$value;
} elseif (array_key_exists($key, $reserved)) {
$return = $this->_matchOrAsterisk($value, $reserved[$key], true);
} else {
return (bool)$allowed;
if (!$this->_startsWith($key, 'user.')) {
$key = 'user.' . $key;
}

$return = $this->_matchOrAsterisk($value, Hash::get($userArr, $key));
}

if ($inverse) {
$return = !$return;
}
if ($key === 'allowed') {
return $return;
}
if (!$return) {
break;
}
}

Expand All @@ -241,24 +276,42 @@ protected function _matchRule($permission, $user, $role, $request)
/**
* Check if rule matched or '*' present in rule matching anything
*
* @param string $permission permission configuration
* @param string $key key to retrieve and check in permissions configuration
* @param string $value value to check with (coming from the request) We'll check the DASHERIZED value too
* @param bool $allowEmpty true if we allow
* @param string|array $possibleValues Values that are accepted (from permission config)
* @param string|mixed|null $value Value to check with. We'll check the DASHERIZED value too
* @param bool $allowEmpty If true and $value is null, the rule will pass
*
* @return bool
*/
protected function _matchOrAsterisk($permission, $key, $value, $allowEmpty = false)
protected function _matchOrAsterisk($possibleValues, $value, $allowEmpty = false)
{
$possibleValues = (array)Hash::get($permission, $key);
if ($allowEmpty && empty($possibleValues) && $value === null) {
$possibleArray = (array)$possibleValues;

if ($allowEmpty && empty($possibleArray) && $value === null) {
return true;
}
if (Hash::get($permission, $key) === '*' ||
in_array($value, $possibleValues) ||
in_array(Inflector::camelize($value, '-'), $possibleValues)) {

if ($possibleValues === '*' ||
in_array($value, $possibleArray) ||
in_array(Inflector::camelize($value, '-'), $possibleArray)
) {
return true;
}

return false;
}

/**
* Checks if $heystack begins with $needle
*
* @see http://stackoverflow.com/a/7168986/2588539
*
* @param string $haystack The whole string
* @param string $needle The beginning to check
*
* @return bool
*/
protected function _startsWith($haystack, $needle)
{
return substr($haystack, 0, strlen($needle)) === $needle;
}
}
Loading