ADR-0006: Two-Tier Authorization Model
Accepted KendoDate: 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
Phase 1: Two-Tier Separation (PR #251, 2026-02-16)
Implemented the original two-tier split. Key decisions:
- 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
Phase 2: RBAC Permission System (PR #656, 2026-03)
Replaced the static UserRoleEnum-based model with a granular role-based access control system. The two-tier structure from Phase 1 is preserved — Phase 2 changes how Tier 1 permissions are resolved, not where they are enforced.
Permission Matrix
16 resources × 4 actions, stored in role_permissions table:
| # | Resource | Scope | # | Resource | Scope |
|---|---|---|---|---|---|
| 0 | Projects | Project | 8 | TimeEntries | Project |
| 1 | Sprints | Project | 9 | Reports | Project |
| 2 | Epics | Project | 10 | SystemPrompts | Project |
| 3 | Lanes | Project | 11 | ProjectTokens | Project |
| 4 | Issues | Project | 12 | Users | Tenant |
| 5 | Comments | Project | 13 | Roles | Tenant |
| 6 | Attachments | Project | 14 | Teams | Tenant |
| 7 | IssueBranchLinks | Project | 15 | AppSettings | Tenant |
Actions: Create (bool), Read (bool), Update (scope), Delete (scope). Scopes (Update/Delete only): None (0) / Own (1) / All (2). Own verifies $ownerId === $user->id.
CheckPermission Resolution Algorithm
CheckPermission (app/Policies/CheckPermission.php) is the shared permission resolution engine injected via constructor DI into all 17 policies. It is not an interaction permission — it lives alongside policies in app/Policies/, not in Interactions/.
6-step algorithm:
- Admin bypass — if any of the user's roles has
is_admin, grant access - No roles — if user has zero roles, deny
- Owner bypass — if resource is project-scoped AND user owns the project, grant access
- Project access — verify user can see the project via
access_all_projectsrole flag, team membership, or directproject_usermembership - Permission union — iterate all user roles, take most permissive grant for each action (booleans OR, scopes take highest)
- Scope check — for Update/Delete with Own scope, verify ownership
Multi-Role Model
Users can have multiple roles. Permissions are unioned across all roles — the most permissive grant wins. This replaces the single UserRoleEnum from Phase 1.
Bypass Rules
| Bypass | Trigger | Scope | Implementation |
|---|---|---|---|
| Admin | is_admin on any role | All resources | Policy before() returns true |
| Project Owner | project->user_id === user->id | 12 project-scoped resources | CheckPermission::check() step 3 |
Admin bypass occurs in policy before() hooks, preventing the request from reaching CheckPermission::check(). The algorithm also contains its own admin bypass (step 1) for cases where check() is called directly (e.g., from AssignRolePermission).
Policy Structure (17 Policies)
All 17 policies follow an identical pattern:
final readonly class {Name}Policy
{
public function __construct(private CheckPermission $checkPermission) {}
public function before(User $user): ?bool
{
if ($user->isAdmin()) { return true; }
return null;
}
public function ability(User $user, Model $model, Project $project): bool
{
return $this->checkPermission->check(
$user, PermissionResourceEnum::..., PermissionActionEnum::...,
$model->user_id, $project,
);
}
}Special cases:
NotificationPolicy— ownership-only ($user->id === $notification->user_id), noCheckPermissionTeamPolicy— requires both permission check AND team membership for update/delete/manageMemberProjectPolicy::transferOwnership()— owner-only check (admin bypass handled bybefore())ProjectPolicy::access()— delegates tohasProjectAccess()(project visibility, not resource permission)AppSettingPolicy::view()— always returnstrue(required for 2FA enforcement middleware)
Frontend Alignment
The frontend mirrors the backend exactly via usePermission() composable:
can(resource, action, ownerId?)— role permission checkcanInProject(resource, action, projectOwnerId, entityOwnerId?)— adds owner bypassisAdmin— computed from any role'sis_admin
Enum values (0–15 resources, 0–3 actions, 0–2 scopes) are identical between frontend and backend.
Implementation
| Territory | State | Notes |
|---|---|---|
| kendo | Complete | Phase 1: PR #251. Phase 2: PR #656. 17 model-based Policies + 1 interaction permission (AssignRolePermission). 8 architecture tests enforce interaction boundary. CheckPermission resolution engine in app/Policies/. |