Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowserSessionService.cs
T
Joseph Doherty 4a6229e4d2 fix(security): env: credential references follow the per-instance rule (#849)
env:<PREFIX><INSTANCE>_... is the per-instance convention for every prefix of a
driver's scope (OTOPCUA_GALAXY_, GALAXY_MXGW_, OTOPCUA_OPCUACLIENT_,
OTOPCUA_HEIDENHAIN_), INSTANCE = a driver id or name upper-cased with every other
character as '_'. A variable whose part after the prefix equals or starts with
(at a '_' boundary) ANOTHER same-type instance's id or name is refused with
CredentialReferenceOtherInstance at save, deploy, Test Connect and browse - the
same DriverCredentialPolicy.DescribeOtherInstanceProblems the secret: rule uses
(now over CollectInstanceReferences / ReferenceNames). Another-instance matches
win over the owner's, as for secrets. Shared variables that name no instance
(GALAXY_MXGW_API_KEY) keep working. HasInstanceSegments now also gates on env:,
so an env:-only browse needs the ConfigDb (fail-closed without it).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-11 06:24:47 -05:00

215 lines
11 KiB
C#

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;
/// <summary>
/// Default <see cref="IBrowserSessionService"/> implementation. Indexes injected
/// <see cref="IDriverBrowser"/>s by <see cref="IDriverBrowser.DriverType"/>
/// (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.
/// </summary>
/// <param name="browsers">The bespoke per-driver browsers.</param>
/// <param name="registry">The open-session registry.</param>
/// <param name="logger">The logger.</param>
/// <param name="universalBrowser">The Discover-backed fallback browser.</param>
/// <param name="authorizationService">Policy evaluator for <see cref="RequestRebirthAsync"/>.</param>
/// <param name="authenticationStateProvider">Supplies the calling circuit's user.</param>
/// <param name="dbFactory">
/// The config DB, for the cross-instance credential rule (lmxopcua#835). Null (test graphs) ⇒ a config carrying an
/// in-scope <c>secret:</c>/<c>env:</c> reference cannot be checked and is refused (fail-closed).
/// </param>
/// <param name="audit">The credential-use audit sink (#835 review I-1); null ⇒ the structured log line only.</param>
public sealed class BrowserSessionService(
IEnumerable<IDriverBrowser> browsers,
BrowseSessionRegistry registry,
ILogger<BrowserSessionService> logger,
IUniversalDriverBrowser universalBrowser,
IAuthorizationService authorizationService,
AuthenticationStateProvider authenticationStateProvider,
IDbContextFactory<OtOpcUaConfigDbContext>? dbFactory = null,
ZB.MOM.WW.Audit.IAuditWriter? audit = null) : IBrowserSessionService
{
/// <summary>Upper bound on a single root/expand/attributes call.</summary>
public static readonly TimeSpan PerCallTimeout = TimeSpan.FromSeconds(20);
private readonly IReadOnlyDictionary<string, IDriverBrowser> _browsersByType =
browsers.ToDictionary(b => b.DriverType, StringComparer.OrdinalIgnoreCase);
/// <inheritdoc />
public Task<BrowseOpenResult> OpenAsync(string driverType, string configJson, CancellationToken ct) =>
OpenAsync(driverType, configJson, null, ct);
/// <inheritdoc />
public async Task<BrowseOpenResult> 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<BrowseOpenResult> 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);
}
}
/// <inheritdoc />
public bool CanBrowse(string driverType, string configJson) =>
_browsersByType.ContainsKey(driverType) || universalBrowser.CanBrowse(driverType, configJson);
private async Task<BrowseOpenResult> 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);
}
}
/// <inheritdoc />
public Task<IReadOnlyList<BrowseNode>> RootAsync(Guid token, CancellationToken ct) =>
InvokeAsync(token, ct, (s, c) => s.RootAsync(c));
/// <inheritdoc />
public Task<IReadOnlyList<BrowseNode>> ExpandAsync(Guid token, string nodeId, CancellationToken ct) =>
InvokeAsync(token, ct, (s, c) => s.ExpandAsync(nodeId, c));
/// <inheritdoc />
public Task<IReadOnlyList<AttributeInfo>> AttributesAsync(Guid token, string nodeId, CancellationToken ct) =>
InvokeAsync<IReadOnlyList<AttributeInfo>>(token, ct, (s, c) => s.AttributesAsync(nodeId, c));
/// <inheritdoc />
// 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<int> 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;
}
/// <inheritdoc />
public bool CanRequestRebirth(Guid token) =>
registry.TryGet(token, out var session) && session is IRebirthCapableBrowseSession { RebirthAvailable: true };
/// <inheritdoc />
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<T> InvokeAsync<T>(
Guid token, CancellationToken callerCt, Func<IBrowseSession, CancellationToken, Task<T>> 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);
}
}