# 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`: ```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` ```csharp public class AuthenticationConfiguration { public bool AllowAnonymous { get; set; } = true; public bool AnonymousCanWrite { get; set; } = true; public List 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): ```csharp public interface IUserAuthenticationProvider { bool ValidateCredentials(string username, string password); } ``` **File-based implementation** — `ConfigUserAuthenticationProvider`: ```csharp public class ConfigUserAuthenticationProvider : IUserAuthenticationProvider { private readonly Dictionary _users; public ConfigUserAuthenticationProvider(List 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: ```csharp 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: ```csharp protected override void OnServerStarted(IServerInternal server) { base.OnServerStarted(server); server.SessionManager.ImpersonateUser += OnImpersonateUser; } ``` Implement `OnImpersonateUser`: ```csharp 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`: ```csharp 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: ```csharp // 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` → `LmxOpcUaServer` - `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)