4a6229e4d2
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>
215 lines
11 KiB
C#
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);
|
|
}
|
|
}
|