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>
100 lines
4.9 KiB
C#
100 lines
4.9 KiB
C#
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
|
|
|
/// <summary>
|
|
/// OPC UA transport security profile selector. Controls which <c>ServerSecurityPolicy</c>
|
|
/// entries the endpoint advertises + which token types the <c>UserTokenPolicies</c> permits.
|
|
/// </summary>
|
|
public enum OpcUaSecurityProfile
|
|
{
|
|
/// <summary>Anonymous only on <c>SecurityPolicies.None</c> — dev-only, no signing or encryption.</summary>
|
|
None,
|
|
|
|
/// <summary>
|
|
/// <c>Basic256Sha256 SignAndEncrypt</c> with <c>UserName</c> and <c>Anonymous</c> token
|
|
/// policies. Clients must present a valid application certificate + user credentials.
|
|
/// </summary>
|
|
Basic256Sha256SignAndEncrypt,
|
|
}
|
|
|
|
/// <summary>
|
|
/// OPC UA server endpoint + application-identity configuration. Bound from the
|
|
/// <c>OpcUaServer</c> section of <c>appsettings.json</c>. PR 17 minimum-viable scope: no LDAP,
|
|
/// no security profiles beyond None — those wire in alongside a future deployment-policy PR
|
|
/// that reads from the central config DB instead of appsettings.
|
|
/// </summary>
|
|
public sealed class OpcUaServerOptions
|
|
{
|
|
public const string SectionName = "OpcUaServer";
|
|
|
|
/// <summary>
|
|
/// Fully-qualified endpoint URI clients connect to. Use <c>0.0.0.0</c> to bind all
|
|
/// interfaces; the stack rewrites to the machine's hostname for the returned endpoint
|
|
/// description at GetEndpoints time.
|
|
/// </summary>
|
|
public string EndpointUrl { get; init; } = "opc.tcp://0.0.0.0:4840/OtOpcUa";
|
|
|
|
/// <summary>Human-readable application name surfaced in the endpoint description.</summary>
|
|
public string ApplicationName { get; init; } = "OtOpcUa Server";
|
|
|
|
/// <summary>Stable application URI — must match the subjectAltName of the app cert.</summary>
|
|
public string ApplicationUri { get; init; } = "urn:OtOpcUa:Server";
|
|
|
|
/// <summary>
|
|
/// Directory where the OPC UA stack stores the application certificate + trusted /
|
|
/// rejected cert folders. Defaults to <c>%ProgramData%\OtOpcUa\pki</c>; the stack
|
|
/// creates the directory tree on first run and generates a self-signed cert.
|
|
/// </summary>
|
|
public string PkiStoreRoot { get; init; } =
|
|
System.IO.Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
|
"OtOpcUa", "pki");
|
|
|
|
/// <summary>
|
|
/// When true, the stack auto-trusts client certs on first connect. Dev-default = true,
|
|
/// production deployments should flip this to false and manually trust clients via the
|
|
/// Admin UI.
|
|
/// </summary>
|
|
public bool AutoAcceptUntrustedClientCertificates { get; init; } = true;
|
|
|
|
/// <summary>
|
|
/// Whether to start the Phase 6.1 Stream C <c>/healthz</c> + <c>/readyz</c> HTTP listener.
|
|
/// Defaults to <c>true</c>; set false in embedded deployments that don't need HTTP
|
|
/// (e.g. tests that only exercise the OPC UA surface).
|
|
/// </summary>
|
|
public bool HealthEndpointsEnabled { get; init; } = true;
|
|
|
|
/// <summary>
|
|
/// URL prefix the health endpoints bind to. Default <c>http://localhost:4841/</c> — loopback
|
|
/// avoids Windows URL-ACL elevation. Production deployments that need remote probing should
|
|
/// either reverse-proxy or use <c>http://+:4841/</c> with netsh urlacl granted.
|
|
/// </summary>
|
|
public string HealthEndpointsPrefix { get; init; } = "http://localhost:4841/";
|
|
|
|
/// <summary>
|
|
/// Security profile advertised on the endpoint. Default <see cref="OpcUaSecurityProfile.None"/>
|
|
/// preserves the PR 17 endpoint shape; set to <see cref="OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt"/>
|
|
/// for production deployments with LDAP-backed UserName auth.
|
|
/// </summary>
|
|
public OpcUaSecurityProfile SecurityProfile { get; init; } = OpcUaSecurityProfile.None;
|
|
|
|
/// <summary>
|
|
/// LDAP binding for UserName token validation. Only consulted when the active
|
|
/// <see cref="SecurityProfile"/> advertises a UserName token policy. When
|
|
/// <c>LdapOptions.Enabled = false</c>, UserName token attempts are rejected.
|
|
/// </summary>
|
|
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; } = [];
|
|
}
|