From acce62ac453f1f3550dc60c441b6f6a3df11fec4 Mon Sep 17 00:00:00 2001 From: Dobando <1692898084@qq.com> Date: Tue, 20 Aug 2024 00:15:44 +0800 Subject: [PATCH 1/2] feat: add Yii authorization integration with AuthManager and Behaviors methods --- src/components/PermissionChecker.php | 24 ++++ src/components/PermissionControl.php | 100 +++++++++++++ src/components/PermissionPolicy.php | 74 ++++++++++ tests/AdapterTest.php | 81 ----------- tests/LocalizationTest.php | 202 +++++++++++++++++++++++++++ tests/TestCase.php | 87 ++++++++++++ tests/support/TestController.php | 35 +++++ tests/support/UserIdentity.php | 58 ++++++++ 8 files changed, 580 insertions(+), 81 deletions(-) create mode 100644 src/components/PermissionChecker.php create mode 100644 src/components/PermissionControl.php create mode 100644 src/components/PermissionPolicy.php create mode 100644 tests/LocalizationTest.php create mode 100644 tests/TestCase.php create mode 100644 tests/support/TestController.php create mode 100644 tests/support/UserIdentity.php diff --git a/src/components/PermissionChecker.php b/src/components/PermissionChecker.php new file mode 100644 index 0000000..af0b44a --- /dev/null +++ b/src/components/PermissionChecker.php @@ -0,0 +1,24 @@ +permission->enforce($userId, ...$params); + } +} diff --git a/src/components/PermissionControl.php b/src/components/PermissionControl.php new file mode 100644 index 0000000..94297da --- /dev/null +++ b/src/components/PermissionControl.php @@ -0,0 +1,100 @@ + 'yii\permission\components\PermissionPolicy']; + + /** + * @var array the policies. + */ + public $policy = []; + + /** + * Initializes the PermissionControl component. + * + * @return void + */ + public function init() + { + parent::init(); + if ($this->user !== false) { + $this->user = Instance::ensure($this->user, User::class); + } + foreach ($this->policy as $i => $policy) { + if (is_array($policy)) { + $this->policy[$i] = Yii::createObject(array_merge($this->policyConfig, $policy)); + } + } + } + + /** + * Checks if the current user has permission to perform the given action. + * + * @param Action $action the action to be performed + * @throws ForbiddenHttpException if the user does not have permission + * @return bool true if the user has permission, false otherwise + */ + public function beforeAction($action) + { + $user = $this->user; + foreach ($this->policy as $policy) { + if ($allow = $policy->allows($action, $user)) { + return true; + } elseif ($allow === false) { + if (isset($policy->denyCallback)) { + call_user_func($policy->denyCallback, $policy, $action); + } elseif ($this->denyCallback !== null) { + call_user_func($this->denyCallback, $policy, $action); + } else { + $this->denyAccess($user); + } + + return false; + } + } + + if ($this->denyCallback !== null) { + call_user_func($this->denyCallback, null, $action); + } else { + $this->denyAccess($user); + } + return false; + } + /** + * Denies the access of the user. + * The default implementation will redirect the user to the login page if he is a guest; + * if the user is already logged, a 403 HTTP exception will be thrown. + * + * @param User|false $user the current user or boolean `false` in case of detached User component + * @throws ForbiddenHttpException if the user is already logged in or in case of detached User component. + */ + protected function denyAccess($user) + { + if ($user !== false && $user->getIsGuest()) { + $user->loginRequired(); + } else { + throw new ForbiddenHttpException(Yii::t('yii', 'You are not allowed to perform this action.')); + } + } +} diff --git a/src/components/PermissionPolicy.php b/src/components/PermissionPolicy.php new file mode 100644 index 0000000..858b822 --- /dev/null +++ b/src/components/PermissionPolicy.php @@ -0,0 +1,74 @@ +matchAction($action) + && $this->matchEnforce($user, $this->enforce) + ) { + return $this->allow ? true : false; + } + + return null; + } + + /** + * Checks if the rule applies to the specified action. + * + * @param Action $action the action + * @return bool whether the rule applies to the action + */ + protected function matchAction($action) + { + return empty($this->actions) || in_array($action->id, $this->actions, true); + } + + /** + * Checks if the rule applies to the specified user. + * + * @param User $user + * @param array $params + * + * @return bool + */ + protected function matchEnforce($user, $params) + { + return Yii::$app->permission->enforce($user->getId(), ...$params); + } +} diff --git a/tests/AdapterTest.php b/tests/AdapterTest.php index cdc95c1..d12a50c 100755 --- a/tests/AdapterTest.php +++ b/tests/AdapterTest.php @@ -2,18 +2,13 @@ namespace yii\permission\tests; -use PHPUnit\Framework\TestCase; -use yii\web\Application; use Yii; -use yii\permission\models\CasbinRule; use Casbin\Persist\Adapters\Filter; use Casbin\Exceptions\InvalidFilterTypeException; use yii\db\ActiveQueryInterface; class AdapterTest extends TestCase { - protected $app; - public function testEnforce() { $this->assertTrue(Yii::$app->permission->enforce('alice', 'data1', 'read')); @@ -331,80 +326,4 @@ public function testLoadFilteredPolicy() ['alice', 'data1', 'read'], ], Yii::$app->permission->getPolicy()); } - - public function createApplication() - { - $config = require __DIR__ . '/../vendor/yiisoft/yii2-app-basic/config/web.php'; - $config['components']['permission'] = require __DIR__ . '/../config/permission.php'; - - $config['components']['db']['dsn'] = 'mysql:host=' . $this->env('DB_HOST', '127.0.0.1') . ';port=' . $this->env('DB_PORT', '3306') . ';dbname=' . $this->env('DB_DATABASE', 'casbin'); - $config['components']['db']['username'] = $this->env('DB_USERNAME', 'root'); - $config['components']['db']['password'] = $this->env('DB_PASSWORD', ''); - - return new Application($config); - } - - /** - * init table. - */ - protected function initTable() - { - $db = CasbinRule::getDb(); - $tableName = CasbinRule::tableName(); - $table = $db->getTableSchema($tableName); - if ($table) { - $db->createCommand()->dropTable($tableName)->execute(); - } - - Yii::$app->permission->init(); - - Yii::$app->db->createCommand()->batchInsert( - $tableName, - ['ptype', 'v0', 'v1', 'v2'], - [ - ['p', 'alice', 'data1', 'read'], - ['p', 'bob', 'data2', 'write'], - ['p', 'data2_admin', 'data2', 'read'], - ['p', 'data2_admin', 'data2', 'write'], - ['g', 'alice', 'data2_admin', null], - ] - )->execute(); - } - - /** - * Refresh the application instance. - */ - protected function refreshApplication() - { - $this->app = $this->createApplication(); - } - - /** - * This method is called before each test. - */ - protected function setUp(): void/* The :void return type declaration that should be here would cause a BC issue */ - { - if (!$this->app) { - $this->refreshApplication(); - } - - $this->initTable(); - } - - /** - * This method is called after each test. - */ - protected function tearDown(): void/* The :void return type declaration that should be here would cause a BC issue */ - { - } - - protected function env($key, $default = null) - { - $value = getenv($key); - if (is_null($default)) { - return $value; - } - - return false === $value ? $default : $value; - } } diff --git a/tests/LocalizationTest.php b/tests/LocalizationTest.php new file mode 100644 index 0000000..7817cc8 --- /dev/null +++ b/tests/LocalizationTest.php @@ -0,0 +1,202 @@ +initBaseBehaviors(); + } + + public static function permissionCheckerProvider() + { + return [ + ['alice', 'data1,read', true], + ['bob', 'data1,read', false], + ['bob', 'data2,write', true], + ['alice', 'data2,read', true], + ['alice', 'data2,write', true] + ]; + } + + /** + * @dataProvider permissionCheckerProvider + */ + public function testPermissionChecker($sub, $params, $expected) + { + $user = $this->mockUser($sub); + $auth = $this->mockAuthManager(); + + $user->accessChecker = $auth; + $this->assertEquals($expected, $user->can($params)); + } + + public function testPermissionControlBasic() + { + $controller = new TestController('test', $this->app, [ + 'behaviors' => [ + 'permission' => $this->baseBehaviors + ], + ]); + $this->assertEquals('create success', $controller->runAction('create-post')); + $this->assertEquals('update success', $controller->runAction('update-post')); + + // try contained actions + try { + $controller->runAction('delete-post'); + $this->fail('Expected exception not thrown'); + } catch (ForbiddenHttpException $e) { + $this->assertInstanceOf(ForbiddenHttpException::class, $e); + } + + // try not contained actions + try { + $controller->runAction('comment'); + $this->fail('Expected exception not thrown'); + } catch (ForbiddenHttpException $e) { + $this->assertInstanceOf(ForbiddenHttpException::class, $e); + } + + // try + } + + public function testPermissionControlWithGlobalDenyCallback() + { + $this->baseBehaviors['denyCallback'] = function ($rule, $action) { + throw new ForbiddenHttpException('forbidden by global'); + }; + $controller = new TestController('test', $this->app, [ + 'behaviors' => [ + 'permission' => $this->baseBehaviors + ] + ]); + + // try contained actions + try { + $controller->runAction('delete-post'); + $this->fail('Expected exception not thrown'); + } catch (ForbiddenHttpException $e) { + $this->assertEquals('forbidden by global', $e->getMessage()); + } + } + + public function testPermissionControlWithDenyCallback() + { + // try local denyCallback when `allow` is false + $this->baseBehaviors['policy'][0]['allow'] = false; + $this->baseBehaviors['policy'][0]['denyCallback'] = function ($rule, $action) { + throw new ForbiddenHttpException('forbidden by local'); + }; + $controller = new TestController('test', $this->app, [ + 'behaviors' => [ + 'permission' => $this->baseBehaviors + ] + ]); + + try { + $controller->runAction('create-post'); + $this->fail('Expected exception not thrown'); + } catch (ForbiddenHttpException $e) { + $this->assertEquals('forbidden by local', $e->getMessage()); + } + + // try global denyCallback when `allow` is false + $this->baseBehaviors['policy'][0]['denyCallback'] = null; + $controller = new TestController('test', $this->app, [ + 'behaviors' => [ + 'permission' => $this->baseBehaviors + ] + ]); + + try { + $controller->runAction('create-post'); + $this->fail('Expected exception not thrown'); + } catch (ForbiddenHttpException $e) { + $this->assertEquals(Yii::t('yii', 'You are not allowed to perform this action.'), $e->getMessage()); + } + + // try custom global denyCallback when `allow` is false + $this->baseBehaviors['denyCallback'] = function ($rule, $action) { + throw new ForbiddenHttpException('forbidden by global'); + }; + $controller = new TestController('test', $this->app, [ + 'behaviors' => [ + 'permission' => $this->baseBehaviors + ] + ]); + + try { + $controller->runAction('create-post'); + $this->fail('Expected exception not thrown'); + } catch (ForbiddenHttpException $e) { + $this->assertEquals('forbidden by global', $e->getMessage()); + } + + // try denyCallback with not thrown exception when `allow` is false + $this->baseBehaviors['denyCallback'] = function ($rule, $action) {}; + $controller = new TestController('test', $this->app, [ + 'behaviors' => [ + 'permission' => $this->baseBehaviors + ] + ]); + $this->assertNull($controller->runAction('delete-post')); + + // try denyCallback with not thrown exception when `allow` is true + $this->baseBehaviors['policy'][0]['allow'] = true; + $controller = new TestController('test', $this->app, [ + 'behaviors' => [ + 'permission' => $this->baseBehaviors + ] + ]); + $this->assertNull($controller->runAction('delete-post')); + } + + protected function initBaseBehaviors() + { + $this->baseBehaviors = [ + 'class' => PermissionControl::class, + 'user' => $this->mockUser('alice'), + 'only' => ['create-post', 'update-post', 'delete-post', 'comment'], + 'policy' => [ + [ + 'allow' => true, + 'actions' => ['create-post', 'update-post'], + 'enforce' => ['data1', 'read'] + ], + [ + 'allow' => true, + 'actions' => ['delete-post'], + 'enforce' => ['data1', 'write'] + ] + ] + ]; + } + + protected function mockUser($userId) + { + $user = new User([ + 'identityClass' => UserIdentity::class, + 'enableAutoLogin' => false, + ]); + $user->setIdentity(UserIdentity::findIdentity($userId)); + return $user; + } + + protected function mockAuthManager() + { + $auth = new PermissionChecker(); + return $auth; + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..9a340aa --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,87 @@ +env('DB_HOST', '127.0.0.1') . ';port=' . $this->env('DB_PORT', '3306') . ';dbname=' . $this->env('DB_DATABASE', 'casbin'); + $config['components']['db']['username'] = $this->env('DB_USERNAME', 'root'); + $config['components']['db']['password'] = $this->env('DB_PASSWORD', ''); + + return new Application($config); + } + + /** + * init table. + */ + protected function initTable() + { + $db = CasbinRule::getDb(); + $tableName = CasbinRule::tableName(); + $table = $db->getTableSchema($tableName); + if ($table) { + $db->createCommand()->dropTable($tableName)->execute(); + } + + Yii::$app->permission->init(); + + Yii::$app->db->createCommand()->batchInsert( + $tableName, + ['ptype', 'v0', 'v1', 'v2'], + [ + ['p', 'alice', 'data1', 'read'], + ['p', 'bob', 'data2', 'write'], + ['p', 'data2_admin', 'data2', 'read'], + ['p', 'data2_admin', 'data2', 'write'], + ['g', 'alice', 'data2_admin', null], + ] + )->execute(); + } + + /** + * Refresh the application instance. + */ + protected function refreshApplication() + { + $this->app = $this->createApplication(); + } + + /** + * This method is called before each test. + */ + protected function setUp(): void/* The :void return type declaration that should be here would cause a BC issue */ + { + if (!$this->app) { + $this->refreshApplication(); + } + + $this->initTable(); + } + + /** + * This method is called after each test. + */ + protected function tearDown(): void/* The :void return type declaration that should be here would cause a BC issue */ {} + + protected function env($key, $default = null) + { + $value = getenv($key); + if (is_null($default)) { + return $value; + } + + return false === $value ? $default : $value; + } +} diff --git a/tests/support/TestController.php b/tests/support/TestController.php new file mode 100644 index 0000000..938aca9 --- /dev/null +++ b/tests/support/TestController.php @@ -0,0 +1,35 @@ +behaviors; + } + + public function actionCreatePost() + { + return 'create success'; + } + + public function actionUpdatePost() + { + return 'update success'; + } + + public function actionDeletePost() + { + return 'delete success'; + } + + public function actionComment() + { + return 'comment success'; + } +} diff --git a/tests/support/UserIdentity.php b/tests/support/UserIdentity.php new file mode 100644 index 0000000..01fce7d --- /dev/null +++ b/tests/support/UserIdentity.php @@ -0,0 +1,58 @@ + 'alice', + 'token2' => 'bob' + ]; + + private $_id; + + private $_token; + + public static function findIdentity($id) + { + if (in_array($id, static::$ids)) { + $identitiy = new static(); + $identitiy->_id = $id; + return $identitiy; + } + } + + public static function findIdentityByAccessToken($token, $type = null) + { + if (isset(static::$tokens[$token])) { + $id = static::$tokens[$token]; + $identitiy = new static(); + $identitiy->_id = $id; + $identitiy->_token = $token; + return $identitiy; + } + } + + public function getId() + { + return $this->_id; + } + + public function getAuthKey() + { + return null; + } + + public function validateAuthKey($authKey) + { + return true; + } +} From 40ccbdb0dc3526e1d84d0ae3ca6b88e624e0f27f Mon Sep 17 00:00:00 2001 From: Dobando <1692898084@qq.com> Date: Wed, 21 Aug 2024 11:23:45 +0800 Subject: [PATCH 2/2] docs: update README with new Yii Authorization features and usage examples --- README.md | 58 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/README.md b/README.md index c527fbc..2c133de 100755 --- a/README.md +++ b/README.md @@ -188,6 +188,64 @@ Determines whether a user has a permission. $permission->hasPermissionForUser('eve', 'articles', 'read'); // true or false ``` +### Using Yii Authorization + +It allows you to integrate Yii's authorization with the Casbin permission management system. + +**(1) AccessChecker** + +Add the accessChecker configuration in your application's `config/web.php` file: + +```php +$config = [ + 'components' => [ + 'user' => [ + ... + 'accessChecker' => 'yii\permission\components\PermissionChecker', + ] +]; +``` + +Once configured, you can use the `can()` method to check if a user has permission to perform certain actions: + +```php +$user->can('acrticles,read'); +``` + +**(2) Behaviors** + +The `PermissionControl` behavior allows you to enforce permission checks at the controller level. Add the PermissionControl behavior to your controller's behaviors() method: + +```php +public function behaviors() +{ + return [ + 'permission' => [ + 'class' => \yii\permission\components\PermissionControl::class, + 'user' => $user, // optional, defaults to \Yii::$app->user + 'only' => ['read-articles', 'write-articles'], + 'policy' => [ + [ + 'allow' => true, + 'actions' => ['read-articles'], + 'enforce' => ['articles', 'read'] + ], + [ + 'allow' => true, + 'actions' => ['write-articles'], + 'enforce' => ['articles', 'write'] + ] + ], + 'denyCallback' => function ($policy, $action) { + // custom action when access is denied + } // optional, defaults to throwing an exception + ] + ]; +} +``` + +**Note:** Additionally,You can also configure a `denyCallback` for each `policy`, which will be invoked when the user does not meet the required permission. This callback takes precedence. The configuration is similar to Yii's official [AccessControl](https://www.yiiframework.com/doc/guide/2.0/zh-cn/security-authorization#access-control-filter). + See [Casbin API](https://casbin.org/docs/en/management-api) for more APIs. ## Define your own model.conf