Skip to content

Commit fa07e0d

Browse files
committed
Add REST policy attributes and ACL name normalization
1 parent 282a29c commit fa07e0d

23 files changed

Lines changed: 1689 additions & 27 deletions

CHANGELOG.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,22 @@ history, the old changelog, and committed file changes. Older Zemit-era entries
1515
are summarized where the commit history is too granular to be useful as
1616
release notes.
1717

18-
## 3.1.0 - Unreleased
18+
## 3.1.0 - 2026-06-11
1919

2020
### Added
2121

22+
- Added controller/action policy attributes for REST permission declarations:
23+
`AllowRoles`, `PermissionFeature`, and `AttachBehavior`. Attributes compile into
24+
the existing permission config shape, support direct roles and feature-based
25+
grants, and can attach action-scoped controller behaviors.
26+
- Added shared permission-name normalization so ACL checks accept camelCase and
27+
dash-case action names while recommending dash-case as the canonical route and
28+
permission key. Dispatcher security also accepts route-style controller aliases
29+
while continuing to prefer controller class names.
30+
- Added the env-backed `acl.attributes` config switch (`ACL_ATTRIBUTES`). It
31+
defaults to enabled so controller attributes work out of the box; config-only
32+
applications can set it to `false` to skip controller reflection during
33+
permission and behavior checks.
2234
- Added array-friendly REST/query policy setters and merge helpers. Controllers
2335
can now pass plain arrays to field, condition, join, eager-loading, count,
2436
distinct, cache, bind, group, having, column, order, and find policy methods;

ROADMAP.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ guidance in the relevant guide or shipped skill reference.
3232

3333
## Current Focus
3434

35-
Target: `3.1.0` REST API ergonomics and scaffold readiness
35+
Target: `3.1.0` REST API ergonomics, controller policy attributes, and scaffold
36+
readiness
3637

3738
Theme: keep REST controller declarations concise and predictable, then continue
3839
the scaffold work with a clear generated-file ownership contract.
@@ -44,6 +45,9 @@ Decision:
4445
- REST policy declaration ergonomics start the `3.1.0` development line:
4546
collection-backed setters should accept arrays while storing normalized
4647
collections internally.
48+
- Controller/action policy declarations can live beside controller code through
49+
optional attributes, while the runtime still compiles them into the existing
50+
permission config and ACL enforcement path.
4751
- The next schedulable block is REST controller scaffold readiness. Start with
4852
generated-file ownership before adding public scaffolding behavior.
4953

guides/configuration.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,5 +287,16 @@ query behaviors, and roles.
287287
The identity/security system can enforce permissions across controllers,
288288
actions, models, methods, CLI tasks, and WebSocket tasks.
289289

290+
Controller/action attributes are enabled by default and are merged into the same
291+
permission graph at runtime. Disable attribute scanning for config-only
292+
applications that want to avoid controller reflection. The default bootstrap
293+
config reads `ACL_ATTRIBUTES`:
294+
295+
```php
296+
'acl' => [
297+
'attributes' => Env::get('ACL_ATTRIBUTES', true),
298+
],
299+
```
300+
290301
For row-level controller conditions and role inheritance, read
291302
[Identity And Permissions](identity-and-permissions.md).

guides/first-rest-resource.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,54 @@ Feature permissions live in config:
229229
The config says which components a role can use. The controller's permission
230230
conditions decide which rows that role can access.
231231

232+
The same controller actions can also be declared with attributes while keeping
233+
role assignment in config:
234+
235+
```php
236+
use PhalconKit\Mvc\Controller\Attributes\PermissionFeature;
237+
238+
#[PermissionFeature('manageProject', actions: '*')]
239+
#[PermissionFeature('viewProject', actions: [
240+
'find',
241+
'find-with',
242+
'find-first',
243+
'find-first-with',
244+
])]
245+
final class ProjectController extends AbstractController
246+
{
247+
}
248+
```
249+
250+
With that style, the config only needs to assign features to roles and keep
251+
model-level permissions:
252+
253+
```php
254+
'permissions' => [
255+
'features' => [
256+
'manageProject' => [
257+
'components' => [
258+
\App\Models\Project::class => ['*'],
259+
\App\Models\ProjectUser::class => ['*'],
260+
],
261+
],
262+
'viewProject' => [
263+
'components' => [
264+
\App\Models\Project::class => ['find'],
265+
\App\Models\ProjectUser::class => ['find'],
266+
],
267+
],
268+
],
269+
'roles' => [
270+
'admin' => [
271+
'features' => ['manageProject'],
272+
],
273+
'researcher' => [
274+
'features' => ['viewProject'],
275+
],
276+
],
277+
],
278+
```
279+
232280
## 6. Call The Resource
233281

234282
Exact URLs depend on your route config. With the default module route shape,

guides/identity-and-permissions.md

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,227 @@ controller behaviors. Roles receive features.
9292
Components can be controllers, controller actions, models, model methods, CLI
9393
tasks, or WebSocket tasks.
9494

95+
Use controller class names as the canonical component key. Route-style
96+
controller aliases such as `project-user` are accepted during dispatcher
97+
security checks for compatibility, but class constants are easier to refactor.
98+
99+
Use dash-case for action keys because these names match URLs:
100+
101+
```php
102+
\App\Modules\Api\Controllers\ProjectController::class => [
103+
'find',
104+
'find-with',
105+
'archive-project',
106+
],
107+
```
108+
109+
Existing camelCase action config remains valid. PhalconKit normalizes dispatcher
110+
actions and ACL registrations so `findWith` and `find-with` refer to the same
111+
permission action. New docs and generated examples should prefer dash-case.
112+
113+
## Controller Attributes
114+
115+
Controllers may declare permissions with PHP attributes. Attributes are additive:
116+
they compile into the same `permissions` structure shown above, so existing
117+
config-driven features, roles, and inheritance remain valid.
118+
119+
```php
120+
use Phalcon\Http\ResponseInterface;
121+
use PhalconKit\Mvc\Controller\Attributes\AllowRoles;
122+
use PhalconKit\Mvc\Controller\Attributes\AttachBehavior;
123+
use PhalconKit\Mvc\Controller\Attributes\PermissionFeature;
124+
use PhalconKit\Mvc\Controller\Behavior\Query\Conditions\RemoveDefaultPermissionCondition;
125+
126+
#[PermissionFeature('project.view', actions: ['find', 'find-with'])]
127+
#[AllowRoles('admin', actions: '*')]
128+
final class ProjectController extends AbstractController
129+
{
130+
#[PermissionFeature('project.write')]
131+
#[AllowRoles(['admin', 'manager'])]
132+
#[AttachBehavior(RemoveDefaultPermissionCondition::class, roles: 'admin')]
133+
public function archiveProjectAction(): ResponseInterface
134+
{
135+
// ...
136+
}
137+
}
138+
```
139+
140+
Method-level attributes without `actions` use the method name after removing the
141+
`Action` suffix. In the example above, `archiveProjectAction()` maps to
142+
`archive-project`. Class-level attributes without `actions` apply to `*`.
143+
144+
`PermissionFeature` declares which actions belong to a feature; roles still
145+
receive that feature through config:
146+
147+
```php
148+
'permissions' => [
149+
'roles' => [
150+
'manager' => [
151+
'features' => ['project.write'],
152+
],
153+
],
154+
],
155+
```
156+
157+
`AllowRoles` grants controller actions directly to roles when a small app or a
158+
local workflow does not need a reusable feature. `AttachBehavior` can target
159+
roles, features, or both. When neither `roles` nor `features` is provided, the
160+
behavior attaches for the `everyone` context role.
161+
162+
### Config-First Feature Example
163+
164+
Use this style when permissions are owned centrally and several controllers,
165+
models, tasks, or WebSocket actions share the same feature.
166+
167+
```php
168+
'permissions' => [
169+
'features' => [
170+
'project.view' => [
171+
'components' => [
172+
\App\Modules\Api\Controllers\ProjectController::class => [
173+
'find',
174+
'find-with',
175+
'find-first',
176+
'find-first-with',
177+
],
178+
\App\Models\Project::class => ['find'],
179+
],
180+
],
181+
'project.manage' => [
182+
'components' => [
183+
\App\Modules\Api\Controllers\ProjectController::class => ['*'],
184+
\App\Models\Project::class => ['*'],
185+
],
186+
'behaviors' => [
187+
\App\Modules\Api\Controllers\ProjectController::class => [
188+
RemoveDefaultPermissionCondition::class,
189+
],
190+
],
191+
],
192+
],
193+
'roles' => [
194+
'admin' => [
195+
'features' => ['project.manage'],
196+
],
197+
'researcher' => [
198+
'features' => ['project.view'],
199+
],
200+
],
201+
],
202+
```
203+
204+
### Attribute-First Feature Example
205+
206+
Use this style when the controller owns its own action surface but roles should
207+
still receive reusable features through config.
208+
209+
```php
210+
#[PermissionFeature('project.view', actions: [
211+
'find',
212+
'find-with',
213+
'find-first',
214+
'find-first-with',
215+
])]
216+
#[PermissionFeature('project.manage', actions: '*')]
217+
#[AttachBehavior(RemoveDefaultPermissionCondition::class, features: 'project.manage')]
218+
final class ProjectController extends AbstractController
219+
{
220+
#[PermissionFeature('project.manage')]
221+
public function archiveProjectAction(): ResponseInterface
222+
{
223+
// archive-project
224+
}
225+
}
226+
```
227+
228+
The remaining config only assigns features to roles:
229+
230+
```php
231+
'permissions' => [
232+
'roles' => [
233+
'admin' => [
234+
'features' => ['project.manage'],
235+
],
236+
'researcher' => [
237+
'features' => ['project.view'],
238+
],
239+
],
240+
],
241+
```
242+
243+
### Direct Role Attribute Example
244+
245+
Use direct role attributes for small controllers or local actions that are not
246+
worth turning into global feature names.
247+
248+
```php
249+
final class ProjectController extends AbstractController
250+
{
251+
#[AllowRoles(['admin', 'manager'])]
252+
public function archiveProjectAction(): ResponseInterface
253+
{
254+
// archive-project
255+
}
256+
257+
#[AllowRoles('admin', actions: ['restore', 'force-delete'])]
258+
public function restoreAction(): ResponseInterface
259+
{
260+
// restore
261+
}
262+
}
263+
```
264+
265+
Direct role attributes do not require matching `features` config. The role still
266+
has to be present in the current identity's ACL role list.
267+
268+
### Behavior Example In Both Styles
269+
270+
Action-scoped behavior through config:
271+
272+
```php
273+
'permissions' => [
274+
'roles' => [
275+
'admin' => [
276+
'behaviorActions' => [
277+
\App\Modules\Api\Controllers\ProjectController::class => [
278+
'archive-project' => [
279+
RemoveDefaultPermissionCondition::class,
280+
],
281+
],
282+
],
283+
],
284+
],
285+
],
286+
```
287+
288+
The same behavior beside the action:
289+
290+
```php
291+
final class ProjectController extends AbstractController
292+
{
293+
#[AttachBehavior(RemoveDefaultPermissionCondition::class, roles: 'admin')]
294+
public function archiveProjectAction(): ResponseInterface
295+
{
296+
// archive-project
297+
}
298+
}
299+
```
300+
301+
The existing `behaviors` key is still supported for non-action-scoped behavior
302+
attachment.
303+
304+
Controller attributes use PHP's built-in Reflection API. Normal PHP 8.5 builds
305+
include Reflection; no extra Composer package or optional extension is required.
306+
Only the active controller class is inspected, and PhalconKit caches the result
307+
inside the process. Config-only applications can disable attribute scanning with
308+
`ACL_ATTRIBUTES=false` or config:
309+
310+
```php
311+
'acl' => [
312+
'attributes' => false,
313+
],
314+
```
315+
95316
## Row-Level Conditions
96317

97318
Feature-level access answers whether a role may use a component. Row-level

guides/rest-api.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,12 @@ normal list requests lighter.
252252
Feature permissions decide whether a role can use a controller/action. Row-level
253253
conditions decide which records that role can access.
254254

255+
Permission action keys should be dash-case, matching route names such as
256+
`find-with` or `archive-project`. Controller methods remain normal PHP camelCase
257+
methods such as `findWithAction()` and `archiveProjectAction()`. PhalconKit
258+
accepts both names during ACL checks, but new config and attributes should use
259+
the route-style dash-case names.
260+
255261
```php
256262
public function initializePermissionConditions(): void
257263
{

guides/to-be-discussed.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,11 +94,11 @@ Keep for discussion:
9494
assignment. Remaining design work: a clearer replacement for boolean
9595
keep-missing sentinels, and explicit rules for sparse payloads that reactivate
9696
or update existing relation rows.
97-
- Controller behavior merging:
97+
- Controller behavior responses and merge semantics:
9898
`src/Mvc/Controller/Traits/Behavior.php`.
9999
Define whether controller behaviors should collect multiple event responses
100-
and how feature/role permission merges should de-duplicate overlapping
101-
entries.
100+
and whether legacy feature/role permission merges should move from native
101+
recursive merging to the attribute resolver's de-duplicating merge helper.
102102
- `findIn*` model helpers:
103103
`src/Mvc/Model/Traits/FindIn.php`.
104104
Expand beyond `findInById()` only after field validation, bind-type

src/Acl/Acl.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ public function get(array $componentsName = ['components'], ?array $permissions
9292
continue;
9393
}
9494

95-
$aclAccess = is_array($accessList) ? array_values(array_unique(array_filter($accessList))) : [$accessList];
95+
$aclAccess = PermissionName::accessList($accessList);
9696
$aclComponent = new Component($component);
9797
$acl->addComponent($aclComponent, $aclAccess);
9898
$acl->allow((string)$aclRole, (string)$aclComponent, $aclAccess);

0 commit comments

Comments
 (0)