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);