Vortos
Authorization

Time-Limited Access

Grant permissions that expire automatically — beta features, trial access, temporary admin rights. Redis TTL handles cleanup with no cron job needed.

Time-Limited Access

Temporal permissions grant a user access to something for a defined period. When the period expires, access is revoked automatically via Redis TTL — no cron job, no cleanup worker needed.

Use cases:

  • Beta feature access for selected users
  • Free trial of premium features
  • Temporary admin access for an investigation
  • Time-boxed contractor access

Grant Access

Inject TemporalAuthorizationManager in your command handlers:

use Vortos\Authorization\Temporal\TemporalAuthorizationManager;

final class ActivateBetaAccessHandler
{
    public function __construct(
        private TemporalAuthorizationManager $authorization,
    ) {}

    public function __invoke(ActivateBetaAccess $command): void
    {
        // Grant for 30 days
        $this->authorization
            ->grant($command->userId, 'beta.analytics_v2')
            ->forDays(30);

        // Grant for 24 hours
        $this->authorization
            ->grant($command->userId, 'beta.ai_suggestions')
            ->forHours(24);

        // Grant until a specific date
        $this->authorization
            ->grant($command->userId, 'feature.early_access')
            ->until(new \DateTimeImmutable('2026-12-31'));
    }
}

Revoke Early

// Revoke before expiry (e.g., user cancelled beta)
$this->authorization->revoke($command->userId, 'beta.analytics_v2');

Check Validity

// Check if access is currently valid
$isValid = $this->authorization->isValid($userId, 'beta.analytics_v2');

// Get expiry date (null if not granted)
$expiry = $this->authorization->getExpiry($userId, 'beta.analytics_v2');

Protect a Route

Use #[RequiresPermission] — the standard authorization system checks temporal permissions the same way it checks all other permissions:

use Vortos\Authorization\Attribute\RequiresPermission;

// This endpoint is only accessible if the user has been granted 'beta.analytics_v2'
#[RequiresPermission('beta.analytics_v2.any')]
final class BetaAnalyticsController { ... }

For this to work, your policy must handle the permission:

#[AsPolicy(resource: 'beta')]
final class BetaPolicy implements PolicyInterface
{
    public function __construct(
        private TemporalAuthorizationManager $temporal,
    ) {}

    public function can(
        UserIdentityInterface $identity,
        string $action,
        string $scope,
        mixed $resource = null,
    ): bool {
        // action is the second segment: 'analytics_v2', 'ai_suggestions', etc.
        $permission = "beta.{$action}";

        return $this->temporal->isValid($identity->id(), $permission);
    }
}

Using Enums

enum BetaFeature: string {
    case AnalyticsV2    = 'beta.analytics_v2';
    case AiSuggestions  = 'beta.ai_suggestions';
    case EarlyAccess    = 'feature.early_access';
}

// Grant
$this->authorization->grant($userId, BetaFeature::AnalyticsV2)->forDays(30);

// Revoke
$this->authorization->revoke($userId, BetaFeature::AnalyticsV2);

// Check
$isValid = $this->authorization->isValid($userId, BetaFeature::AnalyticsV2);

Storage

Temporal permissions are stored in Redis:

Key:   temporal_perm:{userId}:{permission}
Value: {"expires_at": 1719532800}
TTL:   seconds until expiry

Index: temporal_perms:{userId}   ← set of active permission names
TTL:   extended to the longest active grant, never shrunk

Redis auto-expires each permission key when its TTL reaches zero. No cleanup job is needed. The isValid() check calls Redis::exists() — if the key is gone, access is denied.

The index set is used by activeGrantsForUser() to enumerate all grants for a user in one round-trip. Existence for each member is batch-checked via a pipeline — no N+1 Redis calls. Expired entries are lazily removed from the index in the same pass.

The index TTL is only ever extended when a new grant is longer-lived than the current TTL — adding a short-lived grant never shrinks the index TTL and never causes longer-lived grants to disappear from enumeration.

Redis Restart and Temporal Permissions

Temporal permissions are stored only in Redis. A Redis restart clears all grants. For production, consider storing grants in a database as the source of truth and using Redis as a cache:

final class DbTemporalPermissionStore implements TemporalPermissionStoreInterface
{
    public function grant(string $userId, string $permission, \DateTimeImmutable $expiresAt): void
    {
        // Write to DB
        $this->db->insert('temporal_permissions', [
            'user_id'    => $userId,
            'permission' => $permission,
            'expires_at' => $expiresAt->format('Y-m-d H:i:s'),
        ]);

        // Also cache in Redis for fast reads
        $ttl = $expiresAt->getTimestamp() - time();
        if ($ttl > 0) {
            $this->redis->setEx("temporal_perm:{$userId}:{$permission}", $ttl,
                json_encode(['expires_at' => $expiresAt->getTimestamp()]));
        }
    }

    public function isValid(string $userId, string $permission): bool
    {
        // Check Redis first (fast)
        if ($this->redis->exists("temporal_perm:{$userId}:{$permission}")) {
            return true;
        }

        // Fall back to DB
        return (bool) $this->db->fetchOne(
            'SELECT 1 FROM temporal_permissions WHERE user_id = ? AND permission = ? AND expires_at > NOW()',
            [$userId, $permission]
        );
    }
}

Grant Builder Reference

$this->authorization->grant($userId, 'permission')

    ->forDays(30)              // Grant for 30 days from now
    ->forHours(24)             // Grant for 24 hours from now
    ->until($dateTimeImmutable) // Grant until a specific datetime

All three methods call TemporalPermissionStoreInterface::grant() with the computed expiry and return void.

On this page