using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using ZB.MOM.WW.OtOpcUa.AdminUI.Audit; using ZB.MOM.WW.OtOpcUa.Commons.Browsing; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.Validation; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Security.Auth; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing; /// /// Default implementation. Indexes injected /// s by /// (case-insensitive) at construction, registers opened sessions, and wraps each /// expand/attributes call in a 20-second linked CTS so a stuck driver cannot /// stall the UI indefinitely. /// /// The bespoke per-driver browsers. /// The open-session registry. /// The logger. /// The Discover-backed fallback browser. /// Policy evaluator for . /// Supplies the calling circuit's user. /// /// The config DB, for the cross-instance credential rule (lmxopcua#835). Null (test graphs) ⇒ a config carrying an /// in-scope secret:/env: reference cannot be checked and is refused (fail-closed). /// /// The credential-use audit sink (#835 review I-1); null ⇒ the structured log line only. public sealed class BrowserSessionService( IEnumerable browsers, BrowseSessionRegistry registry, ILogger logger, IUniversalDriverBrowser universalBrowser, IAuthorizationService authorizationService, AuthenticationStateProvider authenticationStateProvider, IDbContextFactory? dbFactory = null, ZB.MOM.WW.Audit.IAuditWriter? audit = null) : IBrowserSessionService { /// Upper bound on a single root/expand/attributes call. public static readonly TimeSpan PerCallTimeout = TimeSpan.FromSeconds(20); private readonly IReadOnlyDictionary _browsersByType = browsers.ToDictionary(b => b.DriverType, StringComparer.OrdinalIgnoreCase); /// public Task OpenAsync(string driverType, string configJson, CancellationToken ct) => OpenAsync(driverType, configJson, null, ct); /// public async Task OpenAsync(string driverType, string configJson, string? driverInstanceId, CancellationToken ct) { var result = await OpenCheckedAsync(driverType, configJson, driverInstanceId, ct).ConfigureAwait(false); // #835 review I-1: who browsed which endpoint with which driver's credential reference, and how it went. await CredentialUseAudit.RecordAsync(audit, authenticationStateProvider, logger, CredentialUseAudit.BrowseAction, driverType, configJson, driverInstanceId, result.Ok, result.Message).ConfigureAwait(false); return result; } private async Task OpenCheckedAsync(string driverType, string configJson, string? driverInstanceId, CancellationToken ct) { // lmxopcua#815: a live browse resolves the config's credential references and sends them to the // author-chosen endpoint — refuse an out-of-namespace reference before any browser (or the // Discover-backed universal browser) is opened. Nothing is read from the store or environment here. var credentialProblems = DriverCredentialPolicy.DescribeProblems(driverType, configJson); if (credentialProblems.Count > 0) return new(false, "Browse refused: " + string.Join(" ", credentialProblems), Guid.Empty); // lmxopcua#835: nor may an in-namespace reference name ANOTHER driver instance's secret — and the named owner's // own secrets count only while the config's endpoint is one that driver has SAVED (review I-1). if (DriverCredentialPolicy.HasInstanceSegments(driverType, configJson)) { if (dbFactory is null) return new(false, "Browse refused: the driver-instance list needed to check this config's secret:/env: " + "references is unavailable (lmxopcua#835, fail-closed).", Guid.Empty); try { await using var db = await dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false); var instanceProblems = await DriverInstanceIdentities .DescribeProbeProblemsAsync(db, driverType, configJson, driverInstanceId, ct).ConfigureAwait(false); if (instanceProblems.Count > 0) return new(false, "Browse refused: " + string.Join(" ", instanceProblems), Guid.Empty); } catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested) { // Review L-2: a ConfigDb fault must not become a circuit error — refuse (fail-closed) with a message. logger.LogWarning(ex, "Browse credential check failed for driverType={DriverType}", driverType); return new(false, "Browse refused: the driver-instance list could not be read to check this config's " + "secret:/env: references (lmxopcua#835/#849, fail-closed). Try again.", Guid.Empty); } } if (!_browsersByType.TryGetValue(driverType, out var browser)) return await OpenUniversalAsync(driverType, configJson, ct).ConfigureAwait(false); try { var session = await browser.OpenAsync(configJson, ct).ConfigureAwait(false); registry.Register(session); return new(true, null, session.Token); } catch (Exception ex) { logger.LogInformation(ex, "Browser open failed for driverType={DriverType}: {Message}", driverType, ex.Message); return new(false, ex.Message, Guid.Empty); } } /// public bool CanBrowse(string driverType, string configJson) => _browsersByType.ContainsKey(driverType) || universalBrowser.CanBrowse(driverType, configJson); private async Task OpenUniversalAsync(string driverType, string configJson, CancellationToken ct) { if (!universalBrowser.CanBrowse(driverType, configJson)) return new(false, $"No browser registered for driver type '{driverType}'.", Guid.Empty); try { var session = await universalBrowser.OpenAsync(driverType, configJson, ct).ConfigureAwait(false); registry.Register(session); return new(true, null, session.Token); } catch (Exception ex) { logger.LogInformation(ex, "Universal browse open failed for driverType={DriverType}: {Message}", driverType, ex.Message); return new(false, ex.Message, Guid.Empty); } } /// public Task> RootAsync(Guid token, CancellationToken ct) => InvokeAsync(token, ct, (s, c) => s.RootAsync(c)); /// public Task> ExpandAsync(Guid token, string nodeId, CancellationToken ct) => InvokeAsync(token, ct, (s, c) => s.ExpandAsync(nodeId, c)); /// public Task> AttributesAsync(Guid token, string nodeId, CancellationToken ct) => InvokeAsync>(token, ct, (s, c) => s.AttributesAsync(nodeId, c)); /// // The authorization check is the point of routing this through the service at all. // Every other member here is read-only; this one causes the browse session to publish onto // a live plant broker, so it is gated on the same DriverOperator policy the picker // bodies evaluate before rendering their Browse affordance. A render-time check alone is // not a control — it decides what a page draws, not what a circuit may invoke — so the // check is made here, where the action actually happens, and it fails closed. // Deliberately not wrapped in -style swallowing: unlike // a failed expand, a failed or refused rebirth is something the operator must see, so the // exception propagates to the caller for display. public async Task RequestRebirthAsync(Guid token, string scope, CancellationToken ct) { if (!registry.TryGet(token, out var session)) throw new BrowseSessionNotFoundException(token); if (session is not IRebirthCapableBrowseSession rebirthable) { throw new NotSupportedException( $"Browse session {token} ({session.GetType().Name}) has no re-announce action."); } var state = await authenticationStateProvider.GetAuthenticationStateAsync().ConfigureAwait(false); var authorized = await authorizationService .AuthorizeAsync(state.User, resource: null, AdminUiPolicies.DriverOperator) .ConfigureAwait(false); if (!authorized.Succeeded) { logger.LogWarning( "Rebirth request REFUSED for scope '{Scope}' on browse session {Token}: caller does not " + "satisfy the {Policy} policy.", scope, token, AdminUiPolicies.DriverOperator); throw new UnauthorizedAccessException( $"Requesting a rebirth requires the {AdminUiPolicies.DriverOperator} policy."); } using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(PerCallTimeout); var published = await rebirthable.RequestRebirthAsync(scope, cts.Token).ConfigureAwait(false); logger.LogInformation( "Rebirth requested by {User} for scope '{Scope}' on browse session {Token}: {Published} " + "message(s) published.", state.User.Identity?.Name ?? "(anonymous)", scope, token, published); return published; } /// public bool CanRequestRebirth(Guid token) => registry.TryGet(token, out var session) && session is IRebirthCapableBrowseSession { RebirthAvailable: true }; /// public async Task CloseAsync(Guid token) { if (!registry.TryRemove(token, out var session)) return; try { await session.DisposeAsync().ConfigureAwait(false); } catch { } logger.LogDebug("Browse session {Token} closed reason=user-close", token); } private async Task InvokeAsync( Guid token, CancellationToken callerCt, Func> op) { if (!registry.TryGet(token, out var session)) throw new BrowseSessionNotFoundException(token); using var cts = CancellationTokenSource.CreateLinkedTokenSource(callerCt); cts.CancelAfter(PerCallTimeout); return await op(session, cts.Token).ConfigureAwait(false); } }