From 2ec6aa480ed7cccd48021243128bd642c9033a21 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 21 Apr 2026 11:49:41 -0400 Subject: [PATCH] =?UTF-8?q?Task=20#219=20=E2=80=94=20OpcUaServerOptions.An?= =?UTF-8?q?onymousRoles=20(5/5=20e2e=20stages=20pass)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anonymous OPC UA sessions had no roles (`UserIdentity()`), so `WriteAuthzPolicy.IsAllowed(SecurityClassification.Operate, [])` rejected every write with `BadUserAccessDenied`. The reverse-write stage of the Modbus e2e script surfaced this: stages 1-3 + 5 pass forward-direction, stage 4 (OPC UA client → server → driver → PLC) blew up with `0x801F0000` even with the factory + seed perfectly wired. Adds a single config knob: "OpcUaServer": { "AnonymousRoles": ["WriteOperate"] } Default empty preserves the pre-existing production-safe behaviour (anonymous reads FreeAccess tags, rejected on everything else). When non-empty, `OtOpcUaServer.OnImpersonateUser` wraps the anonymous token in a `RoleBasedIdentity("(anonymous)", "Anonymous", AnonymousRoles)` so the server-layer write guard sees the configured roles. Wire-through: - OpcUaServerOptions.AnonymousRoles (new) - OpcUaApplicationHost passes it to OtOpcUaServer ctor - OtOpcUaServer new anonymousRoles ctor param + OnImpersonateUser branch - Program.cs reads `OpcUaServer:AnonymousRoles` section from config Env override syntax: `OpcUaServer__AnonymousRoles__0=WriteOperate`. ## Verified live Booted server against `seed-modbus-smoke.sql` with `OpcUaServer__AnonymousRoles__0=WriteOperate` + pymodbus fixture → `test-modbus.ps1 -BridgeNodeId "ns=2;s=HR200"`: === Modbus e2e summary: 5/5 passed === [PASS] Probe [PASS] Driver loopback [PASS] Server bridge (driver → server → client) [PASS] OPC UA write bridge (client → server → driver) [PASS] Subscribe sees change All five stages green end-to-end. Issue #219 closed by this PR; the Modbus-seed update to set AnonymousRoles lives in the follow-up #220 live-boot PR (same AnonymousRoles value applies to every driver since the classification is a driver-constant, not per-tag). Full-solution build: 0 errors, only pre-existing xUnit1051 warnings. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../OpcUa/OpcUaApplicationHost.cs | 3 ++- .../OpcUa/OpcUaServerOptions.cs | 11 +++++++++++ .../OpcUa/OtOpcUaServer.cs | 17 +++++++++++++++-- src/ZB.MOM.WW.OtOpcUa.Server/Program.cs | 1 + 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs index 2dc9c4f..be26535 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs @@ -118,7 +118,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable _server = new OtOpcUaServer(_driverHost, _authenticator, _pipelineBuilder, _loggerFactory, authzGate: _authzGate, scopeResolver: _scopeResolver, tierLookup: _tierLookup, resilienceConfigLookup: _resilienceConfigLookup, - virtualReadable: _virtualReadable, scriptedAlarmReadable: _scriptedAlarmReadable); + virtualReadable: _virtualReadable, scriptedAlarmReadable: _scriptedAlarmReadable, + anonymousRoles: _options.AnonymousRoles); await _application.Start(_server).ConfigureAwait(false); _logger.LogInformation("OPC UA server started — endpoint={Endpoint} driverCount={Count}", diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaServerOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaServerOptions.cs index 34bcd09..3ebc1b0 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaServerOptions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaServerOptions.cs @@ -85,4 +85,15 @@ public sealed class OpcUaServerOptions /// LdapOptions.Enabled = false, UserName token attempts are rejected. /// public LdapOptions Ldap { get; init; } = new(); + + /// + /// Roles granted to anonymous OPC UA sessions. Default empty — anonymous clients can + /// read FreeAccess attributes but cannot write Operate/Tune/ + /// Configure tags ( rejects the empty role set). + /// Dev + smoke-test deployments that need anonymous writes populate this with the + /// role names they want, e.g. ["WriteOperate"] to match v1's anonymous-can- + /// operate default. Production deployments leave it empty + route operators through + /// UserName auth. + /// + public IReadOnlyList AnonymousRoles { get; init; } = []; } diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OtOpcUaServer.cs b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OtOpcUaServer.cs index 2b0e313..49dd62a 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OtOpcUaServer.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OtOpcUaServer.cs @@ -34,6 +34,15 @@ public sealed class OtOpcUaServer : StandardServer private readonly IReadable? _virtualReadable; private readonly IReadable? _scriptedAlarmReadable; + /// + /// Roles granted to anonymous sessions. When non-empty, + /// wraps AnonymousIdentityToken in a carrying + /// these roles so 's write-authz check passes for + /// matching classifications. Empty (the default) preserves the pre-existing behaviour + /// of rejecting anonymous writes at Operate or higher. + /// + private readonly IReadOnlyList _anonymousRoles; + private readonly ILoggerFactory _loggerFactory; private readonly List _driverNodeManagers = new(); @@ -47,7 +56,8 @@ public sealed class OtOpcUaServer : StandardServer Func? tierLookup = null, Func? resilienceConfigLookup = null, IReadable? virtualReadable = null, - IReadable? scriptedAlarmReadable = null) + IReadable? scriptedAlarmReadable = null, + IReadOnlyList? anonymousRoles = null) { _driverHost = driverHost; _authenticator = authenticator; @@ -58,6 +68,7 @@ public sealed class OtOpcUaServer : StandardServer _resilienceConfigLookup = resilienceConfigLookup; _virtualReadable = virtualReadable; _scriptedAlarmReadable = scriptedAlarmReadable; + _anonymousRoles = anonymousRoles ?? []; _loggerFactory = loggerFactory; } @@ -112,7 +123,9 @@ public sealed class OtOpcUaServer : StandardServer switch (args.NewIdentity) { case AnonymousIdentityToken: - args.Identity = new UserIdentity(); // anonymous + args.Identity = _anonymousRoles.Count == 0 + ? new UserIdentity() // anonymous, no roles — production default + : new RoleBasedIdentity("(anonymous)", "Anonymous", _anonymousRoles); return; case UserNameIdentityToken user: diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs b/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs index f02a385..0738536 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs @@ -83,6 +83,7 @@ var opcUaOptions = new OpcUaServerOptions SecurityProfile = Enum.TryParse(opcUaSection.GetValue("SecurityProfile"), true, out var p) ? p : OpcUaSecurityProfile.None, Ldap = ldapOptions, + AnonymousRoles = opcUaSection.GetSection("AnonymousRoles").Get() ?? [], }; builder.Services.AddSingleton(options);