ea172edebc
- 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.
292 lines
13 KiB
C#
292 lines
13 KiB
C#
using System.IdentityModel.Tokens.Jwt;
|
|
using System.Security.Claims;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.Security;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Security.Tests;
|
|
|
|
/// <summary>
|
|
/// <see cref="RoleMappingCache"/> + <see cref="RoleMapper"/> (review 2026-10-21 05/P1, 07/P-3):
|
|
/// a fresh snapshot serves mappings with no repository reads, it expires after
|
|
/// <see cref="SecurityOptions.RoleMappingCacheSeconds"/>, <see cref="RoleMappingCache.Invalidate"/>
|
|
/// forces a re-read, and the scope rules are resolved once per refresh.
|
|
/// </summary>
|
|
public class RoleMappingCacheTests
|
|
{
|
|
private sealed class MutableClock : TimeProvider
|
|
{
|
|
public DateTimeOffset Now { get; set; } = new(2026, 10, 22, 12, 0, 0, TimeSpan.Zero);
|
|
|
|
public override DateTimeOffset GetUtcNow() => Now;
|
|
}
|
|
|
|
// Counts the two reads RoleMapper uses; every other member is unused here.
|
|
private sealed class CountingRepository : ISecurityRepository
|
|
{
|
|
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 async Task<IReadOnlyList<LdapGroupMapping>> GetAllMappingsAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
MappingReads++;
|
|
var snapshot = Mappings.ToList();
|
|
if (BeforeMappingRead is not null)
|
|
await BeforeMappingRead();
|
|
return snapshot;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<IReadOnlyList<SiteScopeRule>> GetScopeRulesForMappingAsync(int ldapGroupMappingId, CancellationToken cancellationToken = default)
|
|
{
|
|
ScopeReads++;
|
|
return Task.FromResult(ScopeRules.TryGetValue(ldapGroupMappingId, out var rules)
|
|
? rules
|
|
: (IReadOnlyList<SiteScopeRule>)Array.Empty<SiteScopeRule>());
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<LdapGroupMapping?> GetMappingByIdAsync(int id, CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
|
/// <inheritdoc />
|
|
public Task<IReadOnlyList<LdapGroupMapping>> GetMappingsByRoleAsync(string role, CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
|
/// <inheritdoc />
|
|
public Task AddMappingAsync(LdapGroupMapping mapping, CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
|
/// <inheritdoc />
|
|
public Task UpdateMappingAsync(LdapGroupMapping mapping, CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
|
/// <inheritdoc />
|
|
public Task DeleteMappingAsync(int id, CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
|
/// <inheritdoc />
|
|
public Task<SiteScopeRule?> GetScopeRuleByIdAsync(int id, CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
|
/// <inheritdoc />
|
|
public Task AddScopeRuleAsync(SiteScopeRule rule, CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
|
/// <inheritdoc />
|
|
public Task UpdateScopeRuleAsync(SiteScopeRule rule, CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
|
/// <inheritdoc />
|
|
public Task DeleteScopeRuleAsync(int id, CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
|
/// <inheritdoc />
|
|
public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
|
}
|
|
|
|
private sealed class StaticMonitor(SecurityOptions value) : IOptionsMonitor<SecurityOptions>
|
|
{
|
|
public SecurityOptions CurrentValue => value;
|
|
|
|
public SecurityOptions Get(string? name) => value;
|
|
|
|
public IDisposable? OnChange(Action<SecurityOptions, string?> listener) => null;
|
|
}
|
|
|
|
private static LdapGroupMapping Mapping(int id, string group, string role) => new(group, role) { Id = id };
|
|
|
|
private static (CountingRepository Repo, MutableClock Clock, RoleMappingCache Cache, RoleMapper Mapper) Create(int ttlSeconds = 30)
|
|
{
|
|
var repo = new CountingRepository();
|
|
repo.Mappings.Add(Mapping(1, "Admins", Roles.Administrator));
|
|
repo.Mappings.Add(Mapping(2, "SiteDeployers", Roles.Deployer));
|
|
repo.ScopeRules[2] = [new SiteScopeRule { LdapGroupMappingId = 2, SiteId = 5 }];
|
|
var clock = new MutableClock();
|
|
var cache = new RoleMappingCache(clock, new StaticMonitor(new SecurityOptions { RoleMappingCacheSeconds = ttlSeconds }));
|
|
return (repo, clock, cache, new RoleMapper(repo, cache));
|
|
}
|
|
|
|
/// <summary>A second mapping within the TTL reads nothing from the repository.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task SecondMapping_WithinTtl_IsServedFromCache()
|
|
{
|
|
var (repo, _, _, mapper) = Create();
|
|
|
|
var first = await mapper.MapGroupsToRolesAsync(["SiteDeployers"]);
|
|
var second = await mapper.MapGroupsToRolesAsync(["SiteDeployers", "Admins"]);
|
|
|
|
Assert.Equal(1, repo.MappingReads);
|
|
Assert.Equal(1, repo.ScopeReads); // one Deployer mapping, read once per refresh
|
|
Assert.Equal(new[] { "5" }, first.PermittedSiteIds);
|
|
Assert.Contains(Roles.Administrator, second.Roles);
|
|
Assert.Contains(Roles.Deployer, second.Roles);
|
|
}
|
|
|
|
/// <summary>The snapshot expires after the TTL and the next mapping re-reads the table.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Mapping_AfterTtl_RereadsRepository()
|
|
{
|
|
var (repo, clock, _, mapper) = Create(ttlSeconds: 30);
|
|
await mapper.MapGroupsToRolesAsync(["Admins"]);
|
|
|
|
repo.Mappings.Add(Mapping(3, "Designers", Roles.Designer));
|
|
clock.Now = clock.Now.AddSeconds(31);
|
|
var result = await mapper.MapGroupsToRolesAsync(["Designers"]);
|
|
|
|
Assert.Equal(2, repo.MappingReads);
|
|
Assert.Contains(Roles.Designer, result.Roles);
|
|
}
|
|
|
|
/// <summary>Invalidate drops the snapshot immediately, so a revocation is seen on the next mapping.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Invalidate_ForcesReread_RevocationVisibleImmediately()
|
|
{
|
|
var (repo, _, cache, mapper) = Create();
|
|
Assert.Contains(Roles.Administrator, (await mapper.MapGroupsToRolesAsync(["Admins"])).Roles);
|
|
|
|
repo.Mappings.RemoveAll(m => m.Id == 1);
|
|
cache.Invalidate();
|
|
var after = await mapper.MapGroupsToRolesAsync(["Admins"]);
|
|
|
|
Assert.Equal(2, repo.MappingReads);
|
|
Assert.Empty(after.Roles);
|
|
}
|
|
|
|
/// <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_AndReadsOnlyMatchedScopeRules()
|
|
{
|
|
var (repo, _, _, mapper) = Create(ttlSeconds: 0);
|
|
|
|
await mapper.MapGroupsToRolesAsync(["Admins"]);
|
|
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>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Cache_IsSharedAcrossScopedMappers()
|
|
{
|
|
var (repo, _, cache, mapper) = Create();
|
|
await mapper.MapGroupsToRolesAsync(["Admins"]);
|
|
|
|
var otherScopeRepo = new CountingRepository();
|
|
var result = await new RoleMapper(otherScopeRepo, cache).MapGroupsToRolesAsync(["Admins"]);
|
|
|
|
Assert.Equal(0, otherScopeRepo.MappingReads);
|
|
Assert.Contains(Roles.Administrator, result.Roles);
|
|
Assert.Equal(1, repo.MappingReads);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Bearer-token hardening of <see cref="JwtTokenService"/> (review 2026-10-21 05/U1): lifetime
|
|
/// comes from <see cref="SecurityOptions.JwtExpiryMinutes"/>, expired and unsigned tokens are
|
|
/// rejected.
|
|
/// </summary>
|
|
public class JwtBearerHardeningTests
|
|
{
|
|
private const string Key = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough";
|
|
|
|
private sealed class FixedClock(DateTimeOffset now) : TimeProvider
|
|
{
|
|
public override DateTimeOffset GetUtcNow() => now;
|
|
}
|
|
|
|
private static JwtTokenService Service(int expiryMinutes = 15, TimeProvider? clock = null) =>
|
|
new(Options.Create(new SecurityOptions { JwtSigningKey = Key, JwtExpiryMinutes = expiryMinutes }),
|
|
NullLogger<JwtTokenService>.Instance, clock);
|
|
|
|
/// <summary>The token's exp claim is JwtExpiryMinutes after issuance.</summary>
|
|
[Fact]
|
|
public void GenerateToken_ExpiryHonoursJwtExpiryMinutes()
|
|
{
|
|
var issuedAt = new DateTimeOffset(2026, 10, 22, 12, 0, 0, TimeSpan.Zero);
|
|
var token = Service(expiryMinutes: 7, clock: new FixedClock(issuedAt))
|
|
.GenerateToken("U", "u", [Roles.Viewer], null);
|
|
|
|
var jwt = new JwtSecurityTokenHandler().ReadJwtToken(token);
|
|
|
|
Assert.Equal(issuedAt.UtcDateTime.AddMinutes(7), jwt.ValidTo);
|
|
}
|
|
|
|
/// <summary>A token past its expiry (beyond the small skew) is rejected.</summary>
|
|
[Fact]
|
|
public void ValidateToken_Expired_ReturnsNull()
|
|
{
|
|
var token = Service(clock: new FixedClock(DateTimeOffset.UtcNow.AddMinutes(-16)))
|
|
.GenerateToken("U", "u", [Roles.Viewer], null);
|
|
|
|
Assert.Null(Service().ValidateToken(token));
|
|
}
|
|
|
|
/// <summary>An alg=none token carrying our issuer/audience is rejected.</summary>
|
|
[Fact]
|
|
public void ValidateToken_AlgNone_ReturnsNull()
|
|
{
|
|
var unsigned = new JwtSecurityToken(
|
|
issuer: JwtTokenService.TokenIssuer,
|
|
audience: JwtTokenService.TokenAudience,
|
|
claims: [new Claim(JwtTokenService.UsernameClaimType, "admin")],
|
|
expires: DateTime.UtcNow.AddMinutes(10));
|
|
|
|
Assert.Null(Service().ValidateToken(new JwtSecurityTokenHandler().WriteToken(unsigned)));
|
|
}
|
|
|
|
/// <summary>A blank token is rejected without throwing.</summary>
|
|
[Fact]
|
|
public void ValidateToken_Blank_ReturnsNull()
|
|
{
|
|
Assert.Null(Service().ValidateToken(" "));
|
|
}
|
|
|
|
/// <summary>A non-positive JwtExpiryMinutes fails options validation.</summary>
|
|
[Fact]
|
|
public void Validator_NonPositiveExpiry_Fails()
|
|
{
|
|
var result = new SecurityOptionsValidator().Validate(null, new SecurityOptions { JwtExpiryMinutes = 0 });
|
|
Assert.True(result.Failed);
|
|
}
|
|
|
|
/// <summary>A negative RoleMappingCacheSeconds fails options validation.</summary>
|
|
[Fact]
|
|
public void Validator_NegativeRoleMappingCacheSeconds_Fails()
|
|
{
|
|
var result = new SecurityOptionsValidator().Validate(null, new SecurityOptions { RoleMappingCacheSeconds = -1 });
|
|
Assert.True(result.Failed);
|
|
}
|
|
}
|