diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a5ddcc7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +vendor/ +build/ +composer.lock +test.db +.vscode/ +coverage/ +clover.xml +.phpunit.result.cache +.runway-config.json diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4b71739 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 FlightPHP, n0nag0n + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..67be549 --- /dev/null +++ b/README.md @@ -0,0 +1,112 @@ +# Flight Permissions Plugin +[![Latest Stable Version](http://poser.pugx.org/flightphp/permissions/v)](https://packagist.org/packages/flightphp/permissions) +[![License](https://poser.pugx.org/flightphp/permissions/license)](https://packagist.org/packages/flightphp/permissions) +[![PHP Version Require](http://poser.pugx.org/flightphp/permissions/require/php)](https://packagist.org/packages/flightphp/permissions) +[![Dependencies](http://poser.pugx.org/flightphp/permissions/dependents)](https://packagist.org/packages/flightphp/permissions) + +Permissions are an important part to any application. Even in a RESTful API you'll need to check that the API key has permission to perform the action requested. In some cases it makes sense to handle authentication in a middleware, but in other cases, it's more helpful to have a standard set of permissions. + +This library follows a CRUD based permissions systems. See [basic example](#basic-example) for example on how this is accomplished. + +## Basic Example + +Let's assume you have a feature in your application that checks if a user is logged in. You can create a permissions object like this: + +```php +// index.php +require 'vendor/autoload.php'; + +// some code + +// then you probably have something that tells you who the current role is of the person +// likely you have something where you pull the current role +// from a session variable which defines this +// after someone logs in, otherwise they will have a 'guest' or 'public' role. +$current_role = 'admin'; + +// setup permissions +$permission = new \flight\Permission($current_role); +$permission->defineRule('loggedIn', function($current_role) { + return $current_role !== 'guest'; +}); + +// You'll probably want to persist this object in Flight somewhere +Flight::set('permission', $permission); +``` + +Then in a controller somewhere, you might have something like this. + +```php +has('loggedIn')) { + // do something + } else { + // do something else + } + } +} +``` + +You can also use this to track if they have permission to do something in your application. +For instance, if your have a way that users can interact with posting on your software, you can +check if they have permission to perform certain actions. + +```php +$current_role = 'admin'; + +// setup permissions +$permission = new \flight\Permission($current_role); +$permission->defineRule('post', function($current_role) { + if($current_role === 'admin') { + $permissions = ['create', 'read', 'update', 'delete']; + } else if($current_role === 'editor') { + $permissions = ['create', 'read', 'update']; + } else if($current_role === 'author') { + $permissions = ['create', 'read']; + } else if($current_role === 'contributor') { + $permissions = ['create']; + } else { + $permissions = []; + } + return $permissions; +}); +Flight::set('permission', $permission); +``` + +Then in a controller somewhere... + +```php +class PostController { + public function create() { + $permission = Flight::get('permission'); + if ($permission->can('post.create')) { + // do something + } else { + // do something else + } + } +} +``` + +See how much fun this is? Let's install it and get started! + +## Installation + +Simply install with Composer + +```php +composer require flightphp/permissions +``` + +## Documentation + +Head over to the [documentation page](https://docs.flightphp.com/awesome-plugins/permissions) to learn more about usage and how cool this thing is! :) + +## License + +MIT diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..529555f --- /dev/null +++ b/composer.json @@ -0,0 +1,37 @@ +{ + "name": "flightphp/permissions", + "type": "library", + "description": "Library for managing permissions in Flight Applications", + "keywords": ["permissions", "authentication","lite","simple"], + "homepage": "https://docs.flightphp.com", + "license": "MIT", + "authors": [ + { + "name": "n0nag0n", + "email": "n0nag0n@sky-9.com", + "role": "Owner" + } + ], + "require": { + "php": ">=7.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.0", + "squizlabs/php_codesniffer": "^3.8", + "rregeer/phpunit-coverage-check": "^0.3.1", + "flightphp/core": "^3.10", + "wruczek/php-file-cache": "^0.0.5" + }, + "autoload": { + "psr-4": {"flight\\": "src/"} + }, + "autoload-dev": { + "psr-4": {"flight\\tests\\": "tests/"} + }, + "scripts": { + "test": "phpunit", + "test-coverage": "XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-html=coverage --coverage-clover=clover.xml && vendor/bin/coverage-check clover.xml 100", + "beautify": "phpcbf --standard=phpcs.xml", + "phpcs": "phpcs --standard=phpcs.xml" + } +} diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 0000000..592cabc --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,10 @@ + + + Created with the PHP Coding Standard Generator. http://edorian.github.io/php-coding-standard-generator/ + + + + + src/ + tests/ + \ No newline at end of file diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..98e037f --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,13 @@ + + + + + src/ + + + + + tests/ + + + diff --git a/src/Permission.php b/src/Permission.php new file mode 100644 index 0000000..53d1ed2 --- /dev/null +++ b/src/Permission.php @@ -0,0 +1,213 @@ + */ + protected $localClassCache = []; + + /** + * Constructor + * + * @param string $currentRole + * @param Engine $f3 + */ + public function __construct(string $currentRole = '', Engine $app = null, $Cache = null) + { + $this->currentRole = $currentRole; + $this->app = $app === null ? Flight::app() : $app; + $this->Cache = $Cache; + } + + /** + * Sets the current user role + * + * @param string $currentRole the current role of the logged in user + * @return void + */ + public function setCurrentRole(string $currentRole) + { + $this->currentRole = $currentRole; + } + + /** + * Gets the current user role + * + * @return string + */ + public function getCurrentRole(): string + { + return $this->currentRole; + } + + /** + * Gets the defined rules + * + * @return array + */ + public function getRules(): array + { + return $this->rules; + } + + /** + * Defines a new rule for a permission + * + * @param string $rule the generic name of the rule + * @param callable|string $callable either a callable, or the name of the Class->method to call that + * will return the allowed permissions. + * This will return an array of strings, each string being a permission + * e.g. return true; would allow access to this permission + * return [ 'create', 'read', 'update', 'delete' ] would allow all permissions + * return [ 'read' ] would only allow the read permission. + * You define what the callable or method returns for allowed permissions + * The callable or method will be passed the following parameters: + * - $f3: the \Base instance + * - $currentRole: the current role of the logged in user + * @param bool $overwrite if true, will overwrite any existing rule with the same name + * @return void + */ + public function defineRule(string $rule, $callableOrClassString, bool $overwrite = false) + { + if ($overwrite === false && isset($this->rules[$rule]) === true) { + throw new \Exception('Rule already defined: ' . $rule); + } + $this->rules[$rule] = $callableOrClassString; + } + + /** + * Defines rules based on the public methods of a class + * + * @param string $className the name of the class to define rules from + * @return void + */ + public function defineRulesFromClassMethods(string $className, int $ttl = 0): void + { + + $useCache = false; + if ($this->Cache !== null && $ttl > 0) { + $useCache = true; + $Cache = $this->Cache; + $cacheKey = 'flight_permissions_class_methods_' . $className; + if (is_a($Cache, PhpFileCache::class) === true) { + /** @var PhpFileCache $Cache */ + $isCached = $Cache->isCached($cacheKey); + if ($isCached === true) { + $this->rules = $Cache->retrieve($cacheKey); + return; + } + } + } + + $reflection = new \ReflectionClass($className); + $methods = $reflection->getMethods(\ReflectionMethod::IS_PUBLIC); + $classRules = []; + foreach ($methods as $method) { + $methodName = $method->getName(); + if (strpos($methodName, '__') === 0) { + continue; + } + $classRules[$methodName] = $className . '->' . $methodName; + } + + if ($useCache === true) { + if (is_a($Cache, PhpFileCache::class) === true) { + /** @var PhpFileCache $Cache */ + $Cache->store($cacheKey, $classRules, $ttl); + } + } + + $this->rules = array_merge($this->rules, $classRules); + } + + /** + * Checks if the current user has permission to perform the action + * + * @param string $permission the permission to check. This can be the rule you defined, or a permission.action + * e.g. 'video.create' or 'video' depending on how you setup the callback. + * @param mixed $additionalArgs any additional arguments to pass to the callback or method. + * @return bool + */ + public function can(string $permission, ...$additionalArgs): bool + { + $allowed = false; + $action = ''; + if (strpos($permission, '.') !== false) { + [ $permission, $action ] = explode('.', $permission); + } + + $permissionsRaw = $this->rules[$permission] ?? null; + if ($permissionsRaw === null) { + throw new Exception('Permission not defined: ' . $permission); + } + + $executedPermissions = null; + + if (is_callable($permissionsRaw) === true) { + $executedPermissions = $permissionsRaw($this->currentRole, ...$additionalArgs); + } else { + if (is_string($permissionsRaw) === true) { + $permissionsRaw = explode('->', $permissionsRaw); + } + [ $className, $methodName ] = $permissionsRaw; + if (isset($this->localClassCache[$className]) === false) { + $class = new $className($this->app); + $this->localClassCache[$className] = $class; + } else { + $class = $this->localClassCache[$className]; + } + $executedPermissions = $class->$methodName($this->currentRole, ...$additionalArgs); + } + + if (is_array($executedPermissions) === true) { + $allowed = in_array($action, $executedPermissions, true) === true; + } elseif (is_bool($executedPermissions) === true) { + $allowed = $executedPermissions; + } + + return $allowed; + } + + /** + * Alias for can. Sometimes it's nice to say has instead of can + * + * @param string $permission Permission to check + * @param mixed $additionalArgs any additional arguments to pass to the callback or method. + * @return boolean + */ + public function has(string $permission, ...$additionalArgs): bool + { + return $this->can($permission, ...$additionalArgs); + } + + /** + * Checks if the current user has the given role + * + * @param string $role [description] + * @return boolean + */ + public function is(string $role): bool + { + return $this->currentRole === $role; + } +} diff --git a/tests/FakePermissionsClass.php b/tests/FakePermissionsClass.php new file mode 100644 index 0000000..9a7a099 --- /dev/null +++ b/tests/FakePermissionsClass.php @@ -0,0 +1,45 @@ +app = $app; + } + + public function createOrder(): bool + { + return true; + } + + public function order($currentRole, $id, $quantity): array + { + $permissions = []; + if ($this->createOrder()) { + $permissions[] = 'create'; + } + + if ($quantity > 10) { + $permissions[] = 'update'; + } + + if ($id > 0) { + $permissions[] = 'delete'; + } + + if ($currentRole === 'admin') { + $permissions[] = 'admin'; + } + + return $permissions; + } +} diff --git a/tests/PermissionTest.php b/tests/PermissionTest.php new file mode 100644 index 0000000..b14579c --- /dev/null +++ b/tests/PermissionTest.php @@ -0,0 +1,114 @@ +setCurrentRole('user'); + $this->assertSame('user', $permission->getCurrentRole()); + } + + public function testDefineRule() + { + $permission = new Permission('admin'); + $permission->defineRule('createOrder', 'Some_Permissions_Class->createOrder'); + $this->assertSame([ 'createOrder' => 'Some_Permissions_Class->createOrder' ], $permission->getRules()); + } + + public function testDefineDuplicateRule() + { + $permission = new Permission('admin'); + $permission->defineRule('createOrder', 'Some_Permissions_Class->createOrder'); + $this->expectException(Exception::class); + $this->expectExceptionMessage('Rule already defined: createOrder'); + $permission->defineRule('createOrder', 'Some_Permissions_Class->createOrder'); + } + + public function testDefineRulesFromClassMethodsNoCacheSimpleBoolean() + { + $permission = new Permission('admin'); + $permission->defineRulesFromClassMethods(FakePermissionsClass::class); + $this->assertTrue($permission->can('createOrder')); + } + + public function testDefineRulesFromClassMethodsNoCacheArray() + { + $permission = new Permission('public'); + $permission->defineRulesFromClassMethods(FakePermissionsClass::class); + $this->assertTrue($permission->can('order.create', 1, 1)); + $this->assertTrue($permission->can('order.delete', 1, 1)); + $this->assertFalse($permission->can('order.update', 1, 1)); + $this->assertTrue($permission->can('order.update', 1, 11)); + $this->assertFalse($permission->can('order.delete', 0, 11)); + $this->assertFalse($permission->can('order.admin', 0, 1)); + $permission->setCurrentRole('admin'); + $this->assertTrue($permission->has('order.admin', 1, 11)); + } + + public function testDefineRulesFromClassMethodsWithCache() + { + $PhpFileCache = new PhpFileCache(__DIR__, 'my_test'); + $permission = new Permission('public', null, $PhpFileCache); + $permission->defineRulesFromClassMethods(FakePermissionsClass::class, 60); + $this->assertTrue($permission->can('order.create', 1, 1)); + $this->assertTrue($permission->can('order.delete', 1, 1)); + $this->assertFalse($permission->can('order.update', 1, 1)); + $this->assertTrue($permission->can('order.update', 1, 11)); + $this->assertFalse($permission->can('order.delete', 0, 11)); + $this->assertFalse($permission->can('order.admin', 0, 1)); + $permission->setCurrentRole('admin'); + $this->assertTrue($permission->has('order.admin', 1, 11)); + } + + public function testDefineRulesFromClassMethodsWithCacheTouchCache() + { + $PhpFileCache = new PhpFileCache(__DIR__, 'my_test'); + $permission = new Permission('public', null, $PhpFileCache); + $permission->defineRulesFromClassMethods(FakePermissionsClass::class, 60); + // Make sure it works + $this->assertTrue($permission->can('order.create', 1, 1)); + + // Then load the classes again like from another request and + // make sure it pulls from the cache + $permission->defineRulesFromClassMethods(FakePermissionsClass::class, 60); + $this->assertFalse($permission->can('order.delete', 0, 11)); + } + + public function testCanNoPermission() + { + $permission = new Permission('admin'); + $this->expectExceptionMessage('Permission not defined: smell'); + $permission->can('smell'); + } + + public function testCanWithCallable() + { + $permission = new Permission('admin'); + $permission->defineRule('canSmell', function ($currentRole) { + return $currentRole === 'admin'; + }); + $this->assertTrue($permission->can('canSmell')); + } + + public function testIs() + { + $permission = new Permission('admin'); + $this->assertTrue($permission->is('admin')); + $this->assertFalse($permission->is('user')); + } +}