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 shrunkRedis 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 datetimeAll three methods call TemporalPermissionStoreInterface::grant() with the computed expiry and return void.