Cache and Versioning
Redis permission cache, request memoization, role generation invalidation, token authz versions, and emergency deny lists.
Cache and Versioning
Permission resolution touches roles, role hierarchy, role permissions, and temporal grants. Vortos layers caching so normal requests do not repeatedly rebuild the same data.
Resolver chain
When Redis is available:
PermissionResolverInterface
-> RequestMemoizedPermissionResolver
-> CachedPermissionResolver
-> DatabasePermissionResolverWithout Redis:
PermissionResolverInterface
-> RequestMemoizedPermissionResolver
-> DatabasePermissionResolverRequest memoization
RequestMemoizedPermissionResolver keeps the resolved permissions for the current request. Multiple checks in one request reuse the same ResolvedPermissions object.
This is important when a controller, a query handler, and a serializer all ask about permissions.
Redis resolved-permission cache
CachedPermissionResolver stores resolved permissions under a hashed user key:
authorization:resolved_permissions:{sha256(userId)}Current behavior:
| Setting | Value |
|---|---|
| TTL | 60 seconds (configurable via $ttlSeconds constructor arg) |
| Rebuild lock TTL | 3000 ms |
| Lock retry | Up to 5 retries with 60–300 ms backoff; falls back to direct DB resolve |
| Payload | JSON-encoded ResolvedPermissions::toArray() plus role generation hash |
| Serialization | JSON only — no serialize()/unserialize() to eliminate PHP object injection risk |
Cache keys never include raw user IDs.
Role generation invalidation
When Redis is available, role permission storage is wrapped by GenerationalRolePermissionStore.
When a role's permissions change (grant or revoke), its generation counter is always incremented — atomically via a Lua script. Cached user permissions include a hash of the expanded role generations. If any role generation changed, the cache entry is stale even before the TTL expires.
This prevents users from keeping old role permissions after a role-level grant or revoke.
Generation counters are stored as individual Redis keys (authorization:role_gen:{role}) with a 24-hour TTL that resets on every mutation. Unused roles expire automatically — the store never grows without bound.
User authz version
User role changes are more direct. UserRoleAdminService does three things inside one transaction:
- Assigns or removes the user role.
- Increments the user's authorization version in Redis.
- Invalidates that user's resolved permission cache.
Authorization versions are stored as individual Redis keys (authorization:user_version:{sha256(userId)}) with a 30-day TTL that resets on every role change. Inactive users' keys expire automatically.
JWT access tokens include:
{
"authz_version": 4
}During authorization, PolicyEngine compares the token version against the runtime version from AuthorizationVersionStoreInterface. The version is read from the request context via RequestAuthzVersionProvider, which is populated by AuthMiddleware from the ValidatedToken returned by JwtService::validate(). In CLI context (auth explain/can commands) it is populated by AuthCommandIdentityFactory instead.
If the token value is older, the decision is:
stale_tokenThe user must refresh or reauthenticate to receive a token with the new version.
Configure version checks
Version checks are enabled by default.
use Vortos\Authorization\DependencyInjection\VortosAuthorizationConfig;
return static function (VortosAuthorizationConfig $config): void {
$config->authzVersionCheck(true);
};Only disable this in tests or special local workflows:
$config->authzVersionCheck(false);Emergency deny list
When Redis is available, Vortos registers RedisEmergencyDenyList. The policy engine checks it before permissions and policies.
If the user is denied, the decision reason is:
emergency_deniedUse this for incident response and immediate user lockout. It is checked on every authorization decision and is not bypassed by regular permissions.
Break-glass bypass
Break-glass is disabled by default. Enable it explicitly:
$config->breakGlassBypass(true);
$config->breakGlassRole('ROLE_SUPER_ADMIN');Break-glass only works for permissions marked bypassable in their catalog metadata:
public static function meta(): array
{
return [
self::ReadAny => self::bypassable(
'Read any order',
'Allows emergency read access during incidents.',
),
];
}It does not bypass unknown permissions, invalid permission formats, unauthenticated users, stale tokens, or emergency deny list checks.