# 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 ### 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: ```csharp public static async Task 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: ```csharp [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:** ```bash # 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)