Opc.Ua.Server was pinned 1.5.374.126 while Client/Configuration were 1.5.378.106, so the
published Host unified Opc.Ua.Core to 1.5.378 (which dropped Opc.Ua.INodeIdFactory that Server
1.5.374 referenced). Every driver-role node (and the fused site nodes) failed to start the OPC
UA server with TypeLoadException, leaving the OPC data plane dead and the site UIs at 503.
Align all OPC UA packages to 1.5.378.106 (bump Server; drop the Opc.Ua.Configuration/Client
VersionOverrides in OpcUaServer + its integration tests) and port the server host to the
1.5.378 async API:
- ApplicationInstance requires an ITelemetryContext ctor (DefaultTelemetry.Create)
- Start/Stop/LoadApplicationConfiguration/Validate -> async; CheckApplicationInstanceCertificate
-> CheckApplicationInstanceCertificatesAsync
- ImpersonateEventHandler is now (ISession, ImpersonateEventArgs)
- UserNameIdentityToken.DecryptedPassword is now byte[] (UTF-8 decode)
- tests ported (byte[] passwords; async discovery/session/read client API)
Verified: full solution builds, OpcUaServer unit tests 52/52, and in docker-dev all six OPC
endpoints (4840-4845) listen and the site UIs return 302 (were 503). End-to-end OPC behaviour
(read/write/subscribe/security under 1.5.378) still needs a functional client test.
The per-role overlay (appsettings.{role}.json) was appended after WebApplicationBuilder's
default sources, so it outranked environment variables — a baked role file could not be
overridden by a deployment env var. In otopcua-dev this meant appsettings.admin.json's
Security:Ldap:DevStubMode=false beat the compose's DevStub override, so every AdminUI login
attempted a real LDAP bind against a non-existent server and failed with 'Unexpected
authentication error'.
- Program.cs: re-append AddEnvironmentVariables() + AddCommandLine(args) after the role
overlay so deployment overrides keep top precedence (overlay still beats base appsettings).
- docker-dev/docker-compose.yml: the DevStub env var targeted the stale 'Authentication:Ldap'
section; the code reads 'Security:Ldap'. Corrected the prefix on every host node (+ comment).
Dev AdminUI login now signs in as Administrator via the DevStub bypass.
The ZB.MOM.WW.Theme cutover left site.css carrying a near-verbatim copy of the
kit's layout.css (.app-shell/.side-rail/.rail-link/.rail-foot/.login-*) plus two
dead rules (#sidebar-collapse — the kit emits #theme-rail; .rail-eyebrow-chevron
— rendered by the deleted NavSection.razor). Those duplicates loaded after the
kit and could silently override it. Removed them; kept only the app-only rules
the kit does not provide: .rail-eyebrow (footer Session label) and
.chip-alert/.chip-caution (domain status variants). 167 lines removed; builds clean.
Introduces the IAuditActorAccessor seam and HttpAuditActorAccessor impl so the
ZB.MOM.WW.Audit.AuditEvent Actor field can be sourced from the authenticated Blazor
cookie principal (ZbClaimTypes.Username) when structured emitters are added. Adds the
AuditActor.Resolve static helper (accessor value → SystemFallback/"system") as the
canonical pattern for future emit sites. Wires DI in AddOtOpcUaAuth (TryAddScoped) with
AddHttpContextAccessor(). The structured AuditEvent path remains DORMANT — no live emit
sites exist; seam is forward-looking. SP-based audit path left untouched. 9 new unit
tests all green; Security (54) and ControlPlane (45) test suites fully pass.
Persist the canonical AuditOutcome and make structured audit rows visible.
- ConfigAuditLog gains a nullable Outcome column, stored as the AuditOutcome
enum member name (nvarchar(16), mirroring how AdminRole is persisted). The
AuditWriterActor flush now writes Outcome = evt.Outcome.ToString(). Nullable so
legacy rows and the bespoke stored-procedure path (no derived outcome) write
NULL.
- Migration 20260602135350_AddConfigAuditLogOutcome: additive nullable column,
no backfill. Up adds the column, Down drops it. Chains after
20260602112419_CanonicalizeAdminRoles; `dotnet ef migrations
has-pending-model-changes` is clean.
- ClusterAudit visibility fix: the page filtered solely on ClusterId, but the
structured AuditWriterActor path stamps NodeId (ClusterId null), so those rows
were invisible. Extracted ClusterAuditQuery.ForClusterAsync (shared by the page
and tests) which ORs in rows whose NodeId belongs to a node in the cluster —
membership resolved from ClusterNode (NodeId -> ClusterId). SP-path
ClusterId-stamped rows still match.
Tests: ControlPlane 45/45 (adds Outcome persistence + Denied-outcome asserts);
new Configuration ClusterAuditQueryTests 3/3 (both-paths visible, other-cluster
excluded, page-size cap); AdminUI 121/121. Configuration Unit suite is green on a
clean run (a pre-existing timing flake in ResilientConfigReaderTests, untouched
here, occasionally fails under parallel load and passes in isolation).
Deep-adopt the shared audit record. Deletes the bespoke 8-field positional
Commons AuditEvent and repoints the writer path at ZB.MOM.WW.Audit.AuditEvent
(0.1.0, feed-mapped via dohertj2-gitea). Adds the package reference to both
Commons and ControlPlane.
- AuditWriterActor now implements IAuditWriter: WriteAsync(evt, ct) is a
best-effort, never-throwing entry point that Self.Tell()s the event onto the
same batching/dedup/flush pipeline and returns Task.CompletedTask. Existing
Receive<AuditEvent> + 500/5s batching + two-layer dedup unchanged.
- Flush mapping updated for the canonical field types: OccurredAtUtc is now
DateTimeOffset (.UtcDateTime into the datetime2 column), SourceNode is string?
(was NodeId.Value), CorrelationId is Guid? (stored null when null). Outcome is
NOT yet persisted (column lands in Task 2.2).
- New AuditOutcomeMapper.FromAction maps the OtOpcUa action vocabulary to the
required canonical Outcome: OpcUaAccessDenied / CrossClusterNamespaceAttempt ->
Denied; config verbs (DraftCreated/Edited, Published, RolledBack, NodeApplied,
ClusterCreated, NodeAdded, CredentialAdded/Disabled, ExternalIdReleased) ->
Success. OtOpcUa emits no Failure events.
The Akka message shape changed, but the structured audit path is dormant (zero
production emit/Tell sites; all live audit flows through the bespoke SP path),
so there is no rolling-deploy wire-compat concern. Tested-not-exercised by
design.
ControlPlane.Tests: 44/44 green (AuditWriterActor suite rewritten to construct
the canonical record + assert the Outcome derivation table + the WriteAsync
best-effort/mailbox-routing contract + null SourceNode/CorrelationId handling).
Standardize the control-plane admin role VALUES on the canonical six
(ZB.MOM.WW.Auth CanonicalRole). OtOpcUa uses four:
ConfigViewer -> Viewer
ConfigEditor -> Designer
FleetAdmin -> Administrator
DriverOperator -> Operator (appsettings-only string role)
This is a rename, not a permission change: enforcement semantics are
preserved (whoever could deploy/administer/operate before still can).
- AdminRole enum members renamed (persisted as string names via
HasConversion<string>); RoleGrants.razor dropdown default updated.
- EF DATA migration CanonicalizeAdminRoles rewrites existing
LdapGroupRoleMapping.Role rows old->new (Up) and back (Down); schema /
model snapshot byte-identical (no pending model changes).
- Enforcement role STRINGS canonicalized:
* Security policies keep their NAMES ("DriverOperator"/"FleetAdmin")
but require canonical roles: RequireRole("Operator","Administrator")
and RequireRole("Administrator").
* Deployments.razor [Authorize(Roles="Administrator,Designer")].
* DevStub now grants "Administrator"; LdapOptions/doc-comment examples
canonicalized.
- Data-plane authorization (NodePermissions/NodeAcl/IPermissionEvaluator/
TriePermissionEvaluator/UserAuthorizationState) UNTOUCHED.
- New CanonicalAdminRolesTests pins canonical claim values end-to-end and
the real registered policies; existing role-string tests updated.
Fix 1 (test): Token_payload_uses_canonical_zb_claim_keys now asserts that the JWT
payload carries at least one role under JwtTokenService.RoleClaimType ("Role"),
pinning the role-key contract so a future rename is caught immediately. Adds a
comment explaining why alice has roles (appsettings "ReadOnly"→"ConfigViewer"
baseline). Adds missing `using ZB.MOM.WW.OtOpcUa.Security.Jwt` to the test file.
Fix 2 (no-validation path — no AddJwtBearer in production pipeline): grep of src/
confirms no AddJwtBearer / JwtBearer scheme in ServiceCollectionExtensions or Host;
the ServiceCollectionExtensions doc comment explicitly states "no JwtBearer parallel
scheme". RoleClaimType intentionally stays the short "Role" key. Three changes:
- RoleClaimType doc comment documents issued-only nature, the caveat that a
JwtBearer scheme MUST use BuildValidationParameters(), and that BuildValidationParameters
is already wired to set RoleClaimType+NameClaimType correctly.
- Issue() inline comment at the role-mint site references RoleClaimType docs.
- BuildValidationParameters() now sets RoleClaimType=RoleClaimType and
NameClaimType=UsernameClaimType so that if it is ever passed to AddJwtBearer,
role/name resolution is correct without any extra wiring. TryValidate() is
refactored to delegate to BuildValidationParameters() so the two can never drift.
All 35 security tests green.
Add ZB.MOM.WW.Auth.AspNetCore package ref to Security project (version 0.1.1
from central PM). Alias JwtTokenService.UsernameClaimType and DisplayNameClaimType
to ZbClaimTypes.Username ("zb:username") and ZbClaimTypes.DisplayName ("zb:displayname")
so every mint/read site inherits the canonical spelling. AuthEndpoints login path now
emits ZbClaimTypes.Name (= ClaimTypes.Name, populates Identity.Name) instead of
ClaimTypes.NameIdentifier (no other read site used it), and references ZbClaimTypes.Role
(= ClaimTypes.Role) for role claims so [Authorize(Roles=...)] continues to resolve.
Cookie hardening now flows through ZbCookieDefaults.Apply (sets HttpOnly, SameSite=Strict,
SlidingExpiration, SecurePolicy, ExpireTimeSpan) followed by opts.Cookie.Name = v.Name to
preserve the OtOpcUa-specific "ZB.MOM.WW.OtOpcUa.Auth" cookie name. Two new tests added
to AuthEndpointsIntegrationTests assert canonical ZbClaimTypes on the cookie principal and
canonical zb: keys in the JWT payload; all 35 security tests green.
Both bugs surfaced only on split-role deployments (the MAIN cluster's
admin-only nodes), where the AdminUI runs without the driver role.
- Test Connect returned "No probe registered" for every driver: the
IDriverProbe set was registered only under the driver role, but the
admin-operations singleton that consumes it is pinned to admin. Extract
AddOtOpcUaDriverProbes() (idempotent via TryAddEnumerable) and call it
in the hasAdmin path too.
- Live driver-status/alerts/script-log panels showed "SignalR error:
Connection refused": these Blazor Server components opened a HubConnection
to their own hub via the browser's public URL, which server-side code
can't reach behind Traefik (host :9200 -> container :9000). Read the
in-process source directly instead -- DriverStatus via
IDriverStatusSnapshotStore.SnapshotChanged, Alerts/ScriptLog via a new
IInProcessBroadcaster<T>. Fleet status was unaffected (reads DB/ActorSystem).
Adds unit tests for probe registration, the snapshot-store event, and the
broadcaster.
GalaxyDriverPage deserialized DriverConfig with case-sensitive camelCase opts, but the
persisted/seeded config is PascalCase (the runtime reads it case-insensitively). So all four
nested option records read as null -> FromRecord NRE (HTTP 500) on edit, and the form would
have shown defaults instead of the real config (risking a clobber on save). Fix: add
PropertyNameCaseInsensitive=true (matches the runtime) so real values load, plus null-coalesce
the nested records in FromRecord as defense-in-depth. Regression test asserts the seeded
PascalCase config loads its real values.
The driver/factory/seed use 'GalaxyMxGateway' (legacy 'Galaxy' was retired),
but the AdminUI editor router, GalaxyDriverPage, address picker, identity
dropdown, the Galaxy browser/probe, and DraftValidator still keyed on 'Galaxy'.
Result: the seeded GalaxyMxGateway driver couldn't be edited ('no editor
registered'), UI-created Galaxy drivers wrote a type with no factory, and a
SystemPlatform-bound GalaxyMxGateway driver failed publish validation.
Align all stragglers to GalaxyMxGateway (+ failing-test-first DraftValidator
coverage). ShouldStub's 'Galaxy' legacy safety-net left intact.
Capture the original ModbusTagDefinition as _source in ModbusTagRow and
rewrite ToDefinition() to use 'with {}', so StringByteOrder, ArrayCount,
Deadband, UnitId, and CoalesceProhibited survive a load→edit→save cycle.