Implements configurable user authentication (anonymous + username/password) with pluggable credential provider (IUserAuthenticationProvider). Anonymous writes can be disabled via AnonymousCanWrite setting while reads remain open. Adds -U/-P flags to all CLI commands for authenticated sessions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
12 KiB
Authentication and Role-Based Access Control Plan
Context
The server currently accepts only anonymous connections with no write restrictions beyond Galaxy security_classification. This plan adds configurable authentication (anonymous + username/password) and role-based write control using the OPC UA framework's built-in RBAC support.
Configuration
Add an Authentication section to appsettings.json:
{
"Authentication": {
"AllowAnonymous": true,
"AnonymousCanWrite": true,
"Users": [
{ "Username": "operator", "Password": "op123" },
{ "Username": "engineer", "Password": "eng456" }
]
}
}
| Setting | Default | Description |
|---|---|---|
AllowAnonymous |
true |
Accept anonymous OPC UA connections |
AnonymousCanWrite |
true |
Allow anonymous users to write tag values (respecting existing security classification) |
Users |
[] |
Username/password pairs for authenticated access |
Roles
Two roles using OPC UA well-known role NodeIds:
| Role | NodeId | Permissions |
|---|---|---|
Anonymous |
ObjectIds.WellKnownRole_Anonymous |
Browse + Read (+ Write if AnonymousCanWrite is true) |
AuthenticatedUser |
ObjectIds.WellKnownRole_AuthenticatedUser |
Browse + Read + Write |
Design
1. Configuration class — AuthenticationConfiguration.cs
public class AuthenticationConfiguration
{
public bool AllowAnonymous { get; set; } = true;
public bool AnonymousCanWrite { get; set; } = true;
public List<UserCredential> Users { get; set; } = new();
}
public class UserCredential
{
public string Username { get; set; } = "";
public string Password { get; set; } = "";
}
Add to AppConfiguration and bind in OpcUaService.
2. User authentication provider — IUserAuthenticationProvider
Pluggable interface so the backing store can be swapped (file-based now, LDAP later):
public interface IUserAuthenticationProvider
{
bool ValidateCredentials(string username, string password);
}
File-based implementation — ConfigUserAuthenticationProvider:
public class ConfigUserAuthenticationProvider : IUserAuthenticationProvider
{
private readonly Dictionary<string, string> _users;
public ConfigUserAuthenticationProvider(List<UserCredential> users)
{
_users = users.ToDictionary(u => u.Username, u => u.Password, StringComparer.OrdinalIgnoreCase);
}
public bool ValidateCredentials(string username, string password)
{
return _users.TryGetValue(username, out var expected) && expected == password;
}
}
3. UserTokenPolicies — OpcUaServerHost.cs
Update the ServerConfiguration.UserTokenPolicies based on config:
var policies = new UserTokenPolicyCollection();
if (authConfig.AllowAnonymous)
policies.Add(new UserTokenPolicy(UserTokenType.Anonymous));
if (authConfig.Users.Count > 0)
policies.Add(new UserTokenPolicy(UserTokenType.UserName));
If AllowAnonymous is false and no users are configured, the server cannot accept connections — log an error.
4. Session impersonation — LmxOpcUaServer.cs
Override CreateMasterNodeManager or startup initialization to register the ImpersonateUser event:
protected override void OnServerStarted(IServerInternal server)
{
base.OnServerStarted(server);
server.SessionManager.ImpersonateUser += OnImpersonateUser;
}
Implement OnImpersonateUser:
private void OnImpersonateUser(Session session, ImpersonateEventArgs args)
{
if (args.NewIdentity is AnonymousIdentityToken)
{
if (!_authConfig.AllowAnonymous)
throw new ServiceResultException(StatusCodes.BadIdentityTokenRejected);
var roles = new NodeIdCollection { ObjectIds.WellKnownRole_Anonymous };
if (_authConfig.AnonymousCanWrite)
roles.Add(ObjectIds.WellKnownRole_AuthenticatedUser); // grants write via RBAC
args.Identity = new RoleBasedIdentity(new UserIdentity(args.NewIdentity as AnonymousIdentityToken), roles);
return;
}
if (args.NewIdentity is UserNameIdentityToken userNameToken)
{
var password = Encoding.UTF8.GetString(userNameToken.DecryptedPassword);
if (!_authProvider.ValidateCredentials(userNameToken.UserName, password))
throw new ServiceResultException(StatusCodes.BadUserAccessDenied);
args.Identity = new RoleBasedIdentity(
new UserIdentity(userNameToken),
new NodeIdCollection { ObjectIds.WellKnownRole_AuthenticatedUser });
return;
}
throw new ServiceResultException(StatusCodes.BadIdentityTokenRejected);
}
5. Write access enforcement — LmxNodeManager.cs
The OPC UA framework already enforces RolePermissions on nodes via base.Write(). To use this, set RolePermissions on variable nodes during address space build.
In CreateAttributeVariable, after setting AccessLevel:
variable.RolePermissions = new RolePermissionTypeCollection
{
new RolePermissionType
{
RoleId = ObjectIds.WellKnownRole_Anonymous,
Permissions = (uint)(PermissionType.Browse | PermissionType.Read | PermissionType.ReadRolePermissions)
},
new RolePermissionType
{
RoleId = ObjectIds.WellKnownRole_AuthenticatedUser,
Permissions = (uint)(PermissionType.Browse | PermissionType.Read | PermissionType.ReadRolePermissions | PermissionType.Write)
}
};
When AnonymousCanWrite is true, add PermissionType.Write to the Anonymous role permissions.
Alternative simpler approach: Skip per-node RolePermissions and check roles manually in the Write override:
// In Write override, before processing:
if (context.UserIdentity?.GrantedRoleIds != null &&
!context.UserIdentity.GrantedRoleIds.Contains(ObjectIds.WellKnownRole_AuthenticatedUser))
{
errors[i] = new ServiceResult(StatusCodes.BadUserAccessDenied);
continue;
}
The manual approach is simpler and avoids setting RolePermissions on every variable node. Use this for Phase 1.
6. Wire configuration through the stack
Pass AuthenticationConfiguration and IUserAuthenticationProvider through:
OpcUaService→OpcUaServerHost→LmxOpcUaServerLmxOpcUaServeruses them inOnImpersonateUserOpcUaServerHostusesAllowAnonymousand user count forUserTokenPoliciesLmxNodeManagerreceivesAnonymousCanWriteflag for write enforcement
7. CLI tool authentication — tools/opcuacli-dotnet/
Add --username and --password options to OpcUaHelper.ConnectAsync so all commands can authenticate.
OpcUaHelper.cs — update ConnectAsync to accept optional credentials:
public static async Task<Session> ConnectAsync(string endpointUrl, string? username = null, string? password = null)
{
// ... existing config setup ...
UserIdentity identity = (username != null)
? new UserIdentity(username, password ?? "")
: new UserIdentity();
var session = await Session.Create(
config, configuredEndpoint, false,
"OpcUaCli", 60000, identity, null);
return session;
}
Each command — add shared options. Since CliFx doesn't support base classes for shared options, add -U / -P to each command:
[CommandOption("username", 'U', Description = "Username for authentication")]
public string? Username { get; init; }
[CommandOption("password", 'P', Description = "Password for authentication")]
public string? Password { get; init; }
And pass to OpcUaHelper.ConnectAsync(Url, Username, Password).
Usage examples:
# Anonymous (current behavior)
dotnet run -- read -u opc.tcp://localhost:4840/LmxOpcUa -n "ns=1;s=TestMachine_001.MachineID"
# Authenticated
dotnet run -- read -u opc.tcp://localhost:4840/LmxOpcUa -n "ns=1;s=TestMachine_001.MachineID" -U operator -P op123
# Write with credentials (when AnonymousCanWrite is false)
dotnet run -- write -u opc.tcp://localhost:4840/LmxOpcUa -n "ns=1;s=TestMachine_001.MachineID" -v "NEW" -U operator -P op123
README update — document the -U / -P flags.
Files to Create/Modify
| File | Change |
|---|---|
src/.../Configuration/AuthenticationConfiguration.cs |
NEW — config class with AllowAnonymous, AnonymousCanWrite, Users |
src/.../Configuration/AppConfiguration.cs |
Add Authentication property |
src/.../Domain/IUserAuthenticationProvider.cs |
NEW — pluggable auth interface |
src/.../Domain/ConfigUserAuthenticationProvider.cs |
NEW — file-based implementation |
src/.../OpcUa/OpcUaServerHost.cs |
Dynamic UserTokenPolicies, pass auth config |
src/.../OpcUa/LmxOpcUaServer.cs |
Register ImpersonateUser handler, validate credentials |
src/.../OpcUa/LmxNodeManager.cs |
Check user role in Write override when AnonymousCanWrite=false |
src/.../OpcUaService.cs |
Bind Authentication config, create provider, pass through |
src/.../appsettings.json |
Add Authentication section |
tests/.../Authentication/UserAuthenticationTests.cs |
NEW — credential validation tests |
tests/.../Integration/WriteAccessTests.cs |
NEW — anonymous vs authenticated write tests |
tools/opcuacli-dotnet/OpcUaHelper.cs |
Add username/password parameters to ConnectAsync |
tools/opcuacli-dotnet/Commands/*.cs |
Add -U / -P options to all 7 commands |
tools/opcuacli-dotnet/README.md |
Document authentication flags |
docs/Configuration.md |
Add Authentication section with settings table |
docs/OpcUaServer.md |
Update security policy and UserTokenPolicies section |
docs/ReadWriteOperations.md |
Document role-based write enforcement |
docs/CliTool.md |
Document -U / -P authentication flags |
Tests
Unit tests — tests/.../Authentication/UserAuthenticationTests.cs (NEW)
ConfigUserAuthenticationProvidervalidates correct username/passwordConfigUserAuthenticationProviderrejects wrong passwordConfigUserAuthenticationProviderrejects unknown usernameConfigUserAuthenticationProvideris case-insensitive on username- Empty user list rejects all credentials
AuthenticationConfigurationdefaults:AllowAnonymous=true,AnonymousCanWrite=true, empty Users list
Integration tests — tests/.../Integration/WriteAccessTests.cs (NEW)
- Anonymous connect succeeds when
AllowAnonymous=true - Anonymous connect rejected when
AllowAnonymous=false(requires test fixture to configure auth) - Anonymous read succeeds regardless of
AnonymousCanWrite - Anonymous write succeeds when
AnonymousCanWrite=true - Anonymous write rejected with
BadUserAccessDeniedwhenAnonymousCanWrite=false - Authenticated user write succeeds when
AnonymousCanWrite=false - Invalid credentials rejected with
BadUserAccessDenied
Existing test updates
OpcUaServerFixture— add option to configureAuthenticationConfigurationfor auth-aware test fixtures- Existing write tests continue passing (default config allows anonymous writes)
Verification
- Build clean, all tests pass
- Deploy with
AllowAnonymous: true, AnonymousCanWrite: true— current behavior preserved - Deploy with
AllowAnonymous: true, AnonymousCanWrite: false:- Anonymous connect succeeds, reads work
- Anonymous writes rejected with
BadUserAccessDenied - Authenticated user writes succeed
- Deploy with
AllowAnonymous: false:- Anonymous connect rejected
- Username/password connect succeeds
- CLI tool:
connectwith and without credentials - Invalid credentials →
BadUserAccessDenied
Future Extensions
- LDAP provider: Implement
IUserAuthenticationProviderbacked by LDAP/Active Directory - Certificate auth: Add
UserTokenType.Certificatepolicy and X.509 validation - Per-tag permissions: Map Galaxy security groups to OPC UA roles for fine-grained access
- Audit logging: Log authentication events (connect, disconnect, access denied)