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:
Joseph Doherty
2026-10-22 08:25:20 -04:00
parent 1b3928a0a2
commit ea172edebc
7 changed files with 158 additions and 16 deletions
@@ -146,7 +146,10 @@ public class JwtTokenService
issuer: TokenIssuer,
audience: TokenAudience,
claims: claims,
notBefore: now,
// nbf is back-dated by the validation skew: a token minted on one central node and
// presented to its peer straight after a failover must not be refused because the
// peer's clock lags the issuer's.
notBefore: now - ValidationClockSkew,
expires: now.Add(TokenLifetime),
signingCredentials: credentials);
@@ -39,7 +39,9 @@ public class RoleMapper
IReadOnlyList<string> ldapGroups,
CancellationToken ct = default)
{
IReadOnlyList<RoleMappingCache.MappingRow> rows = _cache is not null
// A disabled cache (TTL 0) takes the matched-rows path: the snapshot loader would read
// the scope rules of EVERY Deployer mapping on each call.
IReadOnlyList<RoleMappingCache.MappingRow> rows = _cache is { Enabled: true }
? await _cache.GetAsync(_securityRepository, ct)
: await LoadMatchedRowsAsync(ldapGroups, ct);
@@ -98,7 +100,7 @@ public class RoleMapper
isSystemWide);
}
// Uncached path (no RoleMappingCache supplied): reads the table and the scope rules of the
// Uncached path (no RoleMappingCache supplied, or its TTL is 0): reads the table and the scope rules of the
// MATCHED Deployer mappings only, exactly as the mapper always did.
private async Task<IReadOnlyList<RoleMappingCache.MappingRow>> LoadMatchedRowsAsync(
IReadOnlyList<string> ldapGroups, CancellationToken ct)
@@ -11,8 +11,12 @@ namespace ZB.MOM.WW.ScadaBridge.Security;
/// <c>GetAllMappingsAsync</c> plus one <c>GetScopeRulesForMappingAsync</c> per matched Deployer
/// mapping. The table changes only through Administrator commands, so a
/// <see cref="SecurityOptions.RoleMappingCacheSeconds"/> window (default 30 s) of staleness is
/// an acceptable price: a role revocation reaches every node within that window, and the node
/// that served the change sees it at once when the mutation path calls <see cref="Invalidate"/>.
/// an acceptable price: without an explicit <see cref="Invalidate"/>, a mapping or scope-rule
/// change is visible on every node within that window. <see cref="Invalidate"/> discards this
/// node's snapshot so the next mapping re-reads the database; calling it after a mutation makes
/// the change immediate on the node that performed it (other nodes still rely on the TTL).
/// A TTL of <c>0</c> disables the cache: <see cref="Enabled"/> is false and
/// <see cref="RoleMapper"/> reads the repository directly on every call.
/// </summary>
/// <remarks>
/// <para>
@@ -54,6 +58,13 @@ public sealed class RoleMappingCache
private TimeSpan Ttl => TimeSpan.FromSeconds(Math.Max(0, _options?.CurrentValue.RoleMappingCacheSeconds ?? 30));
/// <summary>
/// False when <see cref="SecurityOptions.RoleMappingCacheSeconds"/> is <c>0</c>; callers then
/// bypass the snapshot (which would otherwise load every Deployer mapping's scope rules on
/// each call) and read only what they match.
/// </summary>
public bool Enabled => Ttl > TimeSpan.Zero;
/// <summary>
/// Discards the cached snapshot so the next mapping re-reads the database. Call after any
/// LDAP-group-mapping or site-scope-rule create/update/delete.
@@ -67,9 +67,9 @@ public class SecurityOptions
/// How long, in seconds, <see cref="RoleMapper"/> may serve the LDAP-group→role mapping
/// table and its site-scope rules from memory before re-reading the configuration
/// database. Default: <b>30</b>. <b>0</b> disables the cache (every mapping reads the
/// database). A mapping change is visible on every node within this window; the node
/// that served the change sees it immediately once <see cref="RoleMappingCache.Invalidate"/>
/// is called.
/// database, matched scope rules only). Without an explicit invalidation a mapping change
/// is visible on every node within this window; <see cref="RoleMappingCache.Invalidate"/>
/// discards the calling node's snapshot so its next mapping re-reads the database.
/// </summary>
public int RoleMappingCacheSeconds { get; set; } = 30;