Merge pull request 'Task #219 — OpcUaServerOptions.AnonymousRoles (5/5 e2e stages pass)' (#221) from task-219-anonymous-roles into v2

This commit was merged in pull request #221.
This commit is contained in:
2026-04-21 11:51:56 -04:00
4 changed files with 29 additions and 3 deletions

View File

@@ -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}",

View File

@@ -85,4 +85,15 @@ public sealed class OpcUaServerOptions
/// <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; } = [];
}

View File

@@ -34,6 +34,15 @@ public sealed class OtOpcUaServer : StandardServer
private readonly IReadable? _virtualReadable;
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 List<DriverNodeManager> _driverNodeManagers = new();
@@ -47,7 +56,8 @@ public sealed class OtOpcUaServer : StandardServer
Func<string, DriverTier>? tierLookup = null,
Func<string, string?>? resilienceConfigLookup = null,
IReadable? virtualReadable = null,
IReadable? scriptedAlarmReadable = null)
IReadable? scriptedAlarmReadable = null,
IReadOnlyList<string>? 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:

View File

@@ -83,6 +83,7 @@ var opcUaOptions = new OpcUaServerOptions
SecurityProfile = Enum.TryParse<OpcUaSecurityProfile>(opcUaSection.GetValue<string>("SecurityProfile"), true, out var p)
? p : OpcUaSecurityProfile.None,
Ldap = ldapOptions,
AnonymousRoles = opcUaSection.GetSection("AnonymousRoles").Get<string[]>() ?? [],
};
builder.Services.AddSingleton(options);