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>
314 lines
12 KiB
Markdown
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)
|