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; } }