Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAclEnforcementTests.cs
T
Joseph Doherty f692f6afe4 feat(security): enforce node ACLs on write, alarms and HistoryRead (#520 stage 5)
`Security:Acl:Enabled` (default false) makes scoped `NodeAcl` grants decide
data-plane authorization at the three seams that have one, and gives HistoryRead
a check it never had. Read, Browse and Subscribe stay ungated — stage 6.

The ACL decision REPLACES the server-wide role check rather than chaining with
it. Chained, a grant would have to confer the role as well as pass the scope
check, and only Cluster-scoped grants confer a role — so every narrow grant
would have stayed inert and the flag would have looked inert too. The
load-bearing test is an ALLOW: a session carrying no roles at all writing
successfully on the strength of its grant. A deny-only test passes against a
build where the ACL is never consulted, because the role gate denies too.

Found en route: the principal never reached the session. The authenticator
passed it as OpcUaUserAuthResult.DisplayName, but RoleCarryingUserIdentity
derives DisplayName from the presented token — for a key-bearing client that is
the static username `apikey`, identical fleet-wide. So the trie's matching key
was dropped at the identity boundary, and the audit ClientUserId on a rejected
write recorded `apikey` for every consuming system. Principal and ApiKeyId are
now carried explicitly.

Beyond the stage as sketched:

- Revocation reaches an OPEN session. Every decision re-checks the
  authenticating key against the current snapshot; without it a key revoked by a
  deployment kept its authority until the client chose to reconnect, which for a
  machine-to-machine consumer can be indefinitely.
- UserAuthorizationState is built per CALL, not per session. The per-session
  shape exists so an LDAP session can re-resolve membership that changed out of
  band; nothing can change out of band under API keys, so the generation stamp is
  the whole invalidation signal and a per-call record picks it up for free.
  IsStale/NeedsRefresh therefore cannot fire here and are documented as vestigial.
- The trie is built inside DataPlaneAuthorityState.Apply, under the same
  generation as the snapshot, rather than by a second wiring at the publish
  actor. An uninstalled trie denies everything — indistinguishable from working
  enforcement. Deleting the install turns 13 of the 22 new tests red.
- The deploy log reports what the BUILD dropped instead of re-deriving it. The
  old count agreed with the builder on one of its four drop reasons; a rule with
  a blank ScopeId, a retired scope kind, or a path not starting at a realm
  vanished with the log reading zero.

Both IApiKeyStore implementations widened to the full grant set. The narrowing
was redundant — ApiKeyInterimRoleMapper applies the same filter itself — and
became a trap once scope was enforced. Pinned by asserting the roles are
unchanged, with the two grants carrying different flags so it cannot pass by
accident.

Falsified by mutation: enforcement never engaging, the write seam ignoring the
ACL, the realm dropped from the scope, the revocation check removed, and the
trie never installed each turn the intended tests red and no others.

Full solution suite green.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-08-01 05:00:28 -04:00

399 lines
17 KiB
C#

using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Server;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
using SdkHistoryReadResult = Opc.Ua.HistoryReadResult;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// #520 stage 5 — enforcement at the node manager's own seams, through a REAL booted
/// <see cref="OtOpcUaSdkServer"/> and the SDK's own dispatch. <see cref="DataPlaneAuthorizerTests"/>
/// proves the decision; this proves the decision is actually consulted, which is the half that has
/// historically gone wrong in this repo (a correct component wired to nothing, or wired behind a
/// branch that never runs).
/// </summary>
/// <remarks>
/// HistoryRead is the seam exercised end-to-end because it is reachable through the node manager's
/// public entry point with no client socket, and because it had <b>no authorization check of any kind</b>
/// before this stage — so a green result here is the difference between "any session can read all
/// history" and "a grant is required".
/// </remarks>
public sealed class NodeManagerAclEnforcementTests : IDisposable
{
private const string Cluster = "SITE-A";
private const string Principal = "scadabridge";
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private readonly string _pkiRoot = Path.Combine(
Path.GetTempPath(), $"otopcua-acl-{Guid.NewGuid():N}");
/// <summary>
/// With enforcement OFF, HistoryRead behaves exactly as it always has — no identity is required and
/// nothing is denied. This is the flag's whole promise, and it is the assertion that would catch a
/// change accidentally enforcing by default.
/// </summary>
[Fact]
public async Task History_read_is_ungated_while_enforcement_is_off()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
nm.HistorianDataSource = NullHistorianDataSource.Instance;
nm.Authorizer = Authorizer(enabled: false, GrantOn(NodePermissions.HistoryRead));
var nodeId = Historized(nm, "Plant/Modbus/dev1/Speed");
var (_, errors) = ReadRaw(nm, identity: null, nodeId);
errors[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied);
}
finally { await host.DisposeAsync(); }
}
/// <summary>
/// With enforcement ON, an AUTHENTICATED session whose principal holds a grant that does not carry
/// the HistoryRead bit is refused — the check the seam never had.
/// </summary>
/// <remarks>
/// The identity is deliberately real and the grant deliberately non-empty. Denying an anonymous
/// context would pass against a gate that only ever checks for a null identity and never consults a
/// grant at all, which is most of what could go wrong here.
/// </remarks>
[Fact]
public async Task History_read_is_denied_without_the_permission_once_enforcement_is_on()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
nm.HistorianDataSource = NullHistorianDataSource.Instance;
nm.Authorizer = Authorizer(enabled: true, GrantOn(NodePermissions.Read));
var nodeId = Historized(nm, "Plant/Modbus/dev1/Speed");
var (results, errors) = ReadRaw(nm, Identity(), nodeId);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
// Not BadHistoryOperationUnsupported: "you may not read this" and "this node has no history"
// are different facts, and reporting the wrong one sends an operator debugging historisation.
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
}
finally { await host.DisposeAsync(); }
}
/// <summary>A granted principal reads normally — the gate is not simply denying everything.</summary>
[Fact]
public async Task History_read_succeeds_for_a_granted_principal()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
nm.HistorianDataSource = NullHistorianDataSource.Instance;
nm.Authorizer = Authorizer(enabled: true, GrantOn(NodePermissions.HistoryRead));
var nodeId = Historized(nm, "Plant/Modbus/dev1/Speed");
var (_, errors) = ReadRaw(nm, Identity(), nodeId);
errors[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied);
}
finally { await host.DisposeAsync(); }
}
/// <summary>
/// A mixed batch is decided per node: the granted node answers and the ungranted one is denied, in
/// the same request. Failing the whole batch would be easier and would also withhold data the client
/// was entitled to.
/// </summary>
[Fact]
public async Task A_mixed_batch_denies_only_the_ungranted_node()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
nm.HistorianDataSource = NullHistorianDataSource.Instance;
var grant = new NodeAcl
{
NodeAclId = "acl-dev1",
ClusterId = Cluster,
Principal = Principal,
ScopeKind = NodeAclScopeKind.RawDevice,
ScopeId = "dev-1",
PermissionFlags = NodePermissions.HistoryRead,
};
nm.Authorizer = Authorizer(
enabled: true, grant,
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>
{
[new AclScopeRef(NodeAclScopeKind.RawDevice, "dev-1")] =
[new NodeAclPath(["raw", "Plant", "Modbus", "dev1"])],
});
var allowed = Historized(nm, "Plant/Modbus/dev1/Speed");
var denied = Historized(nm, "Plant/Modbus/dev2/Speed");
var (_, errors) = ReadRaw(nm, Identity(), allowed, denied);
errors[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied);
errors[1].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
}
finally { await host.DisposeAsync(); }
}
// ───────────────────────────── the alarm seam's operation mapping ─────────────────────────────
/// <summary>
/// Every Part 9 method name the node manager routes maps onto the permission it should require.
/// The <c>AlarmAck</c> role this replaces could not distinguish these at all — one role covered
/// acknowledge, confirm and shelve — so the mapping is new authority granularity, not a rename.
/// </summary>
/// <remarks>
/// The inputs are the exact literals the <c>OnAcknowledge</c> / <c>OnShelve</c> / <c>OnEnableDisable</c>
/// delegates pass, not invented ones; a mapping that agreed with itself but not with its callers
/// would grant the wrong permission and this test would not notice.
/// </remarks>
[Theory]
[InlineData("Acknowledge", OpcUaOperation.AlarmAcknowledge)]
[InlineData("Confirm", OpcUaOperation.AlarmConfirm)]
[InlineData("OneShotShelve", OpcUaOperation.AlarmShelve)]
[InlineData("TimedShelve", OpcUaOperation.AlarmShelve)]
[InlineData("Unshelve", OpcUaOperation.AlarmShelve)]
// AddComment / Enable / Disable have no OpcUaOperation of their own, so they take the most
// restrictive available reading — acknowledge is the permission "may act on alarms at all" grants.
[InlineData("AddComment", OpcUaOperation.AlarmAcknowledge)]
[InlineData("Enable", OpcUaOperation.AlarmAcknowledge)]
[InlineData("Disable", OpcUaOperation.AlarmAcknowledge)]
public void Part9_method_names_map_to_their_permission(string method, OpcUaOperation expected) =>
OtOpcUaNodeManager.AlarmOperationFor(method).ShouldBe(expected);
// ───────────────────────────── the write seam ─────────────────────────────
/// <summary>
/// With enforcement ON, the ACL decision REPLACES the <c>WriteOperate</c> role check — an identity
/// carrying NO roles at all writes successfully on the strength of its grant alone.
/// </summary>
/// <remarks>
/// This is the assertion that proves the two gates are alternatives rather than a chain. Run them in
/// series and this write is refused, because only a Cluster-scoped grant confers the server-wide
/// role — which is exactly the "narrow grants stay inert" outcome the stage exists to end. A test
/// that only checked denials would be satisfied by a build that denies everything.
/// </remarks>
[Fact]
public async Task An_acl_grant_authorizes_a_write_from_a_session_carrying_no_roles()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var gateway = new RecordingWriteGateway();
nm.NodeWriteGateway = gateway;
nm.Authorizer = Authorizer(enabled: true, GrantOn(NodePermissions.WriteOperate));
var nodeId = Writable(nm, "Plant/Modbus/dev1/Setpoint");
var errors = Write(nm, Identity(), nodeId, 42f);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
gateway.Writes.ShouldHaveSingleItem();
}
finally { await host.DisposeAsync(); }
}
/// <summary>
/// And the converse: a principal whose grant lacks <c>WriteOperate</c> is refused, and — the part
/// that matters beyond the status code — <b>nothing is dispatched to the device</b>. A gate that
/// denied the client but still routed the write would be worse than no gate at all.
/// </summary>
[Fact]
public async Task A_denied_write_never_reaches_the_device()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var gateway = new RecordingWriteGateway();
nm.NodeWriteGateway = gateway;
nm.Authorizer = Authorizer(enabled: true, GrantOn(NodePermissions.Read));
var nodeId = Writable(nm, "Plant/Modbus/dev1/Setpoint");
var errors = Write(nm, Identity(), nodeId, 42f);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
gateway.Writes.ShouldBeEmpty();
}
finally { await host.DisposeAsync(); }
}
/// <summary>
/// With enforcement OFF the role gate still stands: a session with no roles is refused exactly as
/// before this stage. Pins that the flag's off-path is untouched, in the direction that matters.
/// </summary>
[Fact]
public async Task A_roleless_write_is_still_denied_while_enforcement_is_off()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var gateway = new RecordingWriteGateway();
nm.NodeWriteGateway = gateway;
nm.Authorizer = Authorizer(enabled: false, GrantOn(NodePermissions.WriteOperate));
var nodeId = Writable(nm, "Plant/Modbus/dev1/Setpoint");
var errors = Write(nm, Identity(), nodeId, 42f);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
gateway.Writes.ShouldBeEmpty();
}
finally { await host.DisposeAsync(); }
}
// ───────────────────────────── helpers ─────────────────────────────
private static NodeId Writable(OtOpcUaNodeManager nm, string rawPath)
{
nm.EnsureVariable(rawPath, parentFolderNodeId: null, displayName: rawPath, dataType: "Float",
writable: true, historianTagname: null, realm: AddressSpaceRealm.Raw);
return nm.TryGetVariable(rawPath, AddressSpaceRealm.Raw)!.NodeId;
}
private static List<ServiceResult> Write(
OtOpcUaNodeManager nm, IUserIdentity? identity, NodeId nodeId, object value)
{
var context = new OperationContext(
new RequestHeader(), secureChannelContext: null, RequestType.Write, identity);
var writes = new List<WriteValue>
{
new()
{
NodeId = nodeId,
AttributeId = Attributes.Value,
Value = new DataValue(new Variant(value)) { StatusCode = StatusCodes.Good },
},
};
var errors = Enumerable.Repeat<ServiceResult>(null!, writes.Count).ToList();
nm.Write(context, writes, errors);
return errors;
}
/// <summary>Records what actually reached the device, so a denial can be shown to route nothing.</summary>
private sealed class RecordingWriteGateway : IOpcUaNodeWriteGateway
{
public List<string> Writes { get; } = [];
public Task<NodeWriteOutcome> WriteAsync(
string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct)
{
lock (Writes) Writes.Add(nodeId);
return Task.FromResult(new NodeWriteOutcome(true, null));
}
}
private static NodeId Historized(OtOpcUaNodeManager nm, string rawPath)
{
nm.EnsureVariable(rawPath, parentFolderNodeId: null, displayName: rawPath, dataType: "Float",
writable: false, historianTagname: rawPath, realm: AddressSpaceRealm.Raw);
return nm.TryGetVariable(rawPath, AddressSpaceRealm.Raw)!.NodeId;
}
private static (List<SdkHistoryReadResult> Results, List<ServiceResult> Errors) ReadRaw(
OtOpcUaNodeManager nm, IUserIdentity? identity, params NodeId[] nodeIds)
{
var context = new OperationContext(
new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity);
var nodesToRead = nodeIds.Select(id => new HistoryReadValueId { NodeId = id }).ToList();
var results = Enumerable.Repeat<SdkHistoryReadResult>(null!, nodeIds.Length).ToList();
var errors = Enumerable.Repeat<ServiceResult>(null!, nodeIds.Length).ToList();
nm.HistoryRead(
context,
new ReadRawModifiedDetails
{
StartTime = DateTime.UtcNow.AddHours(-1),
EndTime = DateTime.UtcNow,
NumValuesPerNode = 10,
IsReadModified = false,
},
TimestampsToReturn.Both,
releaseContinuationPoints: false,
nodesToRead,
results,
errors);
return (results, errors);
}
private static DataPlaneAuthorizer Authorizer(
bool enabled,
NodeAcl grant,
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>? scopePaths = null)
{
var state = new DataPlaneAuthorityState();
state.Apply(Cluster, "rev-1", [], [grant],
scopePaths ?? new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>());
return new DataPlaneAuthorizer(
state, new TriePermissionEvaluator(state.Tries), enabled, NullLogger.Instance);
}
private static NodeAcl GrantOn(NodePermissions flags) => new()
{
NodeAclId = "acl-cluster",
ClusterId = Cluster,
Principal = Principal,
ScopeKind = NodeAclScopeKind.Cluster,
PermissionFlags = flags,
};
private static RoleCarryingUserIdentity Identity() =>
new(new UserNameIdentityToken { UserName = "apikey" }, [], Principal, apiKeyId: null);
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
{
var host = new OpcUaApplicationHost(
new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.AclTest",
ApplicationUri = $"urn:OtOpcUa.AclTest:{Guid.NewGuid():N}",
OpcUaPort = AllocateFreePort(),
PublicHostname = "localhost",
PkiStoreRoot = _pkiRoot,
},
NullLogger<OpcUaApplicationHost>.Instance);
var server = new OtOpcUaSdkServer();
await host.StartAsync(server, Ct);
return (host, server);
}
private static int AllocateFreePort()
{
using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
listener.Start();
var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
/// <summary>Cleans up the PKI root directory.</summary>
public void Dispose()
{
if (Directory.Exists(_pkiRoot))
{
try { Directory.Delete(_pkiRoot, recursive: true); }
catch (IOException) { /* best effort */ }
}
}
}