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.phpThe fluent config object is VortosAuthorizationConfig.
Main services
| Service | Responsibility |
|---|---|
RoleVoter | Expands and checks role hierarchy |
PermissionRegistry | Holds compile-time permission metadata |
PolicyRegistry | Holds discovered policies keyed by resource |
PolicyEngine | Central authorization decision engine |
AuthorizationMiddleware | Enforces #[RequiresPermission] on controllers |
ControllerPermissionMap | Compile-time map of controller requirements |
DatabasePermissionResolver | Builds permissions from roles and runtime tables |
RequestMemoizedPermissionResolver | Per-request resolver memoization |
CachedPermissionResolver | Redis cache for resolved permissions |
UserRoleAdminService | Mutates user roles with audit/version/cache side effects |
RolePermissionAdminService | Mutates role permissions with audit side effects |
Compiler passes
PermissionRegistryPass does two jobs:
- Discover permission catalogs and build
PermissionRegistry. - Scan
vortos.api.controllerservices for#[RequiresPermission]and buildControllerPermissionMap.
PolicyRegistryPass discovers #[AsPolicy] services and fills the policy service locator.
Ownership has its own compiler pass:
OwnershipCompilerPassIt 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): JsonResponseIt 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 defaultkernel.response:
AuditMiddleware priority 0 — records request + response statusAll 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:
- Parse
resource.action.scope. - Deny if the permission is not registered.
- Deny if the identity is unauthenticated.
- Deny if the emergency deny list blocks the user.
- Deny if
authz_versionis stale. - Allow break-glass bypass only if enabled and permission metadata says
bypassable. - Resolve permissions.
- Deny if the resolved permissions do not include the requested permission.
- Deny if scoped permission checks fail.
- Deny if no policy exists for the resource.
- Call the resource policy.
- 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:
| Interface | Implementation |
|---|---|
EmergencyDenyListInterface | RedisEmergencyDenyList |
AuthorizationVersionStoreInterface | RedisAuthorizationVersionStore |
ScopedPermissionStoreInterface | RedisScopedPermissionStore |
TemporalPermissionStoreInterface | RedisTemporalPermissionStore |
RolePermissionStoreInterface | GenerationalRolePermissionStore wrapping DBAL |
AuthorizationCacheInvalidatorInterface | CachedPermissionInvalidator |
Without Redis, the module falls back where possible:
| Interface | Implementation |
|---|---|
EmergencyDenyListInterface | NullEmergencyDenyList |
AuthorizationVersionStoreInterface | NullAuthorizationVersionStore |
ScopedPermissionStoreInterface | NullScopedPermissionStore |
RolePermissionStoreInterface | DbalRolePermissionStore |
AuthorizationCacheInvalidatorInterface | NullAuthorizationCacheInvalidator |
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 family | Examples |
|---|---|
| Decisions | authorization.decision |
| Resolver work | authorization.resolver.database, authorization.resolver.cache_hit, authorization.resolver.cache_miss |
| Admin mutations | authorization.admin.user_role.assign, authorization.admin.role_permission.grant |
User IDs are hashed before being added to span attributes.