Vortos
Authorization

Internals

Compiler passes, DI wiring, resolver chain, middleware order, Redis fallbacks, and contributor notes for the authorization module.

Authorization Internals

This page is for contributors and future debugging. It explains how the authorization module is wired.

Package and extension

AuthorizationPackage registers AuthorizationExtension and compiler passes.

AuthorizationExtension reads:

config/authorization.php
config/{env}/authorization.php

The fluent config object is VortosAuthorizationConfig.

Main services

ServiceResponsibility
RoleVoterExpands and checks role hierarchy
PermissionRegistryHolds compile-time permission metadata
PolicyRegistryHolds discovered policies keyed by resource
PolicyEngineCentral authorization decision engine
AuthorizationMiddlewareEnforces #[RequiresPermission] on controllers
ControllerPermissionMapCompile-time map of controller requirements
DatabasePermissionResolverBuilds permissions from roles and runtime tables
RequestMemoizedPermissionResolverPer-request resolver memoization
CachedPermissionResolverRedis cache for resolved permissions
UserRoleAdminServiceMutates user roles with audit/version/cache side effects
RolePermissionAdminServiceMutates role permissions with audit side effects

Compiler passes

PermissionRegistryPass does two jobs:

  1. Discover permission catalogs and build PermissionRegistry.
  2. Scan vortos.api.controller services for #[RequiresPermission] and build ControllerPermissionMap.

PolicyRegistryPass discovers #[AsPolicy] services and fills the policy service locator.

Ownership has its own compiler pass:

OwnershipCompilerPass

It scans controllers for #[RequiresOwnership] and #[RequiresOwnershipOrPermission] at both class level and method level, and builds the route map and policy map for OwnershipMiddleware. Method-level keys use the ControllerClass::method format; class-level keys use just ControllerClass. Method-level takes precedence at runtime.

Controller permission map

Runtime request handling does not reflect controller attributes.

At compile time, PermissionRegistryPass scans controller classes and methods:

#[RequiresPermission('athletes.update.own', resourceParam: 'id')]
public function __invoke(string $id): JsonResponse

It stores:

[
    App\Athlete\Http\UpdateAthleteController::class . '::__invoke' => [
        [
            'permission' => 'athletes.update.own',
            'resourceParam' => 'id',
            'scope' => null,
            'scopeMode' => 'All',
        ],
    ],
]

At runtime, AuthorizationMiddleware only looks up the current controller in ControllerPermissionMap.

Request order

Full kernel.request priority chain (higher number fires first):

RouterListener              priority 32  — Symfony default
RateLimitIpGlobal           priority 7   — IP/Global scopes, before auth
AuthMiddleware              priority 6   — JWT decode, identity resolution
TwoFactorMiddleware         priority 5   — 2FA gate
RateLimitUser               priority 4   — User scope, after identity is set
AuthorizationMiddleware     priority 3   — permission checks
OwnershipMiddleware         priority 2   — resource ownership checks
FeatureAccessMiddleware     priority 1   — plan/feature gates
QuotaMiddleware             priority 0   — usage quota enforcement
ControllerResolver          priority -10 — Symfony default

kernel.response:

AuditMiddleware             priority 0   — records request + response status

All authorization-aware middleware fire after routing (so _controller is set) and after auth (so identity is available).

Policy engine decision order

PolicyEngine::decide() evaluates in this order:

  1. Parse resource.action.scope.
  2. Deny if the permission is not registered.
  3. Deny if the identity is unauthenticated.
  4. Deny if the emergency deny list blocks the user.
  5. Deny if authz_version is stale.
  6. Allow break-glass bypass only if enabled and permission metadata says bypassable.
  7. Resolve permissions.
  8. Deny if the resolved permissions do not include the requested permission.
  9. Deny if scoped permission checks fail.
  10. Deny if no policy exists for the resource.
  11. Call the resource policy.
  12. Allow only if the policy returns true.

The decision object contains:

$decision->allowed();
$decision->denied();
$decision->reason();
$decision->requiredPermission();

Redis and null fallbacks

When Redis exists, the module uses:

InterfaceImplementation
EmergencyDenyListInterfaceRedisEmergencyDenyList
AuthorizationVersionStoreInterfaceRedisAuthorizationVersionStore
ScopedPermissionStoreInterfaceRedisScopedPermissionStore
TemporalPermissionStoreInterfaceRedisTemporalPermissionStore
RolePermissionStoreInterfaceGenerationalRolePermissionStore wrapping DBAL
AuthorizationCacheInvalidatorInterfaceCachedPermissionInvalidator

Without Redis, the module falls back where possible:

InterfaceImplementation
EmergencyDenyListInterfaceNullEmergencyDenyList
AuthorizationVersionStoreInterfaceNullAuthorizationVersionStore
ScopedPermissionStoreInterfaceNullScopedPermissionStore
RolePermissionStoreInterfaceDbalRolePermissionStore
AuthorizationCacheInvalidatorInterfaceNullAuthorizationCacheInvalidator

Temporal authorization is only registered when Redis is available.

Tracing hooks

Authorization tracing is opt-in:

$config->traceDecisions(true);
$config->traceResolver(true);
$config->traceAdminMutations(true);

When tracing is enabled and the tracing module is installed, authorization emits spans for:

Span familyExamples
Decisionsauthorization.decision
Resolver workauthorization.resolver.database, authorization.resolver.cache_hit, authorization.resolver.cache_miss
Admin mutationsauthorization.admin.user_role.assign, authorization.admin.role_permission.grant

User IDs are hashed before being added to span attributes.

On this page