diff --git a/docs/book/v3/configuration.md b/docs/book/v3/configuration.md index 3d6c373..13f4ad8 100644 --- a/docs/book/v3/configuration.md +++ b/docs/book/v3/configuration.md @@ -2,7 +2,8 @@ As with many Dotkernel modules, we focus on the configuration based approach of customizing the module for your needs. -After installing, merge the module's `ConfigProvider` with your application's config to make sure required dependencies and default module configuration are registered. Create a configuration file for this module in your 'config/autoload' folder. +After installing, merge the module's `ConfigProvider` with your application's config to make sure required dependencies and default module configuration are registered. +Create a configuration file for this module in your 'config/autoload' folder. ## authorization-guards.global.php @@ -41,8 +42,8 @@ return [ 'logout' => ['admin', 'user', 'viewer'], 'account' => ['admin', 'user'], 'home' => ['*'], - ] - ] + ], + ], ], [ 'type' => 'RoutePermission', @@ -51,8 +52,8 @@ return [ 'premium' => ['premium'], 'account' => ['my-account'], 'logout' => ['only-logged'], - ] - ] + ], + ], ], [ 'type' => 'Controller', @@ -60,12 +61,12 @@ return [ 'rules' => [ [ 'route' => 'controller route name', - 'actions' => [//list of actions to apply, or empty array for all actions], - //by default, authorization pass if all permissions are present(AND) - 'roles' => [//list of roles to allow], + 'actions' => [], //list of actions to apply, or empty array for all actions, + // by default, authorization passes if all permissions are present(AND) + 'roles' => ['admin'], //list of roles to allow, ], - ] - ] + ], + ], ], [ 'type' => 'ControllerPermission', @@ -73,18 +74,18 @@ return [ 'rules' => [ [ 'route' => 'controller route name', - 'actions' => [//list of actions to apply, or empty array for all actions], - //by default, authorization pass if all permissions are present(AND) - 'permissions' => [//list of permissions to allow], + 'actions' => [], //list of actions to apply, or empty array for all actions, + // by default, authorization passes if all permissions are present(AND) + 'permissions' => ['authenticated'], //list of permissions to allow, ], [ 'route' => 'controller route name', - 'actions' => [//list of actions to apply, or empty array for all actions], + 'actions' => [], //list of actions to apply, or empty array for all actions, 'permissions' => [ //permission can be defined in this way too, for all permission type guards - 'permissions' => [//list of permissions], + 'permissions' => ['authenticated'], //list of permissions, 'condition' => \Dot\Rbac\Guard\GuardInterface::CONDITION_OR, - ] + ], ] ] ] @@ -103,3 +104,71 @@ return [ ], ]; ``` + +> It is **strongly recommended** to explicitly define permissions or roles and not leave the values empty, especially if using `GuardInterface::POLICY_DENY`! + +## Route Name Placeholders + +Route **names** are allowed to contain `*` as placeholders, allowing more compact specifications. +This feature is available for all types of guards. + +> Note that route rules are verified in order of their writing, take care of the order when using placeholder routes, as not to overwrite any specific routes! + +```php +[ + 'type' => 'Route', + 'options' => [ + 'rules' => [ + 'account-create-form' => ['admin'], + 'account-update-form' => ['admin'], + 'account-delete-form' => ['admin'], + ] + ], + [ + 'type' => 'Controller', + 'options' => [ + 'rules' => [ + [ + 'route' => 'admin-create', + 'actions' => [], + 'roles' => ['admin'], + ], + [ + 'route' => 'admin-edit', + 'actions' => [], + 'roles' => ['admin'], + ], + [ + 'route' => 'admin-view', + 'actions' => [], + 'roles' => ['admin'], + ], + ] + ] + ], +], + + +// Can be written as: + +[ + 'type' => 'Route', + 'options' => [ + 'rules' => [ + 'account-*-form' => ['admin'], + ] + ], + [ + 'type' => 'Controller', + 'options' => [ + 'rules' => [ + [ + 'route' => 'admin-*', + 'actions' => [], + 'roles' => ['admin'], + ], + ] + ] + ], +] +``` diff --git a/src/Guard/ControllerGuard.php b/src/Guard/ControllerGuard.php index f0b9ee1..34c4a95 100644 --- a/src/Guard/ControllerGuard.php +++ b/src/Guard/ControllerGuard.php @@ -10,7 +10,12 @@ use Mezzio\Router\RouteResult; use Psr\Http\Message\ServerRequestInterface; +use function array_keys; use function in_array; +use function preg_match; +use function sprintf; +use function str_contains; +use function str_replace; use function strtolower; class ControllerGuard extends AbstractGuard @@ -19,6 +24,8 @@ class ControllerGuard extends AbstractGuard protected ?RoleServiceInterface $roleService = null; + protected array $rules = []; + public function __construct(?array $options = null) { $options = $options ?? []; @@ -34,8 +41,6 @@ public function __construct(?array $options = null) public function setRules(array $rules): void { - $this->rules = []; - foreach ($rules as $rule) { $route = strtolower($rule['route']); $actions = isset($rule['actions']) ? (array) $rule['actions'] : []; @@ -72,6 +77,22 @@ public function isGranted(ServerRequestInterface $request): bool $route = strtolower($routeResult->getMatchedRouteName()); + foreach (array_keys($this->rules) as $routeName) { + if (str_contains($routeName, '*')) { + $routePattern = sprintf( + "/^%s$/", + str_replace('*', '[\w\-]+', $routeName) + ); + + $routeMatched = preg_match($routePattern, $route); + + if ($routeMatched === 1) { + $route = $routeName; + break; + } + } + } + if (! isset($this->rules[$route])) { return $this->protectionPolicy === self::POLICY_ALLOW; } diff --git a/src/Guard/ControllerPermissionGuard.php b/src/Guard/ControllerPermissionGuard.php index 619e516..87f551d 100644 --- a/src/Guard/ControllerPermissionGuard.php +++ b/src/Guard/ControllerPermissionGuard.php @@ -11,10 +11,14 @@ use Mezzio\Router\RouteResult; use Psr\Http\Message\ServerRequestInterface; +use function array_keys; use function gettype; use function in_array; use function is_object; +use function preg_match; use function sprintf; +use function str_contains; +use function str_replace; use function strtolower; class ControllerPermissionGuard extends AbstractGuard @@ -23,6 +27,8 @@ class ControllerPermissionGuard extends AbstractGuard protected ?AuthorizationInterface $authorizationService = null; + protected array $rules = []; + public function __construct(?array $options = null) { $options = $options ?? []; @@ -42,8 +48,6 @@ public function __construct(?array $options = null) public function setRules(array $rules): void { - $this->rules = []; - foreach ($rules as $rule) { $route = strtolower($rule['route']); $actions = isset($rule['actions']) ? (array) $rule['actions'] : []; @@ -69,6 +73,23 @@ public function isGranted(ServerRequestInterface $request): bool } $route = $routeResult->getMatchedRouteName(); + + foreach (array_keys($this->rules) as $routeName) { + if (str_contains($routeName, '*')) { + $routePattern = sprintf( + "/^%s$/", + str_replace('*', '[\w\-]+', $routeName) + ); + + $routeMatched = preg_match($routePattern, $route); + + if ($routeMatched === 1) { + $route = $routeName; + break; + } + } + } + if (! isset($this->rules[$route])) { return $this->protectionPolicy === self::POLICY_ALLOW; } diff --git a/src/Guard/RouteGuard.php b/src/Guard/RouteGuard.php index 6975071..1fbcede 100644 --- a/src/Guard/RouteGuard.php +++ b/src/Guard/RouteGuard.php @@ -12,6 +12,10 @@ use function array_keys; use function in_array; use function is_int; +use function preg_match; +use function sprintf; +use function str_contains; +use function str_replace; use function strtolower; class RouteGuard extends AbstractGuard @@ -20,6 +24,8 @@ class RouteGuard extends AbstractGuard protected ?RoleServiceInterface $roleService = null; + protected array $rules = []; + public function __construct(?array $options = null) { $options = $options ?? []; @@ -36,8 +42,6 @@ public function __construct(?array $options = null) public function setRules(array $rules): void { - $this->rules = []; - foreach ($rules as $key => $value) { if (is_int($key)) { $routeName = strtolower($value); @@ -73,9 +77,23 @@ public function isGranted(ServerRequestInterface $request): bool $allowedRoles = null; foreach (array_keys($this->rules) as $routeName) { - if ($routeName === $matchedRouteName) { - $allowedRoles = $this->rules[$routeName]; - break; + if (str_contains($routeName, '*')) { + $routePattern = sprintf( + "/^%s$/", + str_replace('*', '[\w\-]+', $routeName) + ); + + $routeMatched = preg_match($routePattern, $matchedRouteName); + + if ($routeMatched === 1) { + $allowedRoles = $this->rules[$routeName]; + break; + } + } else { + if ($routeName === $matchedRouteName) { + $allowedRoles = $this->rules[$routeName]; + break; + } } } diff --git a/src/Guard/RoutePermissionGuard.php b/src/Guard/RoutePermissionGuard.php index 89dc98e..0a195e3 100644 --- a/src/Guard/RoutePermissionGuard.php +++ b/src/Guard/RoutePermissionGuard.php @@ -15,7 +15,10 @@ use function in_array; use function is_int; use function is_object; +use function preg_match; use function sprintf; +use function str_contains; +use function str_replace; use function strtolower; class RoutePermissionGuard extends AbstractGuard @@ -24,6 +27,8 @@ class RoutePermissionGuard extends AbstractGuard protected ?AuthorizationInterface $authorizationService = null; + protected array $rules = []; + public function __construct(?array $options = null) { $options = $options ?? []; @@ -43,7 +48,6 @@ public function __construct(?array $options = null) public function setRules(array $rules): void { - $this->rules = []; foreach ($rules as $key => $value) { if (is_int($key)) { $routeName = strtolower($value); @@ -68,9 +72,23 @@ public function isGranted(ServerRequestInterface $request): bool $allowedPermissions = null; foreach (array_keys($this->rules) as $routeName) { - if ($matchedRouteName === $routeName) { - $allowedPermissions = $this->rules[$routeName]; - break; + if (str_contains($routeName, '*')) { + $routePattern = sprintf( + "/^%s$/", + str_replace('*', '[\w\-]+', $routeName) + ); + + $routeMatched = preg_match($routePattern, $matchedRouteName); + + if ($routeMatched === 1) { + $allowedPermissions = $this->rules[$routeName]; + break; + } + } else { + if ($matchedRouteName === $routeName) { + $allowedPermissions = $this->rules[$routeName]; + break; + } } } diff --git a/test/Guard/ControllerGuardTest.php b/test/Guard/ControllerGuardTest.php index 8c34843..ba53376 100644 --- a/test/Guard/ControllerGuardTest.php +++ b/test/Guard/ControllerGuardTest.php @@ -8,6 +8,7 @@ use Dot\Rbac\Role\RoleServiceInterface; use Laminas\Diactoros\ServerRequest; use Mezzio\Router\RouteResult; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\Exception; use PHPUnit\Framework\TestCase; @@ -19,24 +20,22 @@ class ControllerGuardTest extends TestCase protected array $rules = [ [ - 'route' => 'account', - 'actions' => [ + 'route' => 'account', + 'actions' => [ 'avatar', 'details', 'changePassword', 'deleteAccount', 'index', ], - 'permissions' => ['unauthenticated'], - 'roles' => ['*'], + 'roles' => ['*'], ], [ - 'route' => 'page', - 'actions' => [ + 'route' => 'page', + 'actions' => [ 'premium-content', ], - 'permissions' => ['premium'], - 'roles' => [], + 'roles' => [], ], [ 'route' => 'invalidRoute', @@ -165,4 +164,81 @@ public function testIsGranted(): void $result = $this->subject->isGranted($request); $this->assertTrue($result); } + + public function rulesProvider(): array + { + return [ + 'valid-placeholder' => [ + [ + [ + 'route' => 'account-*', + 'actions' => [], + 'roles' => ['*'], + ], + [ + 'route' => 'account-view', + 'actions' => [], + 'roles' => ['*'], + ], + ], + 'account-view', + true, + ], + 'invalid-placeholder' => [ + [ + [ + 'route' => 'account-*', + 'actions' => [], + 'roles' => ['*'], + ], + ], + 'different-account-route', + false, + ], + 'valid-composite-placeholder' => [ + [ + [ + 'route' => 'user-*-form', + 'actions' => [], + 'roles' => ['*'], + ], + ], + 'user-create-form', + true, + ], + 'invalid-composite-placeholder' => [ + [ + [ + 'route' => 'user-*-form', + 'actions' => [], + 'roles' => ['*'], + ], + ], + 'user-create-avatar', + false, + ], + ]; + } + + #[DataProvider('rulesProvider')] + public function testPlaceholderRoutes(array $rules, string $matchedRoute, bool $isValid): void + { + $request = $this->createMock(ServerRequest::class); + $routeResult = $this->createMock(RouteResult::class); + + $this->subject->setRules($rules); + + $request->expects($this->once()) + ->method('getAttribute') + ->with(RouteResult::class) + ->willReturn($routeResult); + $routeResult->expects($this->any()) + ->method('getMatchedParams') + ->willReturn([]); + $routeResult->expects($this->atLeastOnce()) + ->method('getMatchedRouteName') + ->willReturn($matchedRoute); + + $this->assertEquals($isValid, $this->subject->isGranted($request)); + } } diff --git a/test/Guard/ControllerPermissionGuardTest.php b/test/Guard/ControllerPermissionGuardTest.php index e3b49d4..e938ddc 100644 --- a/test/Guard/ControllerPermissionGuardTest.php +++ b/test/Guard/ControllerPermissionGuardTest.php @@ -8,6 +8,7 @@ use Dot\Rbac\Guard\Guard\ControllerPermissionGuard; use Laminas\Diactoros\ServerRequest; use Mezzio\Router\RouteResult; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\Exception; use PHPUnit\Framework\TestCase; @@ -28,7 +29,6 @@ class ControllerPermissionGuardTest extends TestCase 'index', ], 'permissions' => ['*'], - 'roles' => ['*'], ], [ 'route' => 'page', @@ -174,4 +174,81 @@ public function testSetAuthorizationService(): void $result = $this->subject->getAuthorizationService(); $this->assertInstanceOf(AuthorizationInterface::class, $result); } + + public function rulesProvider(): array + { + return [ + 'valid-placeholder' => [ + [ + [ + 'route' => 'account-*', + 'actions' => [], + 'permissions' => ['*'], + ], + [ + 'route' => 'account-view', + 'actions' => [], + 'permissions' => ['*'], + ], + ], + 'account-view', + true, + ], + 'invalid-placeholder' => [ + [ + [ + 'route' => 'account-*', + 'actions' => [], + 'permissions' => ['*'], + ], + ], + 'different-account-route', + false, + ], + 'valid-composite-placeholder' => [ + [ + [ + 'route' => 'user-*-form', + 'actions' => [], + 'permissions' => ['*'], + ], + ], + 'user-create-form', + true, + ], + 'invalid-composite-placeholder' => [ + [ + [ + 'route' => 'user-*-form', + 'actions' => [], + 'permissions' => ['*'], + ], + ], + 'user-create-avatar', + false, + ], + ]; + } + + #[DataProvider('rulesProvider')] + public function testPlaceholderRoutes(array $rules, string $matchedRoute, bool $isValid): void + { + $request = $this->createMock(ServerRequest::class); + $routeResult = $this->createMock(RouteResult::class); + + $this->subject->setRules($rules); + + $request->expects($this->once()) + ->method('getAttribute') + ->with(RouteResult::class) + ->willReturn($routeResult); + $routeResult->expects($this->any()) + ->method('getMatchedParams') + ->willReturn([]); + $routeResult->expects($this->atLeastOnce()) + ->method('getMatchedRouteName') + ->willReturn($matchedRoute); + + $this->assertEquals($isValid, $this->subject->isGranted($request)); + } } diff --git a/test/Guard/RouteGuardTest.php b/test/Guard/RouteGuardTest.php index 164516f..d496913 100644 --- a/test/Guard/RouteGuardTest.php +++ b/test/Guard/RouteGuardTest.php @@ -8,6 +8,7 @@ use Dot\Rbac\Role\RoleServiceInterface; use Laminas\Diactoros\ServerRequest; use Mezzio\Router\RouteResult; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\Exception; use PHPUnit\Framework\TestCase; @@ -145,4 +146,70 @@ public function testIsGranted(): void $result = $this->subject->isGranted($request); $this->assertTrue($result); } + + public function rulesProvider(): array + { + return [ + 'valid-placeholder' => [ + [ + 'account-*' => [ + 'view', + 'create', + ], + 'account-view' => [], + ], + 'account-view', + true, + ], + 'invalid-placeholder' => [ + [ + 'account-*' => [ + 'view', + 'create', + ], + ], + 'different-account-route', + false, + ], + 'valid-composite-placeholder' => [ + [ + 'user-*-form' => [ + 'user-create-form', + 'user-edit-form', + ], + ], + 'user-create-form', + true, + ], + 'invalid-composite-placeholder' => [ + [ + 'user-*-form' => [ + 'user-create-form', + 'user-edit-form', + ], + ], + 'user-create-avatar', + false, + ], + ]; + } + + #[DataProvider('rulesProvider')] + public function testPlaceholderRoutes(array $rules, string $matchedRoute, bool $isValid): void + { + $request = $this->createMock(ServerRequest::class); + $routeResult = $this->createMock(RouteResult::class); + + $this->subject->setRules($rules); + + $request->expects($this->once()) + ->method('getAttribute') + ->with(RouteResult::class) + ->willReturn($routeResult); + $routeResult->expects($this->atLeastOnce()) + ->method('getMatchedRouteName') + ->willReturn($matchedRoute); + + $this->assertEquals($isValid, $this->subject->isGranted($request)); + } } diff --git a/test/Guard/RoutePermissionGuardTest.php b/test/Guard/RoutePermissionGuardTest.php index 23ab940..da125da 100644 --- a/test/Guard/RoutePermissionGuardTest.php +++ b/test/Guard/RoutePermissionGuardTest.php @@ -8,6 +8,7 @@ use Dot\Rbac\Guard\Guard\RoutePermissionGuard; use Laminas\Diactoros\ServerRequest; use Mezzio\Router\RouteResult; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\Exception; use PHPUnit\Framework\TestCase; @@ -118,4 +119,70 @@ public function testIsGranted(): void $result = $this->subject->isGranted($request); $this->assertTrue($result); } + + public function rulesProvider(): array + { + return [ + 'valid-placeholder' => [ + [ + 'account-*' => [ + 'view', + 'create', + ], + 'account-view' => [], + ], + 'account-view', + true, + ], + 'invalid-placeholder' => [ + [ + 'account-*' => [ + 'view', + 'create', + ], + ], + 'different-account-route', + false, + ], + 'valid-composite-placeholder' => [ + [ + 'user-*-form' => [ + 'user-create-form', + 'user-edit-form', + ], + ], + 'user-create-form', + true, + ], + 'invalid-composite-placeholder' => [ + [ + 'user-*-form' => [ + 'user-create-form', + 'user-edit-form', + ], + ], + 'user-create-avatar', + false, + ], + ]; + } + + #[DataProvider('rulesProvider')] + public function testPlaceholderRoutes(array $rules, string $matchedRoute, bool $isValid): void + { + $request = $this->createMock(ServerRequest::class); + $routeResult = $this->createMock(RouteResult::class); + + $this->subject->setRules($rules); + + $request->expects($this->once()) + ->method('getAttribute') + ->with(RouteResult::class) + ->willReturn($routeResult); + $routeResult->expects($this->atLeastOnce()) + ->method('getMatchedRouteName') + ->willReturn($matchedRoute); + + $this->assertEquals($isValid, $this->subject->isGranted($request)); + } }