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>
This commit is contained in:
Joseph Doherty
2026-03-27 02:14:37 -04:00
parent b27d355763
commit bbd043e97b
24 changed files with 499 additions and 34 deletions

View File

@@ -20,6 +20,23 @@ dotnet run -- <command> [options]
`OpcUaHelper.ConvertValue()` converts a raw string from the command line into the runtime type expected by the target node. It uses the current node value to infer the type (bool, byte, short, int, float, double, etc.) and falls back to string if the type is not recognized.
## Authentication Options
All commands accept optional credentials for `UserName` token authentication:
| Flag | Description |
|------|-------------|
| `-U` / `--username` | Username for OPC UA `UserName` token authentication |
| `-P` / `--password` | Password for OPC UA `UserName` token authentication |
When `-U` and `-P` are provided, `OpcUaHelper.ConnectAsync()` passes a `UserIdentity(username, password)` to `Session.Create`. Without credentials, an anonymous `UserIdentity` is used.
Example:
```bash
dotnet run -- write -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -v 42 -U operator -P op123
```
## Commands
### connect

View File

@@ -21,6 +21,7 @@ configuration.GetSection("MxAccess").Bind(_config.MxAccess);
configuration.GetSection("GalaxyRepository").Bind(_config.GalaxyRepository);
configuration.GetSection("Dashboard").Bind(_config.Dashboard);
configuration.GetSection("Historian").Bind(_config.Historian);
configuration.GetSection("Authentication").Bind(_config.Authentication);
```
This pattern uses `IConfiguration.GetSection().Bind()` rather than `IOptions<T>` because the service targets .NET Framework 4.8, where the full dependency injection container is not used.
@@ -102,6 +103,33 @@ Controls the Wonderware Historian connection for OPC UA historical data access.
| `CommandTimeoutSeconds` | `int` | `30` | SQL command timeout for historian queries |
| `MaxValuesPerRead` | `int` | `10000` | Maximum values returned per `HistoryRead` request |
### Authentication
Controls user authentication and write authorization for the OPC UA server. Defined in `AuthenticationConfiguration`.
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `AllowAnonymous` | `bool` | `true` | Accepts anonymous client connections when `true` |
| `AnonymousCanWrite` | `bool` | `true` | Permits anonymous users to write when `true` |
| `Users` | `List<UserCredential>` | `[]` | List of username/password credentials for `UserName` token authentication |
Each entry in the `Users` list has two properties: `Username` (string) and `Password` (string).
The defaults preserve the existing behavior: anonymous clients can connect, read, and write with no credentials required. To restrict writes to authenticated users, set `AnonymousCanWrite` to `false` and add entries to the `Users` list.
Example configuration:
```json
"Authentication": {
"AllowAnonymous": true,
"AnonymousCanWrite": false,
"Users": [
{ "Username": "operator", "Password": "op123" },
{ "Username": "engineer", "Password": "eng456" }
]
}
```
## Feature Flags
Three boolean properties act as feature flags that control optional subsystems:
@@ -177,6 +205,11 @@ Integration tests use this constructor to inject substitute implementations of `
"ConnectionString": "Server=localhost;Database=Runtime;Integrated Security=true;",
"CommandTimeoutSeconds": 30,
"MaxValuesPerRead": 10000
},
"Authentication": {
"AllowAnonymous": true,
"AnonymousCanWrite": true,
"Users": []
}
}
```

View File

@@ -33,19 +33,33 @@ The configuration covers:
## Security Policy
The server runs with `MessageSecurityMode.None` and `SecurityPolicies.None`. Only anonymous user tokens are accepted:
The server runs with `MessageSecurityMode.None` and `SecurityPolicies.None`:
```csharp
SecurityPolicies = { new ServerSecurityPolicy
{
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = SecurityPolicies.None
} },
UserTokenPolicies = { new UserTokenPolicy(UserTokenType.Anonymous) }
} }
```
This is intentional for plant-floor deployments where the server sits on an isolated OT network. Galaxy-level security classification controls write access per attribute rather than at the transport layer.
### User token policies
`UserTokenPolicies` are dynamically configured based on the `Authentication` settings in `appsettings.json`:
- An `Anonymous` user token policy is added when `AllowAnonymous` is `true` (the default).
- A `UserName` user token policy is added when the `Users` list contains at least one entry.
Both policies can be active simultaneously, allowing clients to connect with or without credentials.
### Session impersonation
When a client presents `UserName` credentials, the server validates them through `IUserAuthenticationProvider`. If the credentials do not match any entry in the configured `Users` list, the session is rejected.
On successful validation, the session identity is set to a `RoleBasedIdentity` that carries the user's granted role IDs. Authenticated users receive the `WellKnownRole_AuthenticatedUser` role. Anonymous connections receive the `WellKnownRole_Anonymous` role. These roles are used downstream by the write override to enforce `AnonymousCanWrite` restrictions.
## Certificate handling
On startup, `OpcUaServerHost.StartAsync` calls `CheckApplicationInstanceCertificate(false, 2048)` to locate or create a 2048-bit self-signed certificate. The certificate subject follows the format `CN={ServerName}, O=ZB MOM, DC=localhost`. Certificate stores use the directory-based store type under `%LOCALAPPDATA%\OPC Foundation\pki\`:

View File

@@ -64,6 +64,17 @@ nextArray.SetValue(ConvertArrayElementValue(normalizedValue, elementType), index
updatedArray = nextArray;
```
### Role-based write enforcement
When `AnonymousCanWrite` is `false` in the `Authentication` configuration, the write override enforces role-based access control before dispatching to MXAccess. The check order is:
1. The base class `Write` runs first, enforcing `AccessLevel`. Nodes without `CurrentWrite` get `BadNotWritable` and the override skips them.
2. The override checks whether the node is in the Galaxy namespace. Non-namespace nodes are skipped.
3. If `AnonymousCanWrite` is `false`, the override inspects `context.OperationContext.Session` for `GrantedRoleIds`. If the session does not hold `WellKnownRole_AuthenticatedUser`, the error is set to `BadUserAccessDenied` and the write is rejected.
4. If the role check passes (or `AnonymousCanWrite` is `true`), the write proceeds to MXAccess.
The existing security classification enforcement (ReadOnly nodes getting `BadNotWritable` via `AccessLevel`) still applies first and takes precedence over the role check.
## Value Type Conversion
`CreatePublishedDataValue` wraps the conversion pipeline. `NormalizePublishedValue` checks whether the tag is an array type with a declared `ArrayDimension` and substitutes a default typed array (via `CreateDefaultArrayValue`) when the raw value is null. This prevents OPC UA clients from receiving a null variant for array nodes, which violates the specification for nodes declared with `ValueRank.OneDimension`.