Skip to content

ADR-0006: Two-Tier Authorization Model

Accepted Kendo

Date: 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:

  1. Model-based — "Can this user access/modify this resource?" Determined entirely by the user and the target model. All inputs available from route bindings.
  2. 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 PermissionsInteraction-Based Permissions
QuestionCan this user access/modify this resource?Can this user perform this specific operation in this context?
InputsUser + Model (route bindings)User + Model + runtime context (request body, business state)
Locationapp/Policies/{Model}Policy.phpapp/Policies/Interactions/{Name}Permission.php
EnforcementRoute-level ->can() middlewareAction-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 + role

How 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:

php
// 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:

php
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():

php
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:

php
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

OptionVerdictReason
Enforce all permissions at route level (status quo)RejectedForces unnatural patterns. Route middleware can't access request body data. This is exactly what caused the gap.
Enforce all permissions in ActionsRejectedDelays 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-basedAcceptedEach 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 permissionif ($user->role->isAdmin()) in each permission class, not a global Gate::before() callback
  • Gate contract injection — Actions inject Illuminate\Contracts\Auth\Access\Gate (not the Facade) to comply with architecture tests
  • Gate registrationGate::define('assignRole', [AssignRolePermission::class, 'authorize']) in AppServiceProvider::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 the Policy suffix 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:

#ResourceScope#ResourceScope
0ProjectsProject8TimeEntriesProject
1SprintsProject9ReportsProject
2EpicsProject10SystemPromptsProject
3LanesProject11ProjectTokensProject
4IssuesProject12UsersTenant
5CommentsProject13RolesTenant
6AttachmentsProject14TeamsTenant
7IssueBranchLinksProject15AppSettingsTenant

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:

  1. Admin bypass — if any of the user's roles has is_admin, grant access
  2. No roles — if user has zero roles, deny
  3. Owner bypass — if resource is project-scoped AND user owns the project, grant access
  4. Project access — verify user can see the project via access_all_projects role flag, team membership, or direct project_user membership
  5. Permission union — iterate all user roles, take most permissive grant for each action (booleans OR, scopes take highest)
  6. 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

BypassTriggerScopeImplementation
Adminis_admin on any roleAll resourcesPolicy before() returns true
Project Ownerproject->user_id === user->id12 project-scoped resourcesCheckPermission::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:

php
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), no CheckPermission
  • TeamPolicy — requires both permission check AND team membership for update/delete/manageMember
  • ProjectPolicy::transferOwnership() — owner-only check (admin bypass handled by before())
  • ProjectPolicy::access() — delegates to hasProjectAccess() (project visibility, not resource permission)
  • AppSettingPolicy::view() — always returns true (required for 2FA enforcement middleware)

Frontend Alignment

The frontend mirrors the backend exactly via usePermission() composable:

  • can(resource, action, ownerId?) — role permission check
  • canInProject(resource, action, projectOwnerId, entityOwnerId?) — adds owner bypass
  • isAdmin — computed from any role's is_admin

Enum values (0–15 resources, 0–3 actions, 0–2 scopes) are identical between frontend and backend.

Implementation

TerritoryStateNotes
kendoCompletePhase 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/.

Architecture documentation for contributors and collaborators.