Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.Security/RoleMappingCache.cs
T
Joseph Doherty 1b3928a0a2 fix(security): finish the bearer-token design and cache role mappings (review 2026-10-21 05/U1, 05/P1)
- ManagementAuthenticator accepts Authorization: Bearer <jwt> alongside Basic on
  /management, the audit REST endpoints and the debug-stream hub. Tokens are
  validated locally (HS256 only, alg=none rejected, issuer/audience, lifetime with
  30 s skew); roles and site scope inside a valid token are authoritative for its
  lifetime, so a bearer call costs no LDAP bind and no role re-map.
- /auth/token returns expires_in + management_bearer:true, carries username, display
  name, roles and permitted sites, and answers 404 under DisableLogin.
- CLI exchanges credentials once per process (in-memory CliTokenStore), sends Bearer,
  re-exchanges once on 401 then falls back to Basic; older/login-disabled servers are
  Basic-only; bad credentials are not retried with Basic. Debug stream uses the token
  via AccessTokenProvider. SCADABRIDGE_AUTH_MODE=basic opts out.
- StartupValidator fails a Central boot on a JwtSigningKey under 32 UTF-8 bytes;
  fixed the false 'fail-fast at construction' comment. Removed the dead
  RefreshToken/RecordActivity/ShouldRefresh/IsIdleTimedOut members and the inert
  JwtRefreshThresholdMinutes; JwtExpiryMinutes now documented as the bearer lifetime
  (validated > 0).
- RoleMapper reads through a process-wide RoleMappingCache (RoleMappingCacheSeconds,
  default 30 s): one refresh loads the table plus every Deployer mapping's scope
  rules, Invalidate() drops it (generation-guarded against in-flight refreshes).
- Component-Security.md / Component-CLI.md / CLI README describe the real model
  (cookie claims ticket, bearer for CLI) instead of a cookie-embedded JWT.
2026-10-22 08:25:35 -04:00

130 lines
5.4 KiB
C#

using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
namespace ZB.MOM.WW.ScadaBridge.Security;
/// <summary>
/// Process-wide, short-TTL snapshot of the LDAP-group→role mapping table and the site-scope
/// rules of every Deployer mapping (review 2026-10-21 05/P1, 07/P-3). Before it, every
/// <see cref="RoleMapper.MapGroupsToRolesAsync"/> call — one per Basic-authenticated
/// management request and one per Blazor auth-state read — issued
/// <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"/>.
/// </summary>
/// <remarks>
/// <para>
/// The snapshot holds immutable copies (never the EF entities of whichever scope loaded it), and
/// loads the scope rules of ALL Deployer mappings in one refresh, so a cache hit performs no
/// database work at all.
/// </para>
/// <para>
/// A refresh that started before an <see cref="Invalidate"/> call is not published: a
/// generation counter is captured before the load and compared on publish, so an in-flight
/// read of the pre-change table can never overwrite the invalidation.
/// </para>
/// </remarks>
public sealed class RoleMappingCache
{
private readonly TimeProvider _timeProvider;
private readonly IOptionsMonitor<SecurityOptions>? _options;
private readonly object _lock = new();
private Entry? _entry;
private long _generation;
/// <summary>One cached mapping row.</summary>
/// <param name="Id">The mapping's primary key.</param>
/// <param name="LdapGroupName">The LDAP group the mapping matches.</param>
/// <param name="Role">The ScadaBridge role the group grants.</param>
/// <param name="ScopeSiteIds">The permitted site ids (Deployer mappings only; empty = unscoped).</param>
public sealed record MappingRow(int Id, string LdapGroupName, string Role, IReadOnlyList<string> ScopeSiteIds);
private sealed record Entry(IReadOnlyList<MappingRow> Rows, DateTimeOffset LoadedAt);
/// <summary>Initializes the cache.</summary>
/// <param name="timeProvider">Clock used to age the snapshot.</param>
/// <param name="options">Security options supplying <see cref="SecurityOptions.RoleMappingCacheSeconds"/>; null uses the 30 s default.</param>
public RoleMappingCache(TimeProvider timeProvider, IOptionsMonitor<SecurityOptions>? options = null)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_options = options;
}
private TimeSpan Ttl => TimeSpan.FromSeconds(Math.Max(0, _options?.CurrentValue.RoleMappingCacheSeconds ?? 30));
/// <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.
/// </summary>
public void Invalidate()
{
lock (_lock)
{
_generation++;
_entry = null;
}
}
/// <summary>
/// Returns the current mapping rows, loading them through <paramref name="repository"/> when
/// the snapshot is missing, older than the TTL, or the cache is disabled.
/// </summary>
/// <param name="repository">The caller's (scoped) security repository, used only on a miss.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The mapping rows with their scope rules resolved.</returns>
public async Task<IReadOnlyList<MappingRow>> GetAsync(ISecurityRepository repository, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(repository);
var ttl = Ttl;
long generation;
lock (_lock)
{
if (ttl > TimeSpan.Zero && _entry is { } cached
&& _timeProvider.GetUtcNow() - cached.LoadedAt < ttl)
{
return cached.Rows;
}
generation = _generation;
}
var rows = await LoadAsync(repository, ct).ConfigureAwait(false);
if (ttl > TimeSpan.Zero)
{
lock (_lock)
{
// Publish only if no Invalidate() raced the load.
if (generation == _generation)
{
_entry = new Entry(rows, _timeProvider.GetUtcNow());
}
}
}
return rows;
}
private static async Task<IReadOnlyList<MappingRow>> LoadAsync(ISecurityRepository repository, CancellationToken ct)
{
var mappings = await repository.GetAllMappingsAsync(ct).ConfigureAwait(false);
var rows = new List<MappingRow>(mappings.Count);
foreach (var mapping in mappings)
{
IReadOnlyList<string> scope = [];
if (mapping.Role.Equals(Roles.Deployer, StringComparison.OrdinalIgnoreCase))
{
var rules = await repository.GetScopeRulesForMappingAsync(mapping.Id, ct).ConfigureAwait(false);
scope = rules.Select(r => r.SiteId.ToString()).ToArray();
}
rows.Add(new MappingRow(mapping.Id, mapping.LdapGroupName, mapping.Role, scope));
}
return rows;
}
}