Skip to content

Commit 16affc5

Browse files
committed
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.
1 parent f3630d8 commit 16affc5

3 files changed

Lines changed: 141 additions & 16 deletions

File tree

‎docs/en/component.md‎

Lines changed: 61 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -24,21 +24,8 @@ controller's default model class and the current action:
2424
$this->Authorization->authorizeModel('index', 'add');
2525
```
2626

27-
You can also mark actions as public by skipping authorization:
28-
29-
```php
30-
$this->loadComponent('Authorization.Authorization', [
31-
'skipAuthorization' => [
32-
'login',
33-
],
34-
]);
35-
```
36-
37-
The same can be done at runtime, for example in `beforeFilter()`:
38-
39-
```php
40-
$this->Authorization->skipAuthorizationActions('login', 'logout');
41-
```
27+
You can also mark actions as public by skipping authorization. See
28+
[Skipping Authorization](#skipping-authorization) below.
4229

4330
By default, every action requires authorization when authorization checking is
4431
enabled.
@@ -127,11 +114,69 @@ public function add()
127114

128115
## Skipping Authorization
129116

130-
You can also skip authorization inside an action:
117+
By default every action requires an authorization check, and the middleware
118+
raises an exception when an action performs none. Marking an action as public is
119+
therefore explicit. There are three ways to do it, which differ in where the
120+
knowledge about the action lives.
121+
122+
### For the whole application
123+
124+
Pass the action names when loading the component, usually in `AppController`:
125+
126+
```php
127+
$this->loadComponent('Authorization.Authorization', [
128+
'skipAuthorization' => [
129+
'login',
130+
],
131+
]);
132+
```
133+
134+
Use this for actions that are public everywhere, such as a login action on a
135+
controller every other controller inherits from.
136+
137+
### Per controller
138+
139+
`skipAuthorizationActions()` appends to the same list at runtime, so a controller
140+
can declare its own public actions without `AppController` knowing about them:
141+
142+
```php
143+
public function beforeFilter(\Cake\Event\EventInterface $event)
144+
{
145+
parent::beforeFilter($event);
146+
147+
$this->Authorization->skipAuthorizationActions('verifyEmail', 'webhook');
148+
}
149+
```
150+
151+
Actions listed here are skipped by the automatic check, and `can()`,
152+
`canResult()` and `authorize()` treat the current action as authorized, so a
153+
manual check in `beforeFilter()` does not have to special-case them:
154+
155+
```php
156+
public function beforeFilter(\Cake\Event\EventInterface $event)
157+
{
158+
parent::beforeFilter($event);
159+
160+
$this->Authorization->skipAuthorizationActions('login', 'logout');
161+
162+
if (!$this->Authorization->can($this)) {
163+
return $this->redirect('/');
164+
}
165+
}
166+
```
167+
168+
This applies only to a check on the current action. An explicit action always
169+
runs its policy, so `can($article, 'delete')` is unaffected by `delete` being in
170+
the list.
171+
172+
### Inside a single action
131173

132174
```php
133175
public function view($id)
134176
{
135177
$this->Authorization->skipAuthorization();
136178
}
137179
```
180+
181+
Use this when whether the action needs authorization depends on something you
182+
only know once the action runs.

‎src/Controller/Component/AuthorizationComponent.php‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use Authorization\AuthorizationServiceInterface;
2020
use Authorization\Exception\ForbiddenException;
2121
use Authorization\IdentityInterface;
22+
use Authorization\Policy\Result;
2223
use Authorization\Policy\ResultInterface;
2324
use Cake\Controller\Component;
2425
use Cake\Http\ServerRequest;
@@ -64,6 +65,12 @@ public function authorize(mixed $resource, ?string $action = null): void
6465
{
6566
if ($action === null) {
6667
$request = $this->getController()->getRequest();
68+
if ($this->isSkippedAction($request)) {
69+
$this->skipAuthorization();
70+
71+
return;
72+
}
73+
6774
$action = $this->getDefaultAction($request);
6875
}
6976

@@ -127,6 +134,16 @@ protected function performCheck(
127134
): ResultInterface|bool {
128135
$request = $this->getController()->getRequest();
129136
if ($action === null) {
137+
if ($this->isSkippedAction($request)) {
138+
$this->skipAuthorization();
139+
140+
if ($method === 'can') {
141+
return true;
142+
}
143+
144+
return new Result(true);
145+
}
146+
130147
$action = $this->getDefaultAction($request);
131148
}
132149

@@ -314,6 +331,21 @@ public function authorizeAction(): void
314331
}
315332
}
316333

334+
/**
335+
* Whether the current controller action is configured to skip authorization.
336+
*
337+
* Only an implicit check refers to the current controller action, so only that one can
338+
* be skipped. The raw action name is matched, the same key `authorizeAction()` uses,
339+
* so both paths agree when `actionMap` is in play.
340+
*
341+
* @param \Cake\Http\ServerRequest $request Server request.
342+
* @return bool
343+
*/
344+
protected function isSkippedAction(ServerRequest $request): bool
345+
{
346+
return $this->checkAction((string)$request->getParam('action'), 'skipAuthorization');
347+
}
348+
317349
/**
318350
* Checks whether an action should be authorized according to the config key provided.
319351
*

‎tests/TestCase/Controller/Component/AuthorizationComponentTest.php‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,54 @@ public function testSkipAuthorizationActionsAppliedOnAuthorizeAction(): void
568568
$this->assertTrue($service->authorizationChecked());
569569
}
570570

571+
public function testSkipAuthorizationActionsAppliedOnCan(): void
572+
{
573+
$service = $this->Controller->getRequest()->getAttribute('authorization');
574+
$article = new Article(['user_id' => 99]);
575+
$this->assertFalse($this->Auth->can($article));
576+
577+
$this->Auth->skipAuthorizationActions('edit');
578+
$this->assertTrue($this->Auth->can($article));
579+
$this->assertTrue($service->authorizationChecked());
580+
}
581+
582+
public function testSkipAuthorizationActionsAppliedOnCanResult(): void
583+
{
584+
$this->Auth->skipAuthorizationActions('edit');
585+
586+
$result = $this->Auth->canResult(new Article(['user_id' => 99]));
587+
$this->assertInstanceOf(ResultInterface::class, $result);
588+
$this->assertTrue($result->getStatus());
589+
}
590+
591+
public function testSkipAuthorizationActionsAppliedOnAuthorize(): void
592+
{
593+
$this->Auth->skipAuthorizationActions('edit');
594+
595+
$this->Auth->authorize(new Article(['user_id' => 99]));
596+
$this->assertTrue($this->Controller->getRequest()->getAttribute('authorization')->authorizationChecked());
597+
}
598+
599+
public function testSkipAuthorizationActionsIgnoredForExplicitAction(): void
600+
{
601+
$article = new Article(['user_id' => 99]);
602+
$this->Auth->skipAuthorizationActions('delete');
603+
604+
$this->assertFalse($this->Auth->can($article, 'delete'));
605+
}
606+
607+
public function testSkipAuthorizationActionsUsesControllerAction(): void
608+
{
609+
$service = $this->Controller->getRequest()->getAttribute('authorization');
610+
$this->Auth->mapAction('edit', 'modify');
611+
$this->Auth->skipAuthorizationActions('edit');
612+
613+
$this->assertTrue($this->Auth->can(new Article(['user_id' => 99])));
614+
615+
$this->Auth->authorizeAction();
616+
$this->assertTrue($service->authorizationChecked());
617+
}
618+
571619
public function testMapAction(): void
572620
{
573621
$this->Auth->mapAction('foo', 'bar');

0 commit comments

Comments
 (0)