Vortos
Authorization

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
  -> DatabasePermissionResolver

Without Redis:

PermissionResolverInterface
  -> RequestMemoizedPermissionResolver
  -> DatabasePermissionResolver

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

SettingValue
TTL60 seconds (configurable via $ttlSeconds constructor arg)
Rebuild lock TTL3000 ms
Lock retryUp to 5 retries with 60–300 ms backoff; falls back to direct DB resolve
PayloadJSON-encoded ResolvedPermissions::toArray() plus role generation hash
SerializationJSON 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:

  1. Assigns or removes the user role.
  2. Increments the user's authorization version in Redis.
  3. 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_token

The user must refresh or reauthenticate to receive a token with the new version.

Configure version checks

Version checks are enabled by default.

config/authorization.php
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_denied

Use 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/authorization.php
$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.

On this page