Compare commits

...

6 Commits

Author SHA1 Message Date
Joseph Doherty
b5f8661e98 Phase 3 PR 27 — Fleet status dashboard page. New /fleet route shows per-node apply state (ClusterNodeGenerationState joined with ClusterNode for the ClusterId) in a sortable table with summary cards for Total / Applied / Stale / Failed node counts. Stale detection: LastSeenAt older than 30s triggers a table-warning row class + yellow count card. Failed rows get table-danger + red card. Badge classes per LastAppliedStatus: Applied=bg-success, Failed=bg-danger, Applying=bg-info, unknown=bg-secondary. Timestamps rendered as relative-age strings ('42s ago', '15m ago', '3h ago', then absolute date for >24h). Error column is truncated to 320px with the full message in a tooltip so the table stays readable on wide fleets. Initial data load on OnInitializedAsync; auto-refresh every 5s via a Timer that calls InvokeAsync(RefreshAsync) — matches the FleetStatusPoller's 5s cadence so the dashboard sees the most recent state without polling ahead of the broadcaster. A Refresh button also kicks a manual reload; _refreshing gate prevents double-runs when the timer fires during an in-flight query. IServiceScopeFactory (matches FleetStatusPoller's pattern) creates a fresh DI scope per refresh so the per-page DbContext can't race the timer with the render thread; no new DI registrations needed. Live SignalR hub push is deliberately deferred to a follow-up PR — the existing FleetStatusHub + NodeStateChangedMessage already works for external JavaScript clients; wiring an in-process Blazor Server consumer adds HubConnectionBuilder plumbing that's worth its own focused change. Sidebar link added to MainLayout between Overview and Clusters. Full Admin.Tests Unit suite 14 pass / 0 fail — unchanged, no tests regressed. Full Admin build clean (0 errors, 0 warnings). Closes the 'no per-driver dashboard' gap from lmx-followups item #7 at the fleet level; per-host (platform/engine/Modbus PLC) granularity still needs a dedicated page that consumes IHostConnectivityProbe.GetHostStatuses from the Server process — that's the live-SignalR follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 13:12:31 -04:00
4058b88784 Merge pull request 'Phase 3 PR 26 — server-layer write authorization by role' (#25) from phase-3-pr26-server-write-authz into v2 2026-04-18 13:04:35 -04:00
Joseph Doherty
6b04a85f86 Phase 3 PR 26 — server-layer write authorization gating by role. Per the user's ACL-at-server-layer directive (saved as feedback_acl_at_server_layer.md in memory), write authorization is enforced in DriverNodeManager.OnWriteValue and never delegated to the driver or to driver-specific auth (the v1 Galaxy-provided security path is explicitly not part of v2 — drivers report SecurityClassification as discovery metadata only). New WriteAuthzPolicy static class in Server/Security/ maps SecurityClassification → required role per the table documented in docs/Configuration.md: FreeAccess = no role required (anonymous sessions can write), Operate + SecuredWrite = WriteOperate, Tune = WriteTune, VerifiedWrite + Configure = WriteConfigure, ViewOnly = deny regardless of roles. Role matching is case-insensitive and role requirements do NOT cascade — a session with WriteConfigure can write Configure attributes but needs WriteOperate separately to write Operate attributes; this is deliberate so escalation is an explicit LDAP group assignment, not a hierarchy the policy silently grants. DriverNodeManager gains a _securityByFullRef Dictionary populated during Variable() registration (parallel to the existing _variablesByFullRef) so OnWriteValue can look up the classification in O(1) on the hot path. OnWriteValue casts the session's context.UserIdentity to the new IRoleBearer interface (implemented by OtOpcUaServer.RoleBasedIdentity from PR 19) — empty Roles collection when the session is anonymous; the same WriteAuthzPolicy.IsAllowed check then either short-circuits true (FreeAccess), false (ViewOnly), or walks the roles list looking for the required one. On deny, OnWriteValue logs 'Write denied for {FullRef}: classification=X userRoles=[...]' at Information level (readable trail for operator complaints) and returns BadUserAccessDenied without touching IWritable.WriteAsync — drivers never see a request we'd have refused. IRoleBearer kept as a minimal server-side interface rather than reusing some abstraction from Core.Abstractions because the concept is OPC-UA-session-scoped and doesn't generalize (the driver side has no notion of a user session). Tests — WriteAuthzPolicyTests (17 new cases): FreeAccess allows write with empty role set + arbitrary roles; ViewOnly denies write even with every role; Operate requires WriteOperate; role match is case-insensitive; Operate denies empty role set + wrong role; SecuredWrite shares Operate's requirement; Tune requires WriteTune; Tune denies WriteOperate-only (asserts roles don't cascade — this is the test that catches a future regression where someone 'helpfully' adds a role-escalation table); Configure requires WriteConfigure; VerifiedWrite shares Configure's requirement; multi-role session allowed when any role matches; unrelated roles denied; RequiredRole theory covering all 5 classified-and-mapped rows + null for FreeAccess/ViewOnly special cases. lmx-followups.md follow-up #2 marked DONE with a back-reference to this PR and the memory note. Full Server.Tests Unit suite: 38 pass / 0 fail (17 new WriteAuthz + 14 SecurityConfiguration from PR 19 + 2 NodeBootstrap + 5 others). Server.Tests Integration (Category=Integration) 2 pass — existing PR 17 anonymous-endpoint smoke tests stay green since the read path doesn't hit OnWriteValue.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 13:01:01 -04:00
cd8691280a Merge pull request 'Phase 3 PR 25 — Modbus test plan + DL205 quirk catalog' (#24) from phase-3-pr25-modbus-test-plan into v2 2026-04-18 12:49:19 -04:00
Joseph Doherty
77d09bf64e Phase 3 PR 25 — modbus-test-plan.md: integration-test playbook with per-device quirk catalog. ModbusPal is the chosen simulator; AutomationDirect DL205 is the first target device class with 6 pending quirks to document and cover with named tests (word order for 32-bit values, register-zero access policy, coil addressing base, maximum registers per FC03, response framing under sustained load, exception code on protected-bit coil write). Each quirk placeholder has a proposed test name so the user's validation work translates directly into integration tests. Test conventions section codifies the named-per-quirk pattern, skip-when-unreachable guard, real ModbusTcpTransport usage, and inter-test isolation. Sets up the harness-and-catalog structure future device families (Allen-Bradley Micrologix, Siemens S7-1200 Modbus gateway, Schneider M340, whatever the user hits) will slot into — same per-device catalog shape, cross-device patterns section for recurring quirks that can get promoted into driver defaults. Next concrete PRs proposed: PR 26 for the integration test project scaffold + DL205 profile + fixture with skip-guard + one smoke test, PR 27+ for the individual confirmed quirks one-per-PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 12:45:21 -04:00
163c821e74 Merge pull request 'Phase 3 PR 24 — Modbus PLC data type extensions' (#23) from phase-3-pr24-modbus-types into v2 2026-04-18 12:32:55 -04:00
9 changed files with 529 additions and 14 deletions

View File

@@ -27,21 +27,19 @@ only exposes `ReadRawAsync` + `ReadProcessedAsync`.
- Integration test: OPC UA client calls `HistoryReadAtTime` / `HistoryReadEvents`,
value flows through IPC to the Host's `HistorianDataSource`, back to the client.
## 2. Write-gating by role
## 2. Write-gating by role — **DONE (PR 26)**
**Status**: `RoleBasedIdentity.Roles` populated on the session (PR 19) but
`DriverNodeManager.OnWriteValue` doesn't consult it.
Landed in PR 26. `WriteAuthzPolicy` in `Server/Security/` maps
`SecurityClassification` → required role (`FreeAccess` → no role required,
`Operate`/`SecuredWrite``WriteOperate`, `Tune``WriteTune`,
`Configure`/`VerifiedWrite``WriteConfigure`, `ViewOnly` → deny regardless).
`DriverNodeManager` caches the classification per variable during discovery and
checks the session's roles (via `IRoleBearer`) in `OnWriteValue` before calling
`IWritable.WriteAsync`. Roles do not cascade — a session with `WriteOperate`
can't write a `Tune` attribute unless it also carries `WriteTune`.
CLAUDE.md defines the role set: `ReadOnly` / `WriteOperate` / `WriteTune` /
`WriteConfigure` / `AlarmAck`. Each `DriverAttributeInfo.SecurityClassification`
maps to a required role for writes.
**To do**:
- Add a `RoleRequirements` table: `SecurityClassification` → required role.
- `OnWriteValue` reads `context.UserIdentity` → cast to `RoleBasedIdentity`
→ check role membership before calling `IWritable.WriteAsync`. Return
`BadUserAccessDenied` on miss.
- Unit test against a fake `ISystemContext` with varying role sets.
See `feedback_acl_at_server_layer.md` in memory for the architectural directive
that authz stays at the server layer and never delegates to driver-specific auth.
## 3. Admin UI client-cert trust management

103
docs/v2/modbus-test-plan.md Normal file
View File

@@ -0,0 +1,103 @@
# Modbus driver — test plan + device-quirk catalog
The Modbus TCP driver unit tests (PRs 2124) cover the protocol surface against an
in-memory fake transport. They validate the codec, state machine, and function-code
routing against a textbook Modbus server. That's necessary but not sufficient: real PLC
populations disagree with the spec in small, device-specific ways, and a driver that
passes textbook tests can still misbehave against actual equipment.
This doc is the harness-and-quirks playbook. It's what gets wired up in the
`tests/Driver.Modbus.IntegrationTests` project when we ship that (PR 26 candidate).
## Harness
**Chosen simulator: ModbusPal** (Java, scriptable). Rationale:
- Scriptable enough to mimic device-specific behaviors (non-standard register
layouts, custom exception codes, intentional response delays).
- Runs locally, no CI dependency. Tests skip when `localhost:502` (or the configured
simulator endpoint) isn't reachable.
- Free + long-maintained — physical PLC bench is unavailable in most dev
environments, and renting cloud PLCs isn't worth the per-test cost.
**Setup pattern** (not yet codified in a script — will land alongside the integration
test project):
1. Install ModbusPal, load the per-device `.xmpp` profile from
`tests/Driver.Modbus.IntegrationTests/ModbusPal/` (TBD directory).
2. Start the simulator listening on `localhost:502` (or override via
`MODBUS_SIM_ENDPOINT` env var).
3. `dotnet test` the integration project — tests auto-skip when the endpoint is
unreachable, so forgetting to start the simulator doesn't wedge CI.
## Per-device quirk catalog
### AutomationDirect DL205
First known target device. Quirks to document and cover with named tests (to be
filled in when user validates each behavior in ModbusPal with a DL205 profile):
- **Word order for 32-bit values**: _pending_ — confirm whether DL205 uses ABCD
(Modbus TCP standard) or CDAB (Siemens-style word-swap) for Int32/UInt32/Float32.
Test name: `DL205_Float32_word_order_is_CDAB` (or `ABCD`, whichever proves out).
- **Register-zero access**: _pending_ — some DL205 configurations reject FC03 at
register 0 with exception code 02 (illegal data address). If confirmed, the
integration test suite verifies `ModbusProbeOptions.ProbeAddress` default of 0
triggers the rejection and operators must override; test name:
`DL205_FC03_at_register_0_returns_IllegalDataAddress`.
- **Coil addressing base**: _pending_ — DL205 documentation sometimes uses 1-based
coil addresses; verify the driver's zero-based addressing matches the physical
PLC without an off-by-one adjustment.
- **Maximum registers per FC03**: _pending_ — Modbus spec caps at 125; some DL205
models enforce a lower limit (e.g., 64). Test name:
`DL205_FC03_beyond_max_registers_returns_IllegalDataValue`.
- **Response framing under sustained load**: _pending_ — the driver's
single-flight semaphore assumes the server pairs requests/responses by
transaction id; at least one DL205 firmware revision is reported to drop the
TxId under load. If reproduced in ModbusPal we add a retry + log-and-continue
path to `ModbusTcpTransport`.
- **Exception code on coil write to a protected bit**: _pending_ — some DL205
setups protect internal coils; the driver should surface the PLC's exception
PDU as `BadNotWritable` rather than `BadInternalError`.
_User action item_: as each quirk is validated in ModbusPal, replace the _pending_
marker with the confirmed behavior and file a named test in the integration suite.
### Future devices
One section per device class, same shape as DL205. Quirks that apply across
multiple devices (e.g., "all AB PLCs use CDAB") can be noted in the cross-device
patterns section below once we have enough data points.
## Cross-device patterns
Once multiple device catalogs accumulate, quirks that recur across two or more
vendors get promoted into driver defaults or opt-in options:
- _(empty — filled in as catalogs grow)_
## Test conventions
- **One named test per quirk.** `DL205_word_order_is_CDAB_for_Float32` is easier to
diagnose on failure than a generic `Float32_roundtrip`. The `DL205_` prefix makes
filtering by device class trivial (`--filter "DisplayName~DL205"`).
- **Skip with a clear SkipReason.** Follow the pattern from
`GalaxyRepositoryLiveSmokeTests`: check reachability in the fixture, capture
a `SkipReason` string, and have each test call `Assert.Skip(SkipReason)` when
it's set. Don't throw — skipped tests read cleanly in CI logs.
- **Use the real `ModbusTcpTransport`.** Integration tests exercise the wire
protocol end-to-end. The in-memory `FakeTransport` from the unit test suite is
deliberately not used here — its value is speed + determinism, which doesn't
help reproduce device-specific issues.
- **Don't depend on ModbusPal state between tests.** Each test resets the
simulator's register bank or uses a unique address range. Avoid relying on
"previous test left value at register 10" setups that flake when tests run in
parallel or re-order.
## Next concrete PRs
- **PR 26 — Integration test project + DL205 profile scaffold**: creates
`tests/Driver.Modbus.IntegrationTests`, imports the ModbusPal profile (or
generates it from JSON), adds the fixture with skip-when-unreachable, plus
one smoke test that reads a register. No DL205-specific assertions yet — that
waits for the user to validate each quirk.
- **PR 27+**: one PR per confirmed DL205 quirk, landing the named test + any
driver-side adjustment (e.g., retry on dropped TxId) needed to pass it.

View File

@@ -5,6 +5,7 @@
<h5 class="mb-4">OtOpcUa Admin</h5>
<ul class="nav flex-column">
<li class="nav-item"><a class="nav-link text-light" href="/">Overview</a></li>
<li class="nav-item"><a class="nav-link text-light" href="/fleet">Fleet status</a></li>
<li class="nav-item"><a class="nav-link text-light" href="/clusters">Clusters</a></li>
<li class="nav-item"><a class="nav-link text-light" href="/reservations">Reservations</a></li>
</ul>

View File

@@ -0,0 +1,172 @@
@page "/fleet"
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@inject IServiceScopeFactory ScopeFactory
@implements IDisposable
<h1 class="mb-4">Fleet status</h1>
<div class="d-flex align-items-center mb-3 gap-2">
<button class="btn btn-sm btn-outline-primary" @onclick="RefreshAsync" disabled="@_refreshing">
@if (_refreshing) { <span class="spinner-border spinner-border-sm me-1" /> }
Refresh
</button>
<span class="text-muted small">
Auto-refresh every @RefreshIntervalSeconds s. Last updated: @(_lastRefreshUtc?.ToString("HH:mm:ss 'UTC'") ?? "—")
</span>
</div>
@if (_rows is null)
{
<p>Loading…</p>
}
else if (_rows.Count == 0)
{
<div class="alert alert-info">
No node state recorded yet. Nodes publish their state to the central DB on each poll; if
this list is empty, either no nodes have been registered or the poller hasn't run yet.
</div>
}
else
{
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="card"><div class="card-body">
<h6 class="text-muted mb-1">Nodes</h6>
<div class="fs-3">@_rows.Count</div>
</div></div>
</div>
<div class="col-md-3">
<div class="card border-success"><div class="card-body">
<h6 class="text-muted mb-1">Applied</h6>
<div class="fs-3 text-success">@_rows.Count(r => r.Status == "Applied")</div>
</div></div>
</div>
<div class="col-md-3">
<div class="card border-warning"><div class="card-body">
<h6 class="text-muted mb-1">Stale</h6>
<div class="fs-3 text-warning">@_rows.Count(r => IsStale(r))</div>
</div></div>
</div>
<div class="col-md-3">
<div class="card border-danger"><div class="card-body">
<h6 class="text-muted mb-1">Failed</h6>
<div class="fs-3 text-danger">@_rows.Count(r => r.Status == "Failed")</div>
</div></div>
</div>
</div>
<table class="table table-hover align-middle">
<thead>
<tr>
<th>Node</th>
<th>Cluster</th>
<th>Generation</th>
<th>Status</th>
<th>Last applied</th>
<th>Last seen</th>
<th>Error</th>
</tr>
</thead>
<tbody>
@foreach (var r in _rows)
{
<tr class="@RowClass(r)">
<td><code>@r.NodeId</code></td>
<td>@r.ClusterId</td>
<td>@(r.GenerationId?.ToString() ?? "—")</td>
<td>
<span class="badge @StatusBadge(r.Status)">@(r.Status ?? "—")</span>
</td>
<td>@FormatAge(r.AppliedAt)</td>
<td class="@(IsStale(r) ? "text-warning" : "")">@FormatAge(r.SeenAt)</td>
<td class="text-truncate" style="max-width: 320px;" title="@r.Error">@r.Error</td>
</tr>
}
</tbody>
</table>
}
@code {
// Refresh cadence. 5s matches FleetStatusPoller's poll interval — the dashboard always sees
// the most recent published state without polling ahead of the broadcaster.
private const int RefreshIntervalSeconds = 5;
private List<FleetNodeRow>? _rows;
private bool _refreshing;
private DateTime? _lastRefreshUtc;
private Timer? _timer;
protected override async Task OnInitializedAsync()
{
await RefreshAsync();
_timer = new Timer(async _ => await InvokeAsync(RefreshAsync),
state: null,
dueTime: TimeSpan.FromSeconds(RefreshIntervalSeconds),
period: TimeSpan.FromSeconds(RefreshIntervalSeconds));
}
private async Task RefreshAsync()
{
if (_refreshing) return;
_refreshing = true;
try
{
using var scope = ScopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
var rows = await db.ClusterNodeGenerationStates.AsNoTracking()
.Join(db.ClusterNodes.AsNoTracking(), s => s.NodeId, n => n.NodeId, (s, n) => new FleetNodeRow(
s.NodeId, n.ClusterId, s.CurrentGenerationId,
s.LastAppliedStatus != null ? s.LastAppliedStatus.ToString() : null,
s.LastAppliedError, s.LastAppliedAt, s.LastSeenAt))
.OrderBy(r => r.ClusterId)
.ThenBy(r => r.NodeId)
.ToListAsync();
_rows = rows;
_lastRefreshUtc = DateTime.UtcNow;
}
finally
{
_refreshing = false;
StateHasChanged();
}
}
private static bool IsStale(FleetNodeRow r)
{
if (r.SeenAt is null) return true;
return (DateTime.UtcNow - r.SeenAt.Value) > TimeSpan.FromSeconds(30);
}
private static string RowClass(FleetNodeRow r) => r.Status switch
{
"Failed" => "table-danger",
_ when IsStale(r) => "table-warning",
_ => "",
};
private static string StatusBadge(string? status) => status switch
{
"Applied" => "bg-success",
"Failed" => "bg-danger",
"Applying" => "bg-info",
_ => "bg-secondary",
};
private static string FormatAge(DateTime? t)
{
if (t is null) return "—";
var age = DateTime.UtcNow - t.Value;
if (age.TotalSeconds < 60) return $"{(int)age.TotalSeconds}s ago";
if (age.TotalMinutes < 60) return $"{(int)age.TotalMinutes}m ago";
if (age.TotalHours < 24) return $"{(int)age.TotalHours}h ago";
return t.Value.ToString("yyyy-MM-dd HH:mm 'UTC'");
}
public void Dispose() => _timer?.Dispose();
internal sealed record FleetNodeRow(
string NodeId, string ClusterId, long? GenerationId,
string? Status, string? Error, DateTime? AppliedAt, DateTime? SeenAt);
}

View File

@@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Server;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Server.Security;
using DriverWriteRequest = ZB.MOM.WW.OtOpcUa.Core.Abstractions.WriteRequest;
namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
@@ -35,6 +36,12 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
private FolderState? _driverRoot;
private readonly Dictionary<string, BaseDataVariableState> _variablesByFullRef = new(StringComparer.OrdinalIgnoreCase);
// PR 26: SecurityClassification per variable, populated during Variable() registration.
// OnWriteValue looks up the classification here to gate the write by the session's roles.
// Drivers never enforce authz themselves — the classification is discovery-time metadata
// only (feedback_acl_at_server_layer.md).
private readonly Dictionary<string, SecurityClassification> _securityByFullRef = new(StringComparer.OrdinalIgnoreCase);
// Active building folder — set per Folder() call so Variable() lands under the right parent.
// A stack would support nested folders; we use a single current folder because IAddressSpaceBuilder
// returns a child builder per Folder call and the caller threads nesting through those references.
@@ -122,6 +129,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
_currentFolder.AddChild(v);
AddPredefinedNode(SystemContext, v);
_variablesByFullRef[attributeInfo.FullName] = v;
_securityByFullRef[attributeInfo.FullName] = attributeInfo.SecurityClass;
v.OnReadValue = OnReadValue;
v.OnWriteValue = OnWriteValue;
@@ -337,6 +345,22 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
var fullRef = node.NodeId.Identifier as string;
if (string.IsNullOrEmpty(fullRef)) return StatusCodes.BadNodeIdUnknown;
// PR 26: server-layer write authorization. Look up the attribute's classification
// (populated during Variable() in Discover) and check the session's roles against the
// policy table. Drivers don't participate in this decision — IWritable.WriteAsync
// never sees a request we'd have refused here.
if (_securityByFullRef.TryGetValue(fullRef!, out var classification))
{
var roles = context.UserIdentity is IRoleBearer rb ? rb.Roles : [];
if (!WriteAuthzPolicy.IsAllowed(classification, roles))
{
_logger.LogInformation(
"Write denied for {FullRef}: classification={Classification} userRoles=[{Roles}]",
fullRef, classification, string.Join(",", roles));
return new ServiceResult(StatusCodes.BadUserAccessDenied);
}
}
try
{
var results = _writable.WriteAsync(

View File

@@ -97,7 +97,7 @@ public sealed class OtOpcUaServer : StandardServer
/// managers can gate writes by role via <c>session.Identity</c>. Anonymous identity still
/// uses the stack's default.
/// </summary>
private sealed class RoleBasedIdentity : UserIdentity
private sealed class RoleBasedIdentity : UserIdentity, IRoleBearer
{
public IReadOnlyList<string> Roles { get; }
public string? Display { get; }

View File

@@ -0,0 +1,13 @@
namespace ZB.MOM.WW.OtOpcUa.Server.Security;
/// <summary>
/// Minimal interface a <see cref="Opc.Ua.IUserIdentity"/> implementation can expose so
/// <see cref="ZB.MOM.WW.OtOpcUa.Server.OpcUa.DriverNodeManager"/> can read the session's
/// resolved roles without a hard dependency on any specific identity subtype. Implemented
/// by <c>OtOpcUaServer.RoleBasedIdentity</c>; tests implement it with stub identities to
/// drive the authz policy under different role sets.
/// </summary>
public interface IRoleBearer
{
IReadOnlyList<string> Roles { get; }
}

View File

@@ -0,0 +1,70 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Server.Security;
/// <summary>
/// Server-layer write-authorization policy. ACL enforcement lives here — drivers report
/// <see cref="SecurityClassification"/> as discovery metadata only; the server decides
/// whether a given session is allowed to write a given attribute by checking the session's
/// roles (resolved at login via <see cref="LdapUserAuthenticator"/>) against the required
/// role for the attribute's classification.
/// </summary>
/// <remarks>
/// Matches the table in <c>docs/Configuration.md</c>:
/// <list type="bullet">
/// <item><c>FreeAccess</c>: no role required — anonymous sessions can write (matches v1 default).</item>
/// <item><c>Operate</c> / <c>SecuredWrite</c>: <c>WriteOperate</c> role required.</item>
/// <item><c>Tune</c>: <c>WriteTune</c> role required.</item>
/// <item><c>VerifiedWrite</c> / <c>Configure</c>: <c>WriteConfigure</c> role required.</item>
/// <item><c>ViewOnly</c>: no role grants write access.</item>
/// </list>
/// <c>AlarmAck</c> is checked at the alarm-acknowledge path, not here.
/// </remarks>
public static class WriteAuthzPolicy
{
public const string RoleWriteOperate = "WriteOperate";
public const string RoleWriteTune = "WriteTune";
public const string RoleWriteConfigure = "WriteConfigure";
/// <summary>
/// Decide whether a session with <paramref name="userRoles"/> is allowed to write to an
/// attribute with the given <paramref name="classification"/>. Returns true for
/// <c>FreeAccess</c> regardless of roles (including empty / anonymous sessions) and
/// false for <c>ViewOnly</c> regardless of roles. Every other classification requires
/// the session to carry the mapped role — case-insensitive match.
/// </summary>
public static bool IsAllowed(SecurityClassification classification, IReadOnlyCollection<string> userRoles)
{
if (classification == SecurityClassification.FreeAccess) return true;
if (classification == SecurityClassification.ViewOnly) return false;
var required = RequiredRole(classification);
if (required is null) return false;
foreach (var r in userRoles)
{
if (string.Equals(r, required, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
/// <summary>
/// Required role for a classification, or null when no role grants access
/// (<see cref="SecurityClassification.ViewOnly"/>) or no role is needed
/// (<see cref="SecurityClassification.FreeAccess"/> — also returns null; callers use
/// <see cref="IsAllowed"/> which handles the special-cases rather than branching on
/// null themselves).
/// </summary>
public static string? RequiredRole(SecurityClassification classification) => classification switch
{
SecurityClassification.FreeAccess => null, // IsAllowed short-circuits
SecurityClassification.Operate => RoleWriteOperate,
SecurityClassification.SecuredWrite => RoleWriteOperate,
SecurityClassification.Tune => RoleWriteTune,
SecurityClassification.VerifiedWrite => RoleWriteConfigure,
SecurityClassification.Configure => RoleWriteConfigure,
SecurityClassification.ViewOnly => null, // IsAllowed short-circuits
_ => null,
};
}

View File

@@ -0,0 +1,134 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Server.Security;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
[Trait("Category", "Unit")]
public sealed class WriteAuthzPolicyTests
{
// --- FreeAccess and ViewOnly special-cases ---
[Fact]
public void FreeAccess_allows_write_even_for_empty_role_set()
{
WriteAuthzPolicy.IsAllowed(SecurityClassification.FreeAccess, []).ShouldBeTrue();
}
[Fact]
public void FreeAccess_allows_write_for_arbitrary_roles()
{
WriteAuthzPolicy.IsAllowed(SecurityClassification.FreeAccess, ["SomeOtherRole"]).ShouldBeTrue();
}
[Fact]
public void ViewOnly_denies_write_even_with_every_role()
{
var allRoles = new[] { "WriteOperate", "WriteTune", "WriteConfigure", "AlarmAck" };
WriteAuthzPolicy.IsAllowed(SecurityClassification.ViewOnly, allRoles).ShouldBeFalse();
}
// --- Operate tier ---
[Fact]
public void Operate_requires_WriteOperate_role()
{
WriteAuthzPolicy.IsAllowed(SecurityClassification.Operate, ["WriteOperate"]).ShouldBeTrue();
}
[Fact]
public void Operate_role_match_is_case_insensitive()
{
WriteAuthzPolicy.IsAllowed(SecurityClassification.Operate, ["writeoperate"]).ShouldBeTrue();
WriteAuthzPolicy.IsAllowed(SecurityClassification.Operate, ["WRITEOPERATE"]).ShouldBeTrue();
}
[Fact]
public void Operate_denies_empty_role_set()
{
WriteAuthzPolicy.IsAllowed(SecurityClassification.Operate, []).ShouldBeFalse();
}
[Fact]
public void Operate_denies_wrong_role()
{
WriteAuthzPolicy.IsAllowed(SecurityClassification.Operate, ["ReadOnly"]).ShouldBeFalse();
}
[Fact]
public void SecuredWrite_maps_to_same_WriteOperate_requirement_as_Operate()
{
WriteAuthzPolicy.IsAllowed(SecurityClassification.SecuredWrite, ["WriteOperate"]).ShouldBeTrue();
WriteAuthzPolicy.IsAllowed(SecurityClassification.SecuredWrite, ["WriteTune"]).ShouldBeFalse();
}
// --- Tune tier ---
[Fact]
public void Tune_requires_WriteTune_role()
{
WriteAuthzPolicy.IsAllowed(SecurityClassification.Tune, ["WriteTune"]).ShouldBeTrue();
}
[Fact]
public void Tune_denies_WriteOperate_only_session()
{
// Important: role roles do NOT cascade — a session with WriteOperate can't write a Tune
// attribute. Operators escalate by adding WriteTune to the session's roles, not by a
// hierarchy the policy infers on its own.
WriteAuthzPolicy.IsAllowed(SecurityClassification.Tune, ["WriteOperate"]).ShouldBeFalse();
}
// --- Configure tier ---
[Fact]
public void Configure_requires_WriteConfigure_role()
{
WriteAuthzPolicy.IsAllowed(SecurityClassification.Configure, ["WriteConfigure"]).ShouldBeTrue();
}
[Fact]
public void VerifiedWrite_maps_to_same_WriteConfigure_requirement_as_Configure()
{
WriteAuthzPolicy.IsAllowed(SecurityClassification.VerifiedWrite, ["WriteConfigure"]).ShouldBeTrue();
WriteAuthzPolicy.IsAllowed(SecurityClassification.VerifiedWrite, ["WriteOperate"]).ShouldBeFalse();
}
// --- Multi-role sessions ---
[Fact]
public void Session_with_multiple_roles_is_allowed_when_any_matches()
{
var roles = new[] { "ReadOnly", "WriteTune", "AlarmAck" };
WriteAuthzPolicy.IsAllowed(SecurityClassification.Tune, roles).ShouldBeTrue();
}
[Fact]
public void Session_with_only_unrelated_roles_is_denied()
{
var roles = new[] { "ReadOnly", "AlarmAck", "SomeCustomRole" };
WriteAuthzPolicy.IsAllowed(SecurityClassification.Configure, roles).ShouldBeFalse();
}
// --- Mapping table ---
[Theory]
[InlineData(SecurityClassification.Operate, WriteAuthzPolicy.RoleWriteOperate)]
[InlineData(SecurityClassification.SecuredWrite, WriteAuthzPolicy.RoleWriteOperate)]
[InlineData(SecurityClassification.Tune, WriteAuthzPolicy.RoleWriteTune)]
[InlineData(SecurityClassification.VerifiedWrite, WriteAuthzPolicy.RoleWriteConfigure)]
[InlineData(SecurityClassification.Configure, WriteAuthzPolicy.RoleWriteConfigure)]
public void RequiredRole_returns_expected_role_for_classification(SecurityClassification c, string expected)
{
WriteAuthzPolicy.RequiredRole(c).ShouldBe(expected);
}
[Theory]
[InlineData(SecurityClassification.FreeAccess)]
[InlineData(SecurityClassification.ViewOnly)]
public void RequiredRole_returns_null_for_special_classifications(SecurityClassification c)
{
WriteAuthzPolicy.RequiredRole(c).ShouldBeNull();
}
}