using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
namespace ZB.MOM.WW.ScadaBridge.Security;
///
/// 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
/// call — one per Basic-authenticated
/// management request and one per Blazor auth-state read — issued
/// GetAllMappingsAsync plus one GetScopeRulesForMappingAsync per matched Deployer
/// mapping. The table changes only through Administrator commands, so a
/// 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 .
///
///
///
/// 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.
///
///
/// A refresh that started before an 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.
///
///
public sealed class RoleMappingCache
{
private readonly TimeProvider _timeProvider;
private readonly IOptionsMonitor? _options;
private readonly object _lock = new();
private Entry? _entry;
private long _generation;
/// One cached mapping row.
/// The mapping's primary key.
/// The LDAP group the mapping matches.
/// The ScadaBridge role the group grants.
/// The permitted site ids (Deployer mappings only; empty = unscoped).
public sealed record MappingRow(int Id, string LdapGroupName, string Role, IReadOnlyList ScopeSiteIds);
private sealed record Entry(IReadOnlyList Rows, DateTimeOffset LoadedAt);
/// Initializes the cache.
/// Clock used to age the snapshot.
/// Security options supplying ; null uses the 30 s default.
public RoleMappingCache(TimeProvider timeProvider, IOptionsMonitor? options = null)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_options = options;
}
private TimeSpan Ttl => TimeSpan.FromSeconds(Math.Max(0, _options?.CurrentValue.RoleMappingCacheSeconds ?? 30));
///
/// 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.
///
public void Invalidate()
{
lock (_lock)
{
_generation++;
_entry = null;
}
}
///
/// Returns the current mapping rows, loading them through when
/// the snapshot is missing, older than the TTL, or the cache is disabled.
///
/// The caller's (scoped) security repository, used only on a miss.
/// Cancellation token.
/// The mapping rows with their scope rules resolved.
public async Task> 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> LoadAsync(ISecurityRepository repository, CancellationToken ct)
{
var mappings = await repository.GetAllMappingsAsync(ct).ConfigureAwait(false);
var rows = new List(mappings.Count);
foreach (var mapping in mappings)
{
IReadOnlyList 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;
}
}