Files
lmxopcua/auth_update.md
Joseph Doherty bbd043e97b Add authentication and role-based write access control
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>
2026-03-27 02:14:37 -04:00

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 implementationConfigUserAuthenticationProvider:

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:

  • OpcUaServiceOpcUaServerHostLmxOpcUaServer
  • LmxOpcUaServer uses them in OnImpersonateUser
  • OpcUaServerHost uses AllowAnonymous and user count for UserTokenPolicies
  • LmxNodeManager receives AnonymousCanWrite flag 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)

  • ConfigUserAuthenticationProvider validates correct username/password
  • ConfigUserAuthenticationProvider rejects wrong password
  • ConfigUserAuthenticationProvider rejects unknown username
  • ConfigUserAuthenticationProvider is case-insensitive on username
  • Empty user list rejects all credentials
  • AuthenticationConfiguration defaults: 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 BadUserAccessDenied when AnonymousCanWrite=false
  • Authenticated user write succeeds when AnonymousCanWrite=false
  • Invalid credentials rejected with BadUserAccessDenied

Existing test updates

  • OpcUaServerFixture — add option to configure AuthenticationConfiguration for auth-aware test fixtures
  • Existing write tests continue passing (default config allows anonymous writes)

Verification

  1. Build clean, all tests pass
  2. Deploy with AllowAnonymous: true, AnonymousCanWrite: true — current behavior preserved
  3. Deploy with AllowAnonymous: true, AnonymousCanWrite: false:
    • Anonymous connect succeeds, reads work
    • Anonymous writes rejected with BadUserAccessDenied
    • Authenticated user writes succeed
  4. Deploy with AllowAnonymous: false:
    • Anonymous connect rejected
    • Username/password connect succeeds
  5. CLI tool: connect with and without credentials
  6. Invalid credentials → BadUserAccessDenied

Future Extensions

  • LDAP provider: Implement IUserAuthenticationProvider backed by LDAP/Active Directory
  • Certificate auth: Add UserTokenType.Certificate policy 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)