From f3630d88783314e89eefdaeae000dd0d4bcd49de Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sat, 29 Aug 2026 01:55:24 +0200 Subject: [PATCH 1/2] Add AuthorizationComponent::skipAuthorizationActions() The `skipAuthorization` config key already marks controller actions as public, but unlike `authorizeModel()` and `mapAction()` it has no fluent setter, so it can only be set through `loadComponent()` options or `setConfig()`. Add a variadic setter that merges into the existing config, matching the `authorizeModel(string ...$actions)` signature. `authorizeAction()` runs on `Controller.startup`, which dispatches after `Controller.initialize`, so registering actions from `beforeFilter()` takes effect. --- docs/en/component.md | 6 ++++++ .../Component/AuthorizationComponent.php | 16 ++++++++++++++++ .../Component/AuthorizationComponentTest.php | 18 ++++++++++++++++++ 3 files changed, 40 insertions(+) diff --git a/docs/en/component.md b/docs/en/component.md index 30d456b..278e85c 100644 --- a/docs/en/component.md +++ b/docs/en/component.md @@ -34,6 +34,12 @@ $this->loadComponent('Authorization.Authorization', [ ]); ``` +The same can be done at runtime, for example in `beforeFilter()`: + +```php +$this->Authorization->skipAuthorizationActions('login', 'logout'); +``` + By default, every action requires authorization when authorization checking is enabled. diff --git a/src/Controller/Component/AuthorizationComponent.php b/src/Controller/Component/AuthorizationComponent.php index c87a5e8..bb963f3 100644 --- a/src/Controller/Component/AuthorizationComponent.php +++ b/src/Controller/Component/AuthorizationComponent.php @@ -178,6 +178,22 @@ public function skipAuthorization() return $this; } + /** + * Adds actions that should skip the automatic authorization check. + * + * Actions registered here are marked as authorized in `authorizeAction()`, + * which runs on the configured `authorizationEvent`. + * + * @param string ...$actions Controller actions to skip authorization for. + * @return $this + */ + public function skipAuthorizationActions(string ...$actions) + { + $this->_config['skipAuthorization'] = array_merge($this->_config['skipAuthorization'], $actions); + + return $this; + } + /** * Allows to map controller action to another authorization policy action. * diff --git a/tests/TestCase/Controller/Component/AuthorizationComponentTest.php b/tests/TestCase/Controller/Component/AuthorizationComponentTest.php index b1d17e6..5b8aefd 100644 --- a/tests/TestCase/Controller/Component/AuthorizationComponentTest.php +++ b/tests/TestCase/Controller/Component/AuthorizationComponentTest.php @@ -550,6 +550,24 @@ public function testAuthorizeModel(): void $this->assertEquals(['foo', 'bar', 'baz'], $this->Auth->getConfig('authorizeModel')); } + public function testSkipAuthorizationActions(): void + { + $this->Auth->skipAuthorizationActions('foo', 'bar'); + $this->assertEquals(['foo', 'bar'], $this->Auth->getConfig('skipAuthorization')); + + $this->Auth->skipAuthorizationActions('baz'); + $this->assertEquals(['foo', 'bar', 'baz'], $this->Auth->getConfig('skipAuthorization')); + } + + public function testSkipAuthorizationActionsAppliedOnAuthorizeAction(): void + { + $service = $this->Controller->getRequest()->getAttribute('authorization'); + + $this->Auth->skipAuthorizationActions('edit'); + $this->Auth->authorizeAction(); + $this->assertTrue($service->authorizationChecked()); + } + public function testMapAction(): void { $this->Auth->mapAction('foo', 'bar'); From 16affc5941836aa516c653e6527c99a0d4798dc8 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sat, 29 Aug 2026 12:42:09 +0200 Subject: [PATCH 2/2] Honor skipped actions in implicit authorization checks Listing an action in `skipAuthorization` only affected the automatic check in `authorizeAction()`. A manual `can()` or `authorize()` for the current action, for example a single gate in `beforeFilter()`, still ran the policy and failed on actions the application had already declared public. Treat the current action as authorized in `can()`, `canResult()` and `authorize()` when it is on the list. Only an implicit check is affected: an explicit action such as `can($article, 'delete')` always runs its policy. The raw action name is matched through the shared `isSkippedAction()` helper, the same key `authorizeAction()` uses, so both paths agree when `actionMap` is set. Document the three ways to skip authorization and how they differ. --- docs/en/component.md | 77 +++++++++++++++---- .../Component/AuthorizationComponent.php | 32 ++++++++ .../Component/AuthorizationComponentTest.php | 48 ++++++++++++ 3 files changed, 141 insertions(+), 16 deletions(-) diff --git a/docs/en/component.md b/docs/en/component.md index 278e85c..17a4a3b 100644 --- a/docs/en/component.md +++ b/docs/en/component.md @@ -24,21 +24,8 @@ controller's default model class and the current action: $this->Authorization->authorizeModel('index', 'add'); ``` -You can also mark actions as public by skipping authorization: - -```php -$this->loadComponent('Authorization.Authorization', [ - 'skipAuthorization' => [ - 'login', - ], -]); -``` - -The same can be done at runtime, for example in `beforeFilter()`: - -```php -$this->Authorization->skipAuthorizationActions('login', 'logout'); -``` +You can also mark actions as public by skipping authorization. See +[Skipping Authorization](#skipping-authorization) below. By default, every action requires authorization when authorization checking is enabled. @@ -127,7 +114,62 @@ public function add() ## Skipping Authorization -You can also skip authorization inside an action: +By default every action requires an authorization check, and the middleware +raises an exception when an action performs none. Marking an action as public is +therefore explicit. There are three ways to do it, which differ in where the +knowledge about the action lives. + +### For the whole application + +Pass the action names when loading the component, usually in `AppController`: + +```php +$this->loadComponent('Authorization.Authorization', [ + 'skipAuthorization' => [ + 'login', + ], +]); +``` + +Use this for actions that are public everywhere, such as a login action on a +controller every other controller inherits from. + +### Per controller + +`skipAuthorizationActions()` appends to the same list at runtime, so a controller +can declare its own public actions without `AppController` knowing about them: + +```php +public function beforeFilter(\Cake\Event\EventInterface $event) +{ + parent::beforeFilter($event); + + $this->Authorization->skipAuthorizationActions('verifyEmail', 'webhook'); +} +``` + +Actions listed here are skipped by the automatic check, and `can()`, +`canResult()` and `authorize()` treat the current action as authorized, so a +manual check in `beforeFilter()` does not have to special-case them: + +```php +public function beforeFilter(\Cake\Event\EventInterface $event) +{ + parent::beforeFilter($event); + + $this->Authorization->skipAuthorizationActions('login', 'logout'); + + if (!$this->Authorization->can($this)) { + return $this->redirect('/'); + } +} +``` + +This applies only to a check on the current action. An explicit action always +runs its policy, so `can($article, 'delete')` is unaffected by `delete` being in +the list. + +### Inside a single action ```php public function view($id) @@ -135,3 +177,6 @@ public function view($id) $this->Authorization->skipAuthorization(); } ``` + +Use this when whether the action needs authorization depends on something you +only know once the action runs. diff --git a/src/Controller/Component/AuthorizationComponent.php b/src/Controller/Component/AuthorizationComponent.php index bb963f3..34cedfa 100644 --- a/src/Controller/Component/AuthorizationComponent.php +++ b/src/Controller/Component/AuthorizationComponent.php @@ -19,6 +19,7 @@ use Authorization\AuthorizationServiceInterface; use Authorization\Exception\ForbiddenException; use Authorization\IdentityInterface; +use Authorization\Policy\Result; use Authorization\Policy\ResultInterface; use Cake\Controller\Component; use Cake\Http\ServerRequest; @@ -64,6 +65,12 @@ public function authorize(mixed $resource, ?string $action = null): void { if ($action === null) { $request = $this->getController()->getRequest(); + if ($this->isSkippedAction($request)) { + $this->skipAuthorization(); + + return; + } + $action = $this->getDefaultAction($request); } @@ -127,6 +134,16 @@ protected function performCheck( ): ResultInterface|bool { $request = $this->getController()->getRequest(); if ($action === null) { + if ($this->isSkippedAction($request)) { + $this->skipAuthorization(); + + if ($method === 'can') { + return true; + } + + return new Result(true); + } + $action = $this->getDefaultAction($request); } @@ -314,6 +331,21 @@ public function authorizeAction(): void } } + /** + * Whether the current controller action is configured to skip authorization. + * + * Only an implicit check refers to the current controller action, so only that one can + * be skipped. The raw action name is matched, the same key `authorizeAction()` uses, + * so both paths agree when `actionMap` is in play. + * + * @param \Cake\Http\ServerRequest $request Server request. + * @return bool + */ + protected function isSkippedAction(ServerRequest $request): bool + { + return $this->checkAction((string)$request->getParam('action'), 'skipAuthorization'); + } + /** * Checks whether an action should be authorized according to the config key provided. * diff --git a/tests/TestCase/Controller/Component/AuthorizationComponentTest.php b/tests/TestCase/Controller/Component/AuthorizationComponentTest.php index 5b8aefd..0a88b9b 100644 --- a/tests/TestCase/Controller/Component/AuthorizationComponentTest.php +++ b/tests/TestCase/Controller/Component/AuthorizationComponentTest.php @@ -568,6 +568,54 @@ public function testSkipAuthorizationActionsAppliedOnAuthorizeAction(): void $this->assertTrue($service->authorizationChecked()); } + public function testSkipAuthorizationActionsAppliedOnCan(): void + { + $service = $this->Controller->getRequest()->getAttribute('authorization'); + $article = new Article(['user_id' => 99]); + $this->assertFalse($this->Auth->can($article)); + + $this->Auth->skipAuthorizationActions('edit'); + $this->assertTrue($this->Auth->can($article)); + $this->assertTrue($service->authorizationChecked()); + } + + public function testSkipAuthorizationActionsAppliedOnCanResult(): void + { + $this->Auth->skipAuthorizationActions('edit'); + + $result = $this->Auth->canResult(new Article(['user_id' => 99])); + $this->assertInstanceOf(ResultInterface::class, $result); + $this->assertTrue($result->getStatus()); + } + + public function testSkipAuthorizationActionsAppliedOnAuthorize(): void + { + $this->Auth->skipAuthorizationActions('edit'); + + $this->Auth->authorize(new Article(['user_id' => 99])); + $this->assertTrue($this->Controller->getRequest()->getAttribute('authorization')->authorizationChecked()); + } + + public function testSkipAuthorizationActionsIgnoredForExplicitAction(): void + { + $article = new Article(['user_id' => 99]); + $this->Auth->skipAuthorizationActions('delete'); + + $this->assertFalse($this->Auth->can($article, 'delete')); + } + + public function testSkipAuthorizationActionsUsesControllerAction(): void + { + $service = $this->Controller->getRequest()->getAttribute('authorization'); + $this->Auth->mapAction('edit', 'modify'); + $this->Auth->skipAuthorizationActions('edit'); + + $this->assertTrue($this->Auth->can(new Article(['user_id' => 99]))); + + $this->Auth->authorizeAction(); + $this->assertTrue($service->authorizationChecked()); + } + public function testMapAction(): void { $this->Auth->mapAction('foo', 'bar');