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

314 lines
12 KiB
Markdown

# 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<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):
```csharp
public interface IUserAuthenticationProvider
{
bool ValidateCredentials(string username, string password);
}
```
**File-based implementation**`ConfigUserAuthenticationProvider`:
```csharp
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:
```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<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:
```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)