Task #219 — OpcUaServerOptions.AnonymousRoles (5/5 e2e stages pass)
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>
This commit is contained in:
@@ -118,7 +118,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
|||||||
_server = new OtOpcUaServer(_driverHost, _authenticator, _pipelineBuilder, _loggerFactory,
|
_server = new OtOpcUaServer(_driverHost, _authenticator, _pipelineBuilder, _loggerFactory,
|
||||||
authzGate: _authzGate, scopeResolver: _scopeResolver,
|
authzGate: _authzGate, scopeResolver: _scopeResolver,
|
||||||
tierLookup: _tierLookup, resilienceConfigLookup: _resilienceConfigLookup,
|
tierLookup: _tierLookup, resilienceConfigLookup: _resilienceConfigLookup,
|
||||||
virtualReadable: _virtualReadable, scriptedAlarmReadable: _scriptedAlarmReadable);
|
virtualReadable: _virtualReadable, scriptedAlarmReadable: _scriptedAlarmReadable,
|
||||||
|
anonymousRoles: _options.AnonymousRoles);
|
||||||
await _application.Start(_server).ConfigureAwait(false);
|
await _application.Start(_server).ConfigureAwait(false);
|
||||||
|
|
||||||
_logger.LogInformation("OPC UA server started — endpoint={Endpoint} driverCount={Count}",
|
_logger.LogInformation("OPC UA server started — endpoint={Endpoint} driverCount={Count}",
|
||||||
|
|||||||
@@ -85,4 +85,15 @@ public sealed class OpcUaServerOptions
|
|||||||
/// <c>LdapOptions.Enabled = false</c>, UserName token attempts are rejected.
|
/// <c>LdapOptions.Enabled = false</c>, UserName token attempts are rejected.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public LdapOptions Ldap { get; init; } = new();
|
public LdapOptions Ldap { get; init; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Roles granted to anonymous OPC UA sessions. Default empty — anonymous clients can
|
||||||
|
/// read <c>FreeAccess</c> attributes but cannot write <c>Operate</c>/<c>Tune</c>/
|
||||||
|
/// <c>Configure</c> tags (<see cref="WriteAuthzPolicy"/> rejects the empty role set).
|
||||||
|
/// Dev + smoke-test deployments that need anonymous writes populate this with the
|
||||||
|
/// role names they want, e.g. <c>["WriteOperate"]</c> to match v1's anonymous-can-
|
||||||
|
/// operate default. Production deployments leave it empty + route operators through
|
||||||
|
/// UserName auth.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<string> AnonymousRoles { get; init; } = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,15 @@ public sealed class OtOpcUaServer : StandardServer
|
|||||||
private readonly IReadable? _virtualReadable;
|
private readonly IReadable? _virtualReadable;
|
||||||
private readonly IReadable? _scriptedAlarmReadable;
|
private readonly IReadable? _scriptedAlarmReadable;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Roles granted to anonymous sessions. When non-empty, <see cref="OnImpersonateUser"/>
|
||||||
|
/// wraps <c>AnonymousIdentityToken</c> in a <see cref="RoleBasedIdentity"/> carrying
|
||||||
|
/// these roles so <see cref="DriverNodeManager"/>'s write-authz check passes for
|
||||||
|
/// matching classifications. Empty (the default) preserves the pre-existing behaviour
|
||||||
|
/// of rejecting anonymous writes at <c>Operate</c> or higher.
|
||||||
|
/// </summary>
|
||||||
|
private readonly IReadOnlyList<string> _anonymousRoles;
|
||||||
|
|
||||||
private readonly ILoggerFactory _loggerFactory;
|
private readonly ILoggerFactory _loggerFactory;
|
||||||
private readonly List<DriverNodeManager> _driverNodeManagers = new();
|
private readonly List<DriverNodeManager> _driverNodeManagers = new();
|
||||||
|
|
||||||
@@ -47,7 +56,8 @@ public sealed class OtOpcUaServer : StandardServer
|
|||||||
Func<string, DriverTier>? tierLookup = null,
|
Func<string, DriverTier>? tierLookup = null,
|
||||||
Func<string, string?>? resilienceConfigLookup = null,
|
Func<string, string?>? resilienceConfigLookup = null,
|
||||||
IReadable? virtualReadable = null,
|
IReadable? virtualReadable = null,
|
||||||
IReadable? scriptedAlarmReadable = null)
|
IReadable? scriptedAlarmReadable = null,
|
||||||
|
IReadOnlyList<string>? anonymousRoles = null)
|
||||||
{
|
{
|
||||||
_driverHost = driverHost;
|
_driverHost = driverHost;
|
||||||
_authenticator = authenticator;
|
_authenticator = authenticator;
|
||||||
@@ -58,6 +68,7 @@ public sealed class OtOpcUaServer : StandardServer
|
|||||||
_resilienceConfigLookup = resilienceConfigLookup;
|
_resilienceConfigLookup = resilienceConfigLookup;
|
||||||
_virtualReadable = virtualReadable;
|
_virtualReadable = virtualReadable;
|
||||||
_scriptedAlarmReadable = scriptedAlarmReadable;
|
_scriptedAlarmReadable = scriptedAlarmReadable;
|
||||||
|
_anonymousRoles = anonymousRoles ?? [];
|
||||||
_loggerFactory = loggerFactory;
|
_loggerFactory = loggerFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +123,9 @@ public sealed class OtOpcUaServer : StandardServer
|
|||||||
switch (args.NewIdentity)
|
switch (args.NewIdentity)
|
||||||
{
|
{
|
||||||
case AnonymousIdentityToken:
|
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;
|
return;
|
||||||
|
|
||||||
case UserNameIdentityToken user:
|
case UserNameIdentityToken user:
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ var opcUaOptions = new OpcUaServerOptions
|
|||||||
SecurityProfile = Enum.TryParse<OpcUaSecurityProfile>(opcUaSection.GetValue<string>("SecurityProfile"), true, out var p)
|
SecurityProfile = Enum.TryParse<OpcUaSecurityProfile>(opcUaSection.GetValue<string>("SecurityProfile"), true, out var p)
|
||||||
? p : OpcUaSecurityProfile.None,
|
? p : OpcUaSecurityProfile.None,
|
||||||
Ldap = ldapOptions,
|
Ldap = ldapOptions,
|
||||||
|
AnonymousRoles = opcUaSection.GetSection("AnonymousRoles").Get<string[]>() ?? [],
|
||||||
};
|
};
|
||||||
|
|
||||||
builder.Services.AddSingleton(options);
|
builder.Services.AddSingleton(options);
|
||||||
|
|||||||
Reference in New Issue
Block a user