ADR-0006: Two-Tier Authorization Model
Accepted Issue TrackerDate: 2026-02-13 | Implemented: 2026-02-16 (PR #251)
Context
A security review uncovered that UserPolicy::assignRole() — a fully implemented and unit-tested policy method with granular role hierarchy enforcement — was never invoked anywhere in the codebase. The method was lost during a refactor and went undetected because the existing architecture test only verified the presence of ->can() middleware on routes, not whether the correct policy method was used.
The root cause is structural: assignRole requires three parameters (User $user, User $target, UserRoleEnum $role), where the role comes from the request body — not from route bindings. Route middleware cannot access validated request data, so the check couldn't be enforced at the route level.
This revealed that the authorization system conflated two fundamentally different types of permission checks:
- Model-based — "Can this user access/modify this resource?" Determined entirely by the user and the target model. All inputs available from route bindings.
- Interaction-based — "Can this user perform this specific operation in this context?" Requires runtime data beyond route bindings (e.g., which role is being assigned).
Decision
Two Authorization Tiers
| Model-Based Permissions | Interaction-Based Permissions | |
|---|---|---|
| Question | Can this user access/modify this resource? | Can this user perform this specific operation in this context? |
| Inputs | User + Model (route bindings) | User + Model + runtime context (request body, business state) |
| Location | app/Policies/{Model}Policy.php | app/Policies/Interactions/{Name}Permission.php |
| Enforcement | Route-level ->can() middleware | Action-level Gate::authorize() |
Directory Structure
app/Policies/
UserPolicy.php # model-based: update, delete, invite
ProjectPolicy.php # model-based: access, create, update, delete
IssuePolicy.php # model-based: create, update, delete
Interactions/
AssignRolePermission.php # interaction-based: user + target + roleHow It Works
Model-based permissions live in standard Policy classes and are enforced at the route level. These only need the user and the model — both available from route bindings:
// Route definition
Route::put('/users/{user}', [UserController::class, 'update'])
->can('update', 'user');Interaction-based permissions live in the Interactions/ directory and are enforced inside the Action. These need runtime data that isn't available at the route level:
final readonly class UpdateUserProfileAction
{
public function __construct(
private ConnectionInterface $db,
private Gate $gate, // Contract, not Facade
) {}
public function execute(User $target, UpdateUserProfileDTO $dto): User
{
return $this->db->transaction(function () use ($target, $dto): User {
if ($dto->role instanceof UserRoleEnum) {
$this->gate->authorize('assignRole', [$target, $dto->role]);
}
// ... update logic
});
}
}Admin Bypass: Explicit Per Permission
Each permission class handles admin access with an explicit if ($user->role->isAdmin()) check, rather than relying on a global Gate::before() callback. This keeps each permission self-documenting and aligns with the explicit-over-implicit principle.
Architecture Test Enforcement
Three tests enforce the boundary from both sides:
1. All authenticated routes must have ->can() middleware (existing test, unchanged)
2. Interaction permissions must NOT appear in route-level ->can():
test('interaction permissions are not registered as route-level can middleware', function (): void {
$interactionPermissions = getInteractionPermissionNames();
$routes = collect(Route::getRoutes()->getRoutes());
foreach ($routes as $route) {
foreach ($route->gatherMiddleware() as $middleware) {
if (str_starts_with((string) $middleware, 'can:')) {
$ability = Str::after((string) $middleware, 'can:');
$ability = Str::before($ability, ',');
expect($interactionPermissions)->not->toContain($ability,
"Route [{$route->uri()}] uses interaction permission at route level."
);
}
}
}
});3. Every interaction permission must be referenced in at least one Action — this is the test that would have caught the assignRole gap:
test('all interaction permissions are invoked in at least one Action', function (): void {
$permissions = glob(app_path('Policies/Interactions/*.php'));
foreach ($permissions as $file) {
$className = pathinfo($file, PATHINFO_FILENAME);
$permissionName = Str::camel(Str::before($className, 'Permission'));
$actionFiles = glob(app_path('Actions/**/*.php'));
$found = false;
foreach ($actionFiles as $actionFile) {
if (str_contains(file_get_contents($actionFile), $permissionName)) {
$found = true;
break;
}
}
expect($found)->toBeTrue(
"Interaction permission [{$className}] is not referenced in any Action."
);
}
});Options Considered
| Option | Verdict | Reason |
|---|---|---|
| Enforce all permissions at route level (status quo) | Rejected | Forces unnatural patterns. Route middleware can't access request body data. This is exactly what caused the gap. |
| Enforce all permissions in Actions | Rejected | Delays rejection of unauthorized requests. Model-based checks work perfectly at the route level — moving them to Actions wastes computation. |
| Two tiers: route-level for model-based, action-level for interaction-based | Accepted | Each tier enforced at the layer where its inputs are naturally available. |
Consequences
Positive
- Authorization checks live at the layer where their inputs are naturally available
- The directory split makes the distinction visible — developers know where to put a new permission
- Architecture tests enforce the boundary, preventing drift
- The "dead permission" test catches exactly the class of bug that created the original gap
Negative
- Introduces a new directory and class pattern that developers must learn
- Interaction permissions are split from their related model Policy (mitigated by keeping them under
Policies/) - Actions that enforce interaction permissions mix authorization and business logic in the same layer
Risks
- Escape hatch abuse — Developers might classify a model-based permission as "interaction-based" to avoid the route-level requirement. Mitigated by code review and keeping the
Interactions/directory small. - Over-classification — The temptation to move more checks into
Interactions/. Rule of thumb: if the check only needs User + Model, it's model-based regardless of complexity.
Implementation Notes
Implemented in PR #251 (2026-02-16). Key decisions made during implementation:
- Admin bypass: explicit per permission —
if ($user->role->isAdmin())in each permission class, not a globalGate::before()callback - Gate contract injection — Actions inject
Illuminate\Contracts\Auth\Access\Gate(not the Facade) to comply with architecture tests - Gate registration —
Gate::define('assignRole', [AssignRolePermission::class, 'authorize'])inAppServiceProvider::boot() - 8 architecture tests — Naming convention,
final readonly, strict types, no facades, no action/controller deps, route-level boundary exclusion, dead permission detector - PoliciesTest exclusion — Existing policy arch tests use
->ignoring('App\Policies\Interactions')to exclude interaction permissions from thePolicysuffix convention