Files
lmxopcua/auth_update.md
Joseph Doherty b27d355763 Fix alarm acknowledge EventId validation and add auth plan
Set a new EventId (GUID) on AlarmConditionState each time an alarm event
is reported so the framework can match it when clients call Acknowledge.
Without this, the framework rejected all ack attempts with BadEventIdUnknown.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 01:39:21 -04:00

8.4 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

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
docs/Configuration.md Update with Authentication section

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)