Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs
T
Joseph Doherty dc0a24f224
v2-ci / unit-tests (push) Failing after 1h8m43s
feat(galaxy): operator-authenticated alarm ack — send Galaxy credential to mxaccessgw (#749)
mxaccessgw 488649c+ acknowledges by AuthenticateUser as the operator and
writing <alarm>.AckMsg, so AcknowledgeAlarmRequest now needs a real Galaxy
operator_user + operator_domain + operator_password. The OPC UA data plane is
API-key/anonymous and carries no Galaxy password, so the driver resolves the
account per ack:

1. the session's API key's Galaxy ack identity, set on AdminUI /apikeys and
   held ONLY in the encrypted Secrets store
   (otopcua/apikeys/<keyId>/galaxy-ack-{user,password}); a key with a user but
   no password secret is refused, never silently downgraded;
2. else the driver instance's AlarmAck fallback account (DriverConfig
   FallbackUser/FallbackDomain/FallbackPasswordSecretRef; secret:/${secret:}/
   env:/file:/dev: only — a literal is refused);
3. else DriverOperationRefusedException: nothing is sent, never an empty
   operator_user. Refusals are excluded from the resilience retry/breaker.

- Pins ZB.MOM.WW.MxGateway.Client/Contracts 0.2.1 -> 0.3.0 (vendored into
  nuget-packages/ from the Gitea feed, per the local-mxgw source mapping).
- The session ApiKeyId (not a secret) rides NativeAlarmAck ->
  RouteNativeAlarmAck -> RouteAlarmAck -> AlarmAcknowledgeRequest; the password
  is resolved in the driver and never travels through actor messages.
- The OPC UA principal + key id are appended to the AckMsg comment
  ("[OPC UA: <principal>, key <id>]") and logged, so a shared fallback
  account does not lose who acknowledged.
- Password never logged: GalaxyAckCredential.ToString omits it, the
  acknowledger scrubs it from every gateway message it echoes and withholds
  any exception that echoes it; audit rows carry only the key id.
- AdminUI: /apikeys "Ack identity" editor (set/remove; delete-key clears it),
  Galaxy driver form gains the fallback section.
- Synchronous OPC UA status for a refusal is follow-up #760 (the native ack
  path is fire-and-forget after the Part 9 method returns).

Tests: request mapping (password populated), password absent from logs and
exception text (mutation-checked), key-mapped / fallback / refused paths at
resolver and driver level, breaker non-trip, key-id propagation through node
manager and DriverHostActor, AdminUI service + form round-trip.

Closes #749
2026-11-06 16:23:00 -05:00

136 lines
8.5 KiB
C#

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
using ZB.MOM.WW.Secrets.Ui;
namespace ZB.MOM.WW.OtOpcUa.AdminUI;
public static class EndpointRouteBuilderExtensions
{
/// <summary>
/// Mounts the AdminUI Razor components and the AdminUI static asset pipeline at the root.
/// Call from the fused Host's Program.cs alongside <c>app.MapOtOpcUaAuth()</c>.
/// </summary>
/// <typeparam name="TApp">The root component type for Razor pages.</typeparam>
/// <param name="app">The endpoint route builder.</param>
/// <returns>The same endpoint route builder, for chaining.</returns>
public static IEndpointRouteBuilder MapAdminUI<TApp>(this IEndpointRouteBuilder app)
where TApp : IComponent
{
// Razor class library static assets (_content/ZB.MOM.WW.OtOpcUa.AdminUI/**) are
// served via the Host's app.UseStaticFiles() middleware which must run BEFORE
// UseAuthentication() — see Program.cs.
// The Secrets.Ui RCL's routable page is deliberately NOT registered here (lmxopcua#483):
// this host's router is static with per-page @rendermode opt-in, so routing straight to
// the RCL page rendered it without a circuit — it displayed but every @onclick was dead.
// Pages/Secrets.razor in THIS assembly owns /admin/secrets instead (wrapping the RCL
// page with the render mode); registering the RCL routes too would make the route
// ambiguous.
app.MapRazorComponents<TApp>()
.AddInteractiveServerRenderMode();
// The /raw CSV export modal's download link (R04 P6): the file goes over plain HTTP instead of
// being embedded in the circuit. ConfigEditor, like the /raw page itself.
app.MapRawTagCsvExport();
return app;
}
/// <summary>
/// Adds AdminUI services to the dependency injection container.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The same service collection, for chaining.</returns>
public static IServiceCollection AddAdminUI(this IServiceCollection services)
{
services.AddRazorComponents().AddInteractiveServerComponents().AddHubOptions(o => o.MaximumReceiveMessageSize = ScriptAnalysis.ScriptEditorLimits.HubMaximumReceiveMessageSize);
services.AddOtOpcUaDriverStatusServices();
// Secrets-management UI (ZB.MOM.WW.Secrets.Ui RCL, mounted at /admin/secrets). Register its
// "secrets:manage"/"secrets:reveal" authorization policies additively onto the AuthorizationOptions
// that AddOtOpcUaAuth (called just before AddAdminUI on admin nodes) sets up — Configure<T> stacks,
// so this ADDS the two policies without disturbing FleetAdmin/DriverOperator/ConfigEditor. The
// policies' AdminRole = "Administrator" reads the same ClaimTypes.Role claim as FleetAdmin, so an
// existing Administrator satisfies them with no new group→role mapping. Registered here (the AdminUI
// composition layer that already references the RCL) rather than in the core Security lib.
services.Configure<AuthorizationOptions>(o => o.AddSecretsAuthorization());
// Browse pipeline — see docs/plans/2026-05-28-driver-browsers-design.md
services.AddSingleton<Browsing.BrowseSessionRegistry>();
services.AddHostedService<Browsing.BrowseSessionReaper>();
services.AddScoped<Browsing.IBrowserSessionService, Browsing.BrowserSessionService>();
services.AddScoped<IUnsTreeService, UnsTreeService>();
services.AddScoped<IRawTreeService, RawTreeService>();
// v3 Batch 3: authoring-time UNS effective-name uniqueness guard. Consumed by
// UnsTreeService's reference/VirtualTag/ScriptedAlarm mutations; mirrors the
// deploy-time DraftValidator UnsEffectiveNameCollision rule.
services.AddScoped<IEffectiveNameGuard, EffectiveNameGuard>();
// the ACL scope picker's two trees + the authoring-time scope/principal checks that
// mirror the deploy gate's NodeAclScope* rules. Scoped, because it composes the two scoped tree
// services above.
services.AddScoped<Acls.IAclScopeService, Acls.AclScopeService>();
// lmxopcua#749: per-API-key Galaxy alarm-ack identity, held in the Secrets store (never the DB).
services.AddScoped<ApiKeyAck.IApiKeyGalaxyAckIdentityService>(sp => new ApiKeyAck.ApiKeyGalaxyAckIdentityService(
sp.GetRequiredService<ZB.MOM.WW.Secrets.Abstractions.ISecretStore>(),
sp.GetRequiredService<ZB.MOM.WW.Secrets.Abstractions.ISecretCipher>(),
sp.GetRequiredService<ZB.MOM.WW.Audit.IAuditWriter>(),
sp.GetService<ZB.MOM.WW.Secrets.Abstractions.ISecretCacheInvalidator>()));
// CSV export enumeration (read-only; does not extend the closed IRawTreeService prelude).
services.AddScoped<RawTagCsvExportReader>();
// /hosts eviction: what the fleet is running (cached per deployment id), unioned with the authored set.
services.AddSingleton<Hosts.DeployedDriverSet>();
services.AddSingleton<IDriverBrowser, OpcUaClientDriverBrowser>();
services.AddSingleton<IDriverBrowser, GalaxyDriverBrowser>();
// Bespoke Sql schema browser (server → schema → table → column). Constructible bare — every
// ctor param has a production default. The universal-browser DI line is deliberately NOT added
// for Sql: SqlDriver.CanBrowse is false, and this bespoke IDriverBrowser wins by construction
// (BrowserSessionService indexes bespoke browsers by DriverType before falling back to universal).
services.AddSingleton<IDriverBrowser, SqlDriverBrowser>();
services.AddSingleton<IDriverBrowser, MqttDriverBrowser>();
// Universal Discover-backed fallback browser (Wave 0). IDriverFactory is bound by the
// fused Host's DriverFactoryBootstrap; a standalone AdminUI has none → NullDriverFactory
// → CanBrowse false → pickers gracefully stay manual-entry (design §6).
services.AddSingleton<IUniversalDriverBrowser>(sp => new DiscoveryDriverBrowser(
sp.GetService<IDriverFactory>() ?? NullDriverFactory.Instance,
sp.GetRequiredService<ILogger<DiscoveryDriverBrowser>>()));
// Roslyn-backed Monaco script-editor analysis (diagnostics/completions/hover/...).
services.AddScoped<ScriptAnalysis.ScriptAnalysisService>();
services.AddScoped<ScriptAnalysis.IScriptTagCatalog, ScriptAnalysis.ScriptTagCatalog>();
// The catalog stays Scoped (per circuit); its six-table fleet-wide projection is shared across
// circuits behind a 5 s TTL snapshot — Monaco fires a completion request per keystroke.
services.AddSingleton<ScriptAnalysis.ScriptTagCatalogSnapshotCache>();
// TryAdd so a host that already bound a clock (test harness, future fused-host registration) wins.
services.TryAddSingleton<TimeProvider>(TimeProvider.System);
// /deployments drift chip. A singleton so its ConfigEdit-watermark cache is shared across
// circuits — otherwise every page load would recompose the whole fleet configuration.
// Constructed explicitly rather than by ctor scan: the compose seam is an optional parameter
// that only tests supply, and production must always get the real ConfigComposer default.
services.AddSingleton(sp => new Deployments.DeploymentDriftProbe(
sp.GetRequiredService<IDbContextFactory<OtOpcUaConfigDbContext>>()));
// Certificate-store actions (trust/untrust/delete) for the /certificates page.
services.AddSingleton<Certificates.CertificateStoreManager>();
// Structured audit-event sink: forwards AuditEvents to the ControlPlane AuditWriter singleton.
services.AddSingleton<ZB.MOM.WW.Audit.IAuditWriter, Audit.ActorAuditWriter>();
return services;
}
}