1b3928a0a2
- 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.
130 lines
5.4 KiB
C#
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;
|
|
}
|
|
}
|