fix(security): bearer token hardening follow-ups — nbf skew, TTL-0 path, cache docs, extra rejection tests (review 2026-10-21 05/U1, 05/P1 L1/L5/L6)
- JwtTokenService back-dates nbf by the 30 s validation skew so a peer central whose clock lags the issuer accepts a fresh token after failover. - RoleMappingCacheSeconds = 0 routes RoleMapper to the matched-rows loader (old cost) instead of loading every Deployer mapping's scope rules per call. - RoleMappingCache / SecurityOptions / Component-Security.md describe Invalidate() by its contract plus the TTL, not by a call site. - Tests: HS512 with the same key, issuer/audience mismatch, lagging-peer nbf, TTL-0 matched-only reads, refresh-vs-Invalidate generation race.
This commit is contained in:
@@ -29,13 +29,17 @@ public class RoleMappingCacheTests
|
||||
public List<LdapGroupMapping> Mappings { get; } = [];
|
||||
public Dictionary<int, IReadOnlyList<SiteScopeRule>> ScopeRules { get; } = [];
|
||||
public int MappingReads { get; private set; }
|
||||
public Func<Task>? BeforeMappingRead { get; set; }
|
||||
public int ScopeReads { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<LdapGroupMapping>> GetAllMappingsAsync(CancellationToken cancellationToken = default)
|
||||
public async Task<IReadOnlyList<LdapGroupMapping>> GetAllMappingsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
MappingReads++;
|
||||
return Task.FromResult<IReadOnlyList<LdapGroupMapping>>(Mappings.ToList());
|
||||
var snapshot = Mappings.ToList();
|
||||
if (BeforeMappingRead is not null)
|
||||
await BeforeMappingRead();
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -140,10 +144,13 @@ public class RoleMappingCacheTests
|
||||
Assert.Empty(after.Roles);
|
||||
}
|
||||
|
||||
/// <summary>A zero TTL disables caching entirely.</summary>
|
||||
/// <summary>
|
||||
/// A zero TTL disables caching entirely and reads only the MATCHED Deployer mappings' scope
|
||||
/// rules, not every Deployer mapping's (review L6).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ZeroTtl_DisablesCache()
|
||||
public async Task ZeroTtl_DisablesCache_AndReadsOnlyMatchedScopeRules()
|
||||
{
|
||||
var (repo, _, _, mapper) = Create(ttlSeconds: 0);
|
||||
|
||||
@@ -151,6 +158,41 @@ public class RoleMappingCacheTests
|
||||
await mapper.MapGroupsToRolesAsync(["Admins"]);
|
||||
|
||||
Assert.Equal(2, repo.MappingReads);
|
||||
Assert.Equal(0, repo.ScopeReads); // "Admins" matches no Deployer mapping
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A refresh that read the table before an <see cref="RoleMappingCache.Invalidate"/> is not
|
||||
/// published over it: the next mapping re-reads (review L5).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RefreshRacingInvalidate_IsNotPublished()
|
||||
{
|
||||
var (repo, _, cache, mapper) = Create();
|
||||
var readStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
repo.BeforeMappingRead = async () =>
|
||||
{
|
||||
readStarted.TrySetResult();
|
||||
await release.Task;
|
||||
};
|
||||
|
||||
// Refresh #1 snapshots the table WITH the Admins mapping, then stalls.
|
||||
var inFlight = mapper.MapGroupsToRolesAsync(["Admins"]);
|
||||
await readStarted.Task;
|
||||
repo.BeforeMappingRead = null;
|
||||
|
||||
// The mapping is revoked and the cache invalidated while #1 is still in flight.
|
||||
repo.Mappings.RemoveAll(m => m.Id == 1);
|
||||
cache.Invalidate();
|
||||
release.SetResult();
|
||||
Assert.Contains(Roles.Administrator, (await inFlight).Roles); // #1 answers its own caller
|
||||
|
||||
var after = await mapper.MapGroupsToRolesAsync(["Admins"]);
|
||||
|
||||
Assert.Empty(after.Roles); // the stale snapshot was never published
|
||||
Assert.Equal(2, repo.MappingReads);
|
||||
}
|
||||
|
||||
/// <summary>The cache is shared by mappers built over different (scoped) repositories.</summary>
|
||||
|
||||
Reference in New Issue
Block a user