feat(mqtt): Sparkplug birth-driven browser tree + scoped Request-rebirth action

Unseals the Sparkplug branch of the MQTT address-picker browser that Task 10
deliberately sealed shut, and adds the first (and only) sanctioned publish on a
browse session.

- MqttBrowseSession serves Group -> EdgeNode -> [Device] -> Metric in Sparkplug
  mode, decoded from observed NBIRTH/DBIRTH certificates (the only Sparkplug
  messages that name their metrics). Node-level metrics hang directly under
  their edge node -- no synthesised device folder, because the authored address
  tuple genuinely has deviceId = null for them. Metric node ids use a '::'
  separator: a metric name may contain '/', so slash-joining would make a
  node-level metric indistinguishable from a device folder.
- AttributesAsync is purely birth-derived in Sparkplug mode. The plain path's
  UTF-8 inference / payload-snippet machinery does not run and no payload bytes
  are retained: a Sparkplug body is protobuf and would be reported as
  "(N bytes, binary)" for every metric in the plant. A datatype ToDriverDataType
  cannot map is reported as the Sparkplug type name, never coerced.
- RequestRebirthAsync(scope) is the one publishing member, reached only by an
  explicit operator action. It routes through the counted PublishAsync
  chokepoint via a private RebirthTransport adapter, so PublishCountForTest
  still proves that OpenAsync/RootAsync/ExpandAsync/AttributesAsync/Observe/
  DisposeAsync publish nothing -- now in both modes. A device or metric scope
  resolves up to its owning edge node (NCMD is node-addressed); group scope
  enumerates observed edge nodes and is refused whole past
  MaxGroupRebirthNodes = 32.
- BrowserSessionService.RequestRebirthAsync gates on the same DriverOperator
  policy that gates the picker, evaluated server-side where the action happens
  rather than only at render time, fails closed, and Info-logs the scope and the
  published count. Exposed through the new IRebirthCapableBrowseSession contract
  so "does this session write?" stays a type question.
- ToBrowseOptions' Last-Will landmine re-verified: MqttDriverOptions still
  carries nothing will-shaped (an NDEATH is a broker-published will and would be
  invisible to PublishCountForTest), now pinned by a reflection guard.

Falsifiability: injecting a publish into RootAsync reddens the Sparkplug and
plain passivity tests; double-publishing per target reddens all four
one-NCMD-per-node assertions. 572/572 MQTT tests, 24/24 AdminUI browsing tests.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-24 22:51:16 -04:00
parent 6d7a458c4d
commit 64ec7aa43a
7 changed files with 1263 additions and 95 deletions
@@ -1,5 +1,8 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Security.Auth;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
@@ -10,11 +13,19 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
/// 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>
public sealed class BrowserSessionService(
IEnumerable<IDriverBrowser> browsers,
BrowseSessionRegistry registry,
ILogger<BrowserSessionService> logger,
IUniversalDriverBrowser universalBrowser) : IBrowserSessionService
IUniversalDriverBrowser universalBrowser,
IAuthorizationService authorizationService,
AuthenticationStateProvider authenticationStateProvider) : IBrowserSessionService
{
/// <summary>Upper bound on a single root/expand/attributes call.</summary>
public static readonly TimeSpan PerCallTimeout = TimeSpan.FromSeconds(20);
@@ -75,6 +86,60 @@ public sealed class BrowserSessionService(
public Task<IReadOnlyList<AttributeInfo>> AttributesAsync(Guid token, string nodeId, CancellationToken ct) =>
InvokeAsync<IReadOnlyList<AttributeInfo>>(token, ct, (s, c) => s.AttributesAsync(nodeId, c));
/// <inheritdoc />
/// <remarks>
/// <para>
/// <b>The authorization check is the point of routing this through the service at all.</b>
/// 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 <c>DriverOperator</c> 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.
/// </para>
/// <para>
/// Deliberately <b>not</b> wrapped in <see cref="PerCallTimeout"/>-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.
/// </para>
/// </remarks>
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 async Task CloseAsync(Guid token)
{
@@ -50,6 +50,27 @@ public interface IBrowserSessionService
/// <returns>The attributes of the node.</returns>
Task<IReadOnlyList<AttributeInfo>> AttributesAsync(Guid token, string nodeId, CancellationToken ct);
/// <summary>
/// Asks the remote peer(s) addressed by <paramref name="scope"/> to re-announce themselves, on
/// a session whose driver offers that action (today: MQTT in Sparkplug B mode, where it is a
/// Sparkplug rebirth-request NCMD).
/// </summary>
/// <param name="token">Registry handle for the open browse session.</param>
/// <param name="scope">The driver-specific target — for Sparkplug, a browse node id from the
/// session's own tree, or a bare <c>{group}/{edgeNode}</c>.</param>
/// <param name="ct">Cancellation token for the operation.</param>
/// <returns>The number of request messages published.</returns>
/// <remarks>
/// <b>The only browse operation that writes to the device network</b>, and therefore the only
/// one carrying an authorization check: the caller must satisfy the same
/// <c>DriverOperator</c> policy that gates the picker's Browse affordance. Everything else on
/// this facade is read-only.
/// </remarks>
/// <exception cref="BrowseSessionNotFoundException">The token is unknown (or was reaped).</exception>
/// <exception cref="UnauthorizedAccessException">The caller is not a DriverOperator.</exception>
/// <exception cref="NotSupportedException">This session's driver has no re-announce action.</exception>
Task<int> RequestRebirthAsync(Guid token, string scope, CancellationToken ct);
/// <summary>Removes the session from the registry and disposes it. No-op for unknown tokens.</summary>
/// <param name="token">Registry handle for the browse session to close.</param>
/// <returns>A task that represents the asynchronous operation.</returns>