< Summary

Information
Class: AsiBackbone.Storage.InMemory.CapabilityGrants.InMemoryCapabilityGrantUseStore
Assembly: AsiBackbone.Storage.InMemory
File(s): /home/runner/work/AsiBackbone/AsiBackbone/src/AsiBackbone.Storage.InMemory/CapabilityGrants/InMemoryCapabilityGrantUseStore.cs
Line coverage
100%
Covered lines: 97
Uncovered lines: 0
Coverable lines: 97
Total lines: 332
Line coverage: 100%
Branch coverage
97%
Covered branches: 35
Total branches: 36
Branch coverage: 97.2%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor()100%11100%
get_EvictionGracePeriod()100%11100%
set_EvictionGracePeriod(...)100%11100%
GetUseCount(...)100%44100%
GetUseCount(...)100%22100%
StopGrant(...)100%11100%
StopGrant(...)100%11100%
CancelGrant(...)100%11100%
CancelGrant(...)100%11100%
Clear()100%11100%
TryConsumeAsync(...)100%1414100%
NormalizeGrantId(...)100%11100%
CreateKey(...)100%11100%
SplitTokenId(...)50%22100%
AdvanceRetentionThreshold(...)100%44100%
EvictExpiredEntries(...)100%1010100%

File(s)

/home/runner/work/AsiBackbone/AsiBackbone/src/AsiBackbone.Storage.InMemory/CapabilityGrants/InMemoryCapabilityGrantUseStore.cs

#LineLine coverage
 1using AsiBackbone.Core.CapabilityGrants;
 2
 3namespace AsiBackbone.Storage.InMemory.CapabilityGrants;
 4
 5/// <summary>
 6/// Provides a non-durable, in-process capability grant use store for tests, samples, and local validation.
 7/// </summary>
 8/// <remarks>
 9/// <para>
 10/// This store is thread-safe within a single process, but it is not durable, distributed, replicated, or suitable for
 11/// production replay protection. Hosts that require production single-use or bounded-use guarantees should provide a
 12/// durable implementation of <see cref="ICapabilityGrantUseStore" /> with documented transaction, locking, retention,
 13/// and failure semantics.
 14/// </para>
 15/// <para>
 16/// Use records are retained until a grant has been expired for longer than <see cref="EvictionGracePeriod" />, measured
 17/// against the latest use time this store has observed. A grant past that retention horizon is refused with
 18/// <c>capability.use-retention-elapsed</c> rather than given a fresh count, because its earlier uses may already have
 19/// been evicted. Set <see cref="EvictionGracePeriod" /> to at least the largest
 20/// <see cref="CapabilityGrantValidationOptions.AllowedClockSkew" /> any validator uses with this store; otherwise grant
 21/// that are expired but still inside the validator's skew are denied (fail closed) instead of accepted.
 22/// </para>
 23/// </remarks>
 24public sealed class InMemoryCapabilityGrantUseStore : ICapabilityGrantUseStore
 25{
 26    /// <summary>
 27    /// Separates issuer from token identifier in a use-record key, using a control character that cannot occur in eithe
 28    /// </summary>
 29    private const string KeySeparator = "\u001F";
 30
 1731    private readonly Lock syncRoot = new();
 1732    private readonly Dictionary<string, GrantUseEntry> useCounts = new(StringComparer.Ordinal);
 1733    private readonly HashSet<string> stoppedGrantKeys = new(StringComparer.Ordinal);
 1734    private readonly HashSet<string> cancelledGrantKeys = new(StringComparer.Ordinal);
 1735    private readonly HashSet<string> stoppedGrantIdsForAllIssuers = new(StringComparer.Ordinal);
 1736    private readonly HashSet<string> cancelledGrantIdsForAllIssuers = new(StringComparer.Ordinal);
 1737    private TimeSpan evictionGracePeriod = TimeSpan.FromMinutes(5);
 38    private DateTimeOffset? latestObservedUseUtc;
 39
 40    /// <summary>
 41    /// Gets or sets the grace period retained after a grant expires before its use record may be evicted.
 42    /// </summary>
 43    /// <remarks>
 44    /// Defaults to five minutes. This is also the retention horizon: a grant expired for longer than this period is ref
 45    /// rather than consumed. Set it to at least the largest <see cref="CapabilityGrantValidationOptions.AllowedClockSke
 46    /// used with this store.
 47    /// </remarks>
 48    /// <exception cref="ArgumentOutOfRangeException">The value is negative.</exception>
 49    public TimeSpan EvictionGracePeriod
 50    {
 51        get
 152        {
 53            lock (syncRoot)
 54            {
 155                return evictionGracePeriod;
 56            }
 157        }
 58
 59        set
 60        {
 661            ArgumentOutOfRangeException.ThrowIfLessThan(value, TimeSpan.Zero);
 62
 63            lock (syncRoot)
 64            {
 565                evictionGracePeriod = value;
 566            }
 567        }
 68    }
 69
 70    private sealed record GrantUseEntry(int Count, DateTimeOffset ExpiresUtc);
 71
 72    /// <summary>
 73    /// Gets the observed use count for a grant identifier, across every issuer that used it.
 74    /// </summary>
 75    /// <param name="grantId">The stable capability grant identifier.</param>
 76    /// <returns>The observed use count, or zero when the grant has not been consumed by this store instance.</returns>
 77    /// <remarks>
 78    /// Use records are keyed by issuer and token identifier, so this overload sums the issuers that used the identifier
 79    /// Call <see cref="GetUseCount(string, string)" /> to read one issuer's count.
 80    /// </remarks>
 81    public int GetUseCount(string grantId)
 82    {
 483        string normalizedGrantId = NormalizeGrantId(grantId);
 84
 85        lock (syncRoot)
 86        {
 487            int total = 0;
 88
 2289            foreach (KeyValuePair<string, GrantUseEntry> item in useCounts)
 90            {
 791                if (string.Equals(SplitTokenId(item.Key), normalizedGrantId, StringComparison.Ordinal))
 92                {
 593                    total += item.Value.Count;
 94                }
 95            }
 96
 497            return total;
 98        }
 499    }
 100
 101    /// <summary>
 102    /// Gets the observed use count for one issuer's grant identifier.
 103    /// </summary>
 104    /// <param name="issuer">The grant issuer.</param>
 105    /// <param name="grantId">The stable capability grant identifier.</param>
 106    /// <returns>The observed use count, or zero when that issuer's grant has not been consumed by this store instance.<
 107    public int GetUseCount(string issuer, string grantId)
 108    {
 5109        string key = CreateKey(issuer, NormalizeGrantId(grantId));
 110
 111        lock (syncRoot)
 112        {
 5113            return useCounts.TryGetValue(key, out GrantUseEntry? entry) ? entry.Count : 0;
 114        }
 5115    }
 116
 117    /// <summary>
 118    /// Marks a grant identifier as stopped for every issuer, for subsequent local validation attempts.
 119    /// </summary>
 120    /// <param name="grantId">The stable capability grant identifier.</param>
 121    /// <remarks>
 122    /// Use records are keyed by issuer and token identifier, so this overload stops every issuer's grant that uses the
 123    /// identifier. Call <see cref="StopGrant(string, string)" /> to stop one issuer's grant.
 124    /// </remarks>
 125    public void StopGrant(string grantId)
 126    {
 2127        string normalizedGrantId = NormalizeGrantId(grantId);
 128
 129        lock (syncRoot)
 130        {
 2131            _ = stoppedGrantIdsForAllIssuers.Add(normalizedGrantId);
 2132            _ = cancelledGrantIdsForAllIssuers.Remove(normalizedGrantId);
 2133        }
 2134    }
 135
 136    /// <summary>
 137    /// Marks one issuer's grant as stopped for subsequent local validation attempts.
 138    /// </summary>
 139    /// <param name="issuer">The grant issuer.</param>
 140    /// <param name="grantId">The stable capability grant identifier.</param>
 141    public void StopGrant(string issuer, string grantId)
 142    {
 2143        string key = CreateKey(issuer, NormalizeGrantId(grantId));
 144
 145        lock (syncRoot)
 146        {
 2147            _ = stoppedGrantKeys.Add(key);
 2148            _ = cancelledGrantKeys.Remove(key);
 2149        }
 2150    }
 151
 152    /// <summary>
 153    /// Marks a grant identifier as cancelled for every issuer, for subsequent local validation attempts.
 154    /// </summary>
 155    /// <param name="grantId">The stable capability grant identifier.</param>
 156    /// <remarks>
 157    /// Use records are keyed by issuer and token identifier, so this overload cancels every issuer's grant that uses th
 158    /// identifier. Call <see cref="CancelGrant(string, string)" /> to cancel one issuer's grant.
 159    /// </remarks>
 160    public void CancelGrant(string grantId)
 161    {
 1162        string normalizedGrantId = NormalizeGrantId(grantId);
 163
 164        lock (syncRoot)
 165        {
 1166            _ = cancelledGrantIdsForAllIssuers.Add(normalizedGrantId);
 1167            _ = stoppedGrantIdsForAllIssuers.Remove(normalizedGrantId);
 1168        }
 1169    }
 170
 171    /// <summary>
 172    /// Marks one issuer's grant as cancelled for subsequent local validation attempts.
 173    /// </summary>
 174    /// <param name="issuer">The grant issuer.</param>
 175    /// <param name="grantId">The stable capability grant identifier.</param>
 176    public void CancelGrant(string issuer, string grantId)
 177    {
 2178        string key = CreateKey(issuer, NormalizeGrantId(grantId));
 179
 180        lock (syncRoot)
 181        {
 2182            _ = cancelledGrantKeys.Add(key);
 2183            _ = stoppedGrantKeys.Remove(key);
 2184        }
 2185    }
 186
 187    /// <summary>
 188    /// Clears use-count, stopped/cancelled, and observed-time state from this in-memory store instance.
 189    /// </summary>
 190    public void Clear()
 1191    {
 192        lock (syncRoot)
 193        {
 1194            useCounts.Clear();
 1195            stoppedGrantKeys.Clear();
 1196            cancelledGrantKeys.Clear();
 1197            stoppedGrantIdsForAllIssuers.Clear();
 1198            cancelledGrantIdsForAllIssuers.Clear();
 1199            latestObservedUseUtc = null;
 1200        }
 1201    }
 202
 203    /// <inheritdoc />
 204    public ValueTask<CapabilityGrantUseResult> TryConsumeAsync(
 205        CapabilityGrant grant,
 206        int maxUseCount,
 207        DateTimeOffset usedUtc,
 208        CancellationToken cancellationToken = default)
 209    {
 62210        ArgumentNullException.ThrowIfNull(grant);
 62211        ArgumentOutOfRangeException.ThrowIfLessThan(maxUseCount, 1);
 62212        cancellationToken.ThrowIfCancellationRequested();
 213
 214        // Stop and cancel state was keyed by token identifier alone while use counts were keyed by issuer and token, so
 215        // stopping one issuer's grant stopped every issuer's grant that shared the identifier.
 62216        string key = CreateKey(grant.Issuer, grant.TokenId);
 217
 218        lock (syncRoot)
 219        {
 62220            if (stoppedGrantKeys.Contains(key) || stoppedGrantIdsForAllIssuers.Contains(grant.TokenId))
 221            {
 4222                return ValueTask.FromResult(CapabilityGrantUseResult.Stopped("The in-memory capability grant use store m
 223            }
 224
 58225            if (cancelledGrantKeys.Contains(key) || cancelledGrantIdsForAllIssuers.Contains(grant.TokenId))
 226            {
 3227                return ValueTask.FromResult(CapabilityGrantUseResult.Cancelled("The in-memory capability grant use store
 228            }
 229
 55230            DateTimeOffset retentionThreshold = AdvanceRetentionThreshold(usedUtc);
 55231            EvictExpiredEntries(retentionThreshold);
 232
 233            // Eviction previously ran against the grace period alone, while the validator accepts an expired grant for 
 234            // long as its clock skew allows. With skew above the grace period, a grant's record was evicted while the g
 235            // still validated, so the next use started a fresh count: a replay. A grant past the retention horizon may
 236            // already have lost its record, so it is refused instead. The horizon only moves forward, so a record is ne
 237            // evicted while a grant it describes can still be accepted.
 55238            if (grant.ExpiresUtc < retentionThreshold)
 239            {
 3240                return ValueTask.FromResult(CapabilityGrantUseResult.RetentionElapsed(
 3241                    "The grant is past the in-memory use store's retention horizon, so its earlier uses can no longer be
 242            }
 243
 52244            _ = useCounts.TryGetValue(key, out GrantUseEntry? entry);
 52245            int currentCount = entry?.Count ?? 0;
 246
 52247            if (currentCount >= maxUseCount)
 248            {
 34249                return ValueTask.FromResult(CapabilityGrantUseResult.UseLimitExceeded(
 34250                    currentCount,
 34251                    "The in-memory capability grant use limit was exceeded."));
 252            }
 253
 18254            int nextCount = currentCount + 1;
 18255            useCounts[key] = new GrantUseEntry(nextCount, grant.ExpiresUtc);
 18256            return ValueTask.FromResult(CapabilityGrantUseResult.Accepted(nextCount));
 257        }
 62258    }
 259
 260    private static string NormalizeGrantId(string grantId)
 261    {
 16262        ArgumentException.ThrowIfNullOrWhiteSpace(grantId);
 16263        return grantId.Trim();
 264    }
 265
 266    /// <summary>
 267    /// Builds the issuer-scoped key for a grant use record.
 268    /// </summary>
 269    private static string CreateKey(string issuer, string grantId)
 270    {
 71271        ArgumentException.ThrowIfNullOrWhiteSpace(issuer);
 272
 71273        return string.Concat(issuer.Trim(), KeySeparator, grantId);
 274    }
 275
 276    private static string SplitTokenId(string key)
 277    {
 7278        int separatorIndex = key.IndexOf(KeySeparator, StringComparison.Ordinal);
 279
 7280        return separatorIndex < 0 ? key : key[(separatorIndex + 1)..];
 281    }
 282
 283    /// <summary>
 284    /// Records the use time and returns the retention threshold measured from the latest use time observed so far.
 285    /// </summary>
 286    /// <remarks>
 287    /// Use times come from the caller, and validators may supply a fixed validation time. Measuring from each call's ow
 288    /// time let a later call with an earlier time find a record that an earlier call had already evicted, and start a f
 289    /// count. Measuring from the latest observed time keeps the threshold monotonic. Must be called while holding the l
 290    /// </remarks>
 291    private DateTimeOffset AdvanceRetentionThreshold(DateTimeOffset usedUtc)
 292    {
 55293        DateTimeOffset normalizedUsedUtc = usedUtc.ToUniversalTime();
 55294        DateTimeOffset latest = latestObservedUseUtc is { } observed && observed > normalizedUsedUtc
 55295            ? observed
 55296            : normalizedUsedUtc;
 297
 55298        latestObservedUseUtc = latest;
 55299        return latest - evictionGracePeriod;
 300    }
 301
 302    /// <summary>
 303    /// Removes use records for grants that expired before the retention threshold.
 304    /// </summary>
 305    /// <remarks>
 306    /// Every consumed token identifier was previously retained for the lifetime of the process, so a long-running host
 307    /// accumulated a record per grant it ever validated with no way to reclaim the memory short of clearing the store.
 308    /// Must be called while holding the lock.
 309    /// </remarks>
 310    private void EvictExpiredEntries(DateTimeOffset retentionThreshold)
 311    {
 55312        List<string>? expiredKeys = null;
 313
 196314        foreach (KeyValuePair<string, GrantUseEntry> item in useCounts)
 315        {
 43316            if (item.Value.ExpiresUtc < retentionThreshold)
 317            {
 4318                (expiredKeys ??= []).Add(item.Key);
 319            }
 320        }
 321
 55322        if (expiredKeys is null)
 323        {
 51324            return;
 325        }
 326
 16327        foreach (string key in expiredKeys)
 328        {
 4329            _ = useCounts.Remove(key);
 330        }
 4331    }
 332}