Task #219 — OpcUaServerOptions.AnonymousRoles (5/5 e2e stages pass) #221

Merged
dohertj2 merged 1 commits from task-219-anonymous-roles into v2 2026-04-21 11:51:58 -04:00
Owner

Closes #219.

Root cause

Anonymous OPC UA sessions landed as plain UserIdentity() — no roles. WriteAuthzPolicy.IsAllowed(SecurityClassification.Operate, []) rejects the empty role set, so DriverNodeManager.OnWriteValue returned BadUserAccessDenied (0x801F0000) for every write against a tag the driver classified as Operate (i.e. every writable tag).

Fix

Single config knob on OpcUaServerOptions:

"OpcUaServer": {
  "AnonymousRoles": ["WriteOperate"]
}

Default is empty, preserving the production-safe behaviour (anonymous can read FreeAccess, rejected on Operate/Tune/Configure). When non-empty, OtOpcUaServer.OnImpersonateUser wraps the anonymous token in a RoleBasedIdentity("(anonymous)", "Anonymous", AnonymousRoles) so DriverNodeManager's write guard sees the configured roles.

Env override syntax: OpcUaServer__AnonymousRoles__0=WriteOperate.

Verified live (5/5 stages)

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

Both directions + subscription delivery work through the server end-to-end. Stage 4 — the one that was failing — PASSes with the driver-side read confirming the OPC UA write propagated: PLC-side value equals 20675 on a reverse-write of 20675.

Files

  • src/.../Server/OpcUa/OpcUaServerOptions.cs — new AnonymousRoles property (IReadOnlyList, default [])
  • src/.../Server/OpcUa/OtOpcUaServer.cs — new anonymousRoles ctor param, used in OnImpersonateUser
  • src/.../Server/OpcUa/OpcUaApplicationHost.cs — passes _options.AnonymousRoles to OtOpcUaServer
  • src/.../Server/Program.cs — reads OpcUaServer:AnonymousRoles section from config

Test plan

  • Full-solution build: 0 errors
  • Live Modbus 5/5 stages against pymodbus fixture
  • AB CIP + S7 live-boot with the same config (#220 follow-up)
Closes #219. ## Root cause Anonymous OPC UA sessions landed as plain `UserIdentity()` — no roles. `WriteAuthzPolicy.IsAllowed(SecurityClassification.Operate, [])` rejects the empty role set, so `DriverNodeManager.OnWriteValue` returned `BadUserAccessDenied` (`0x801F0000`) for every write against a tag the driver classified as `Operate` (i.e. every writable tag). ## Fix Single config knob on `OpcUaServerOptions`: ```json "OpcUaServer": { "AnonymousRoles": ["WriteOperate"] } ``` Default is **empty**, preserving the production-safe behaviour (anonymous can read `FreeAccess`, rejected on `Operate`/`Tune`/`Configure`). When non-empty, `OtOpcUaServer.OnImpersonateUser` wraps the anonymous token in a `RoleBasedIdentity("(anonymous)", "Anonymous", AnonymousRoles)` so `DriverNodeManager`'s write guard sees the configured roles. Env override syntax: `OpcUaServer__AnonymousRoles__0=WriteOperate`. ## Verified live (5/5 stages) 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 ``` Both directions + subscription delivery work through the server end-to-end. Stage 4 — the one that was failing — PASSes with the driver-side read confirming the OPC UA write propagated: `PLC-side value equals 20675` on a reverse-write of `20675`. ## Files - `src/.../Server/OpcUa/OpcUaServerOptions.cs` — new `AnonymousRoles` property (IReadOnlyList<string>, default []) - `src/.../Server/OpcUa/OtOpcUaServer.cs` — new `anonymousRoles` ctor param, used in `OnImpersonateUser` - `src/.../Server/OpcUa/OpcUaApplicationHost.cs` — passes `_options.AnonymousRoles` to `OtOpcUaServer` - `src/.../Server/Program.cs` — reads `OpcUaServer:AnonymousRoles` section from config ## Test plan - [x] Full-solution build: 0 errors - [x] Live Modbus 5/5 stages against pymodbus fixture - [ ] AB CIP + S7 live-boot with the same config (#220 follow-up)
dohertj2 added 1 commit 2026-04-21 11:51:55 -04:00
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) <noreply@anthropic.com>
dohertj2 merged commit cc8a6c9ec1 into v2 2026-04-21 11:51:58 -04:00
dohertj2 deleted branch task-219-anonymous-roles 2026-04-21 11:51:58 -04:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: dohertj2/lmxopcua#221