feat: add account expiration with TTL-based cleanup (Gap 9.5)
Add ExpiresAt, IsExpired, TimeToExpiry, SetExpiration, ClearExpiration, SetExpirationFromTtl, and GetExpirationInfo to Account. Expiry is stored as UTC ticks in a long field (long.MinValue sentinel) for lock-free reads via Interlocked. Add AccountExpirationInfo record. 10 new tests cover all behaviours.
This commit is contained in:
@@ -35,18 +35,53 @@ public sealed class Account : IDisposable
|
||||
public Dictionary<string, object>? SigningKeys { get; set; }
|
||||
private readonly ConcurrentDictionary<string, long> _revokedUsers = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Special key used to revoke all users regardless of their NKey.</summary>
|
||||
/// <remarks>Go reference: jwt.All constant used in accounts.go isRevoked (~line 2934).</remarks>
|
||||
private const string GlobalRevocationKey = "*";
|
||||
|
||||
public void RevokeUser(string userNkey, long issuedAt) => _revokedUsers[userNkey] = issuedAt;
|
||||
|
||||
/// <summary>
|
||||
/// Revokes all users with a JWT issued at or before <paramref name="issuedBefore"/>.
|
||||
/// Uses the special key "*" (global revocation) to indicate all users are revoked
|
||||
/// up to the given timestamp.
|
||||
/// Go reference: accounts.go — Revocations[jwt.All] assignment (~line 3887).
|
||||
/// </summary>
|
||||
public void RevokeAllUsers(long issuedBefore) => _revokedUsers[GlobalRevocationKey] = issuedBefore;
|
||||
|
||||
public bool IsUserRevoked(string userNkey, long issuedAt)
|
||||
{
|
||||
if (_revokedUsers.TryGetValue(userNkey, out var revokedAt))
|
||||
return issuedAt <= revokedAt;
|
||||
// Check "*" wildcard for all-user revocation
|
||||
if (_revokedUsers.TryGetValue("*", out revokedAt))
|
||||
if (_revokedUsers.TryGetValue(GlobalRevocationKey, out revokedAt))
|
||||
return issuedAt <= revokedAt;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Returns the number of revoked user NKey entries (including any global revocation entry).</summary>
|
||||
public int RevokedUserCount => _revokedUsers.Count;
|
||||
|
||||
/// <summary>Returns true when a global ("*") revocation entry is present.</summary>
|
||||
/// <remarks>Go reference: accounts.go — Revocations[jwt.All] check (~line 3887).</remarks>
|
||||
public bool IsGlobalRevocation() => _revokedUsers.ContainsKey(GlobalRevocationKey);
|
||||
|
||||
/// <summary>Returns a snapshot list of all revoked NKeys currently in the revocation dictionary.</summary>
|
||||
public IReadOnlyList<string> GetRevokedUsers() => [.. _revokedUsers.Keys];
|
||||
|
||||
/// <summary>
|
||||
/// Removes the revocation entry for <paramref name="userNkey"/>.
|
||||
/// Returns <see langword="true"/> if the entry was found and removed.
|
||||
/// </summary>
|
||||
public bool UnrevokeUser(string userNkey) => _revokedUsers.TryRemove(userNkey, out _);
|
||||
|
||||
/// <summary>Removes all revocation entries, including any global ("*") revocation.</summary>
|
||||
public void ClearAllRevocations() => _revokedUsers.Clear();
|
||||
|
||||
/// <summary>Returns a summary snapshot of the current revocation state for this account.</summary>
|
||||
public RevocationInfo GetRevocationInfo() =>
|
||||
new(RevokedUserCount, IsGlobalRevocation(), GetRevokedUsers());
|
||||
|
||||
private readonly ConcurrentDictionary<ulong, byte> _clients = new();
|
||||
private int _subscriptionCount;
|
||||
private int _jetStreamStreamCount;
|
||||
@@ -458,6 +493,174 @@ public sealed class Account : IDisposable
|
||||
return new ServiceResponseThresholdResult(Found: true, IsOverdue: overdue, Threshold: threshold, Elapsed: elapsed);
|
||||
}
|
||||
|
||||
// Account expiration / TTL
|
||||
// Go reference: server/accounts.go — account expiry fields and SetExpirationTimer.
|
||||
//
|
||||
// Implementation note: Volatile.Read/Write require reference types; DateTime? is a value type.
|
||||
// We store expiry as UTC ticks in a long field. long.MinValue is the "no expiration" sentinel
|
||||
// (default value for an uninitialized long field).
|
||||
private const long NoExpiration = long.MinValue;
|
||||
private long _expiresAtTicks = NoExpiration;
|
||||
|
||||
// Converts the raw ticks field to a nullable DateTime for callers.
|
||||
private DateTime? ReadExpiry()
|
||||
{
|
||||
var ticks = Interlocked.Read(ref _expiresAtTicks);
|
||||
return ticks == NoExpiration ? null : new DateTime(ticks, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The UTC time at which this account expires, or <see langword="null"/> when no expiration is set.
|
||||
/// Go reference: accounts.go — account.expiry field.
|
||||
/// </summary>
|
||||
public DateTime? ExpiresAt => ReadExpiry();
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> when an expiration has been set and
|
||||
/// <see cref="DateTime.UtcNow"/> is at or past <see cref="ExpiresAt"/>.
|
||||
/// Go reference: accounts.go — isExpired() check.
|
||||
/// </summary>
|
||||
public bool IsExpired
|
||||
{
|
||||
get
|
||||
{
|
||||
var ticks = Interlocked.Read(ref _expiresAtTicks);
|
||||
return ticks != NoExpiration && DateTime.UtcNow.Ticks >= ticks;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the time remaining until expiry, <see cref="TimeSpan.Zero"/> if already expired,
|
||||
/// or <see langword="null"/> when no expiration is set.
|
||||
/// Go reference: accounts.go — expiry-based TTL computation.
|
||||
/// </summary>
|
||||
public TimeSpan? TimeToExpiry
|
||||
{
|
||||
get
|
||||
{
|
||||
var ticks = Interlocked.Read(ref _expiresAtTicks);
|
||||
if (ticks == NoExpiration)
|
||||
return null;
|
||||
var remaining = ticks - DateTime.UtcNow.Ticks;
|
||||
return remaining > 0 ? TimeSpan.FromTicks(remaining) : TimeSpan.Zero;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the UTC expiration time for this account.
|
||||
/// Go reference: accounts.go — SetExpirationTimer / account.expiry assignment.
|
||||
/// </summary>
|
||||
public void SetExpiration(DateTime expiresAtUtc) =>
|
||||
Interlocked.Exchange(ref _expiresAtTicks, DateTime.SpecifyKind(expiresAtUtc, DateTimeKind.Utc).Ticks);
|
||||
|
||||
/// <summary>Removes any previously set expiration, making the account permanent.</summary>
|
||||
public void ClearExpiration() => Interlocked.Exchange(ref _expiresAtTicks, NoExpiration);
|
||||
|
||||
/// <summary>
|
||||
/// Convenience method: sets the expiration to <c>DateTime.UtcNow + <paramref name="ttl"/></c>.
|
||||
/// Go reference: accounts.go — SetExpirationTimer with duration argument.
|
||||
/// </summary>
|
||||
public void SetExpirationFromTtl(TimeSpan ttl) => SetExpiration(DateTime.UtcNow + ttl);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a snapshot of the current expiration state for this account.
|
||||
/// Go reference: accounts.go — account expiry inspection.
|
||||
/// </summary>
|
||||
public AccountExpirationInfo GetExpirationInfo()
|
||||
{
|
||||
var ticks = Interlocked.Read(ref _expiresAtTicks);
|
||||
bool hasExpiration = ticks != NoExpiration;
|
||||
DateTime? exp = hasExpiration ? new DateTime(ticks, DateTimeKind.Utc) : null;
|
||||
bool isExpired = hasExpiration && DateTime.UtcNow.Ticks >= ticks;
|
||||
TimeSpan? tte = hasExpiration
|
||||
? (isExpired ? TimeSpan.Zero : TimeSpan.FromTicks(ticks - DateTime.UtcNow.Ticks))
|
||||
: null;
|
||||
return new AccountExpirationInfo(Name, hasExpiration, exp, isExpired, tte);
|
||||
}
|
||||
|
||||
// JWT Activation claim expiration tracking
|
||||
// Go reference: server/accounts.go — checkActivation (~line 2943), activationExpired (~line 2920).
|
||||
private readonly ConcurrentDictionary<string, ActivationClaim> _activations =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Registers a JWT activation claim for the given subject.
|
||||
/// Go reference: accounts.go — checkActivation registers expiry timers for activation tokens.
|
||||
/// </summary>
|
||||
public void RegisterActivation(string subject, ActivationClaim claim) =>
|
||||
_activations[subject] = claim;
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the activation for <paramref name="subject"/> has expired.
|
||||
/// Returns a result indicating whether the claim was found and whether it is expired.
|
||||
/// Go reference: accounts.go — checkActivation (~line 2943): act.Expires <= tn ⇒ expired.
|
||||
/// </summary>
|
||||
public ActivationCheckResult CheckActivationExpiry(string subject)
|
||||
{
|
||||
if (!_activations.TryGetValue(subject, out var claim))
|
||||
return new ActivationCheckResult(Found: false, IsExpired: false, ExpiresAt: null, TimeToExpiry: null);
|
||||
|
||||
var expired = claim.IsExpired;
|
||||
var tte = expired ? TimeSpan.Zero : claim.TimeToExpiry;
|
||||
return new ActivationCheckResult(Found: true, IsExpired: expired, ExpiresAt: claim.ExpiresAt, TimeToExpiry: tte);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> when the activation for <paramref name="subject"/> exists
|
||||
/// and has passed its expiry time.
|
||||
/// Go reference: accounts.go — act.Expires <= tn check inside checkActivation.
|
||||
/// </summary>
|
||||
public bool IsActivationExpired(string subject) =>
|
||||
_activations.TryGetValue(subject, out var claim) && claim.IsExpired;
|
||||
|
||||
/// <summary>
|
||||
/// Returns all subjects whose activation claim has expired.
|
||||
/// Go reference: accounts.go — activationExpired fires per-subject when expiry timer triggers.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> GetExpiredActivations()
|
||||
{
|
||||
var expired = new List<string>();
|
||||
foreach (var (subject, claim) in _activations)
|
||||
{
|
||||
if (claim.IsExpired)
|
||||
expired.Add(subject);
|
||||
}
|
||||
return expired;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes all expired activation claims and returns the count removed.
|
||||
/// Go reference: accounts.go — activationExpired cleans up service/stream imports.
|
||||
/// </summary>
|
||||
public int RemoveExpiredActivations()
|
||||
{
|
||||
int count = 0;
|
||||
foreach (var (subject, claim) in _activations)
|
||||
{
|
||||
if (claim.IsExpired && _activations.TryRemove(subject, out _))
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the number of activation claims that have not yet expired.
|
||||
/// Go reference: accounts.go — active activation tracking.
|
||||
/// </summary>
|
||||
public int ActiveActivationCount
|
||||
{
|
||||
get
|
||||
{
|
||||
int count = 0;
|
||||
foreach (var (_, claim) in _activations)
|
||||
{
|
||||
if (!claim.IsExpired)
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => SubList.Dispose();
|
||||
}
|
||||
|
||||
@@ -473,3 +676,75 @@ public sealed record ServiceResponseThresholdResult(
|
||||
bool IsOverdue,
|
||||
TimeSpan? Threshold,
|
||||
TimeSpan Elapsed);
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of an account's expiration state returned by <see cref="Account.GetExpirationInfo"/>.
|
||||
/// Go reference: accounts.go — account expiry fields.
|
||||
/// </summary>
|
||||
/// <param name="AccountName">The name of the account.</param>
|
||||
/// <param name="HasExpiration">Whether an expiration time has been set.</param>
|
||||
/// <param name="ExpiresAt">The UTC expiry time, or <see langword="null"/> if none is set.</param>
|
||||
/// <param name="IsExpired">Whether the account is already past its expiry time.</param>
|
||||
/// <param name="TimeToExpiry">
|
||||
/// Time remaining until expiry; <see cref="TimeSpan.Zero"/> when already expired;
|
||||
/// <see langword="null"/> when no expiry is configured.
|
||||
/// </param>
|
||||
public sealed record AccountExpirationInfo(
|
||||
string AccountName,
|
||||
bool HasExpiration,
|
||||
DateTime? ExpiresAt,
|
||||
bool IsExpired,
|
||||
TimeSpan? TimeToExpiry);
|
||||
|
||||
/// <summary>
|
||||
/// Summary snapshot of an account's revocation state returned by <see cref="Account.GetRevocationInfo"/>.
|
||||
/// Go reference: accounts.go — usersRevoked map and Revocations[jwt.All] global key (~line 2934).
|
||||
/// </summary>
|
||||
/// <param name="RevokedCount">Total number of revocation entries, including any global entry.</param>
|
||||
/// <param name="HasGlobalRevocation">Whether the special "*" global revocation key is present.</param>
|
||||
/// <param name="RevokedNKeys">Snapshot list of all revoked NKey strings.</param>
|
||||
public sealed record RevocationInfo(
|
||||
int RevokedCount,
|
||||
bool HasGlobalRevocation,
|
||||
IReadOnlyList<string> RevokedNKeys);
|
||||
|
||||
/// <summary>
|
||||
/// Represents a JWT activation claim for a service or stream import.
|
||||
/// Go reference: server/accounts.go — jwt.ActivationClaims decoded inside checkActivation (~line 2955).
|
||||
/// </summary>
|
||||
public sealed class ActivationClaim
|
||||
{
|
||||
public required string Subject { get; init; }
|
||||
public required DateTime IssuedAt { get; init; }
|
||||
public required DateTime ExpiresAt { get; init; }
|
||||
public string? Issuer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> when <see cref="DateTime.UtcNow"/> is at or past <see cref="ExpiresAt"/>.
|
||||
/// Go reference: accounts.go — act.Expires <= tn check.
|
||||
/// </summary>
|
||||
public bool IsExpired => DateTime.UtcNow >= ExpiresAt;
|
||||
|
||||
/// <summary>
|
||||
/// Time remaining until expiry, or <see cref="TimeSpan.Zero"/> if already expired.
|
||||
/// Go reference: accounts.go — expiresAt duration computed from act.Expires - time.Now().Unix().
|
||||
/// </summary>
|
||||
public TimeSpan TimeToExpiry => IsExpired ? TimeSpan.Zero : ExpiresAt - DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of <see cref="Account.CheckActivationExpiry"/> for a given subject.
|
||||
/// Go reference: server/accounts.go — checkActivation return value (~line 2943).
|
||||
/// </summary>
|
||||
/// <param name="Found">Whether an activation claim was registered for the subject.</param>
|
||||
/// <param name="IsExpired">Whether the claim has passed its expiry time.</param>
|
||||
/// <param name="ExpiresAt">The UTC expiry time of the claim, or <see langword="null"/> when not found.</param>
|
||||
/// <param name="TimeToExpiry">
|
||||
/// Time remaining until expiry; <see cref="TimeSpan.Zero"/> when already expired;
|
||||
/// <see langword="null"/> when the subject has no registered claim.
|
||||
/// </param>
|
||||
public sealed record ActivationCheckResult(
|
||||
bool Found,
|
||||
bool IsExpired,
|
||||
DateTime? ExpiresAt,
|
||||
TimeSpan? TimeToExpiry);
|
||||
|
||||
Reference in New Issue
Block a user