f692f6afe4
`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
394 lines
18 KiB
C#
394 lines
18 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Opc.Ua;
|
|
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;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
|
|
|
/// <summary>
|
|
/// #520 stage 5 — the node-scoped ACL gate. Every test drives the REAL
|
|
/// <see cref="DataPlaneAuthorityState"/> → <see cref="PermissionTrieBuilder"/> →
|
|
/// <see cref="TriePermissionEvaluator"/> chain rather than a fake evaluator, because the defect class
|
|
/// this stage exists to close is precisely "the pieces are individually correct and nothing connects
|
|
/// them". A test that stubs the evaluator would pass against a gate wired to an empty trie.
|
|
/// </summary>
|
|
public sealed class DataPlaneAuthorizerTests
|
|
{
|
|
private const string Cluster = "SITE-A";
|
|
private const string Principal = "scadabridge";
|
|
private const string KeyId = "a1b2c3d4e5f60011";
|
|
|
|
// ───────────────────────────── the fail-closed edges ─────────────────────────────
|
|
|
|
/// <summary>
|
|
/// A session with no role-carrying identity is denied. There is no "unauthenticated means
|
|
/// unrestricted" path: an anonymous session that the host admitted still arrives here as a
|
|
/// principal, so a null identity means something went wrong upstream.
|
|
/// </summary>
|
|
[Fact]
|
|
public void A_session_with_no_identity_is_denied()
|
|
{
|
|
var gate = Gate(ClusterGrant(NodePermissions.WriteOperate));
|
|
|
|
Veto(gate.Authorize(null, OpcUaOperation.WriteOperate, RawNode("Plant/Modbus/dev1/Speed"), AddressSpaceRealm.Raw));
|
|
}
|
|
|
|
/// <summary>
|
|
/// An identity carrying no principal is denied even when a grant would otherwise match. This is the
|
|
/// legacy two-argument <see cref="RoleCarryingUserIdentity"/> constructor, and the safe reading of it
|
|
/// is "unknown", not "infer one from the display name" — the display name for every key-bearing
|
|
/// client is the same static username.
|
|
/// </summary>
|
|
[Fact]
|
|
public void An_identity_with_no_principal_is_denied()
|
|
{
|
|
var gate = Gate(ClusterGrant(NodePermissions.WriteOperate));
|
|
var identity = new RoleCarryingUserIdentity(new UserNameIdentityToken { UserName = "apikey" }, []);
|
|
|
|
identity.Principal.ShouldBeEmpty();
|
|
Veto(gate.Authorize(identity, OpcUaOperation.WriteOperate, RawNode("Plant/Modbus/dev1/Speed"), AddressSpaceRealm.Raw));
|
|
}
|
|
|
|
/// <summary>
|
|
/// A node that resolved no cluster identity has no trie to evaluate against, so it denies. Inventing
|
|
/// a placeholder cluster would be the dangerous alternative — it would apply some other cluster's
|
|
/// grants to a node whose own cluster is unknown.
|
|
/// </summary>
|
|
[Fact]
|
|
public void A_node_with_no_cluster_identity_denies_everything()
|
|
{
|
|
var state = new DataPlaneAuthorityState();
|
|
state.Apply(clusterId: null, "rev", [], [ClusterGrant(NodePermissions.WriteOperate)], Scopes());
|
|
|
|
var gate = new DataPlaneAuthorizer(
|
|
state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance);
|
|
|
|
Veto(gate.Authorize(Identity(), OpcUaOperation.WriteOperate, RawNode("Plant/Modbus/dev1/Speed"), AddressSpaceRealm.Raw));
|
|
}
|
|
|
|
/// <summary>
|
|
/// A NodeId whose identifier is not a string addresses no ACL scope — every node this server mints
|
|
/// carries a string path — so it is denied rather than evaluated against an empty segment list,
|
|
/// which would match a realm-wide grant.
|
|
/// </summary>
|
|
[Fact]
|
|
public void A_node_id_with_no_string_identifier_is_denied()
|
|
{
|
|
var gate = Gate(ClusterGrant(NodePermissions.WriteOperate));
|
|
|
|
Veto(gate.Authorize(Identity(), OpcUaOperation.WriteOperate, new NodeId(42u, 2), AddressSpaceRealm.Raw));
|
|
}
|
|
|
|
// ───────────────────────────── the grant actually decides ─────────────────────────────
|
|
|
|
/// <summary>A cluster-wide WriteOperate grant authorizes a write anywhere in that cluster.</summary>
|
|
[Fact]
|
|
public void A_cluster_grant_authorizes_a_write()
|
|
{
|
|
var gate = Gate(ClusterGrant(NodePermissions.WriteOperate));
|
|
|
|
gate.Authorize(Identity(), OpcUaOperation.WriteOperate, RawNode("Plant/Modbus/dev1/Speed"), AddressSpaceRealm.Raw)
|
|
.ShouldBeNull();
|
|
}
|
|
|
|
/// <summary>
|
|
/// The permission bit is honoured, not merely the presence of a grant: a read-only principal is
|
|
/// refused a write on the very node it may read.
|
|
/// </summary>
|
|
[Fact]
|
|
public void A_read_grant_does_not_authorize_a_write()
|
|
{
|
|
var gate = Gate(ClusterGrant(NodePermissions.Read));
|
|
|
|
Veto(gate.Authorize(Identity(), OpcUaOperation.WriteOperate, RawNode("Plant/Modbus/dev1/Speed"), AddressSpaceRealm.Raw));
|
|
}
|
|
|
|
/// <summary>
|
|
/// <b>The heart of the stage.</b> A grant scoped to one device authorizes a tag beneath it and
|
|
/// refuses a tag under a sibling device. Before this, a narrow grant conferred nothing at all — it
|
|
/// was inert — so both of these would have been denied, and a test asserting only the denial would
|
|
/// have passed against the broken build.
|
|
/// </summary>
|
|
[Fact]
|
|
public void A_raw_device_scoped_grant_covers_its_own_tags_and_not_a_siblings()
|
|
{
|
|
var grant = new NodeAcl
|
|
{
|
|
NodeAclId = "acl-dev1",
|
|
ClusterId = Cluster,
|
|
Principal = Principal,
|
|
ScopeKind = NodeAclScopeKind.RawDevice,
|
|
ScopeId = "dev-1",
|
|
PermissionFlags = NodePermissions.WriteOperate,
|
|
};
|
|
|
|
var gate = Gate(grant, Scopes(("dev-1", NodeAclScopeKind.RawDevice, ["raw", "Plant", "Modbus", "dev1"])));
|
|
|
|
gate.Authorize(Identity(), OpcUaOperation.WriteOperate, RawNode("Plant/Modbus/dev1/Speed"), AddressSpaceRealm.Raw)
|
|
.ShouldBeNull();
|
|
Veto(gate.Authorize(Identity(), OpcUaOperation.WriteOperate, RawNode("Plant/Modbus/dev2/Speed"), AddressSpaceRealm.Raw));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Segment matching is case-SENSITIVE. Raw sibling names are ordinal-unique, so <c>Speed</c> and
|
|
/// <c>speed</c> can both exist under one device; folding case would let a grant on either silently
|
|
/// cover both.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Scope_segments_are_matched_case_sensitively()
|
|
{
|
|
var grant = new NodeAcl
|
|
{
|
|
NodeAclId = "acl-tag",
|
|
ClusterId = Cluster,
|
|
Principal = Principal,
|
|
ScopeKind = NodeAclScopeKind.Tag,
|
|
ScopeId = "tag-1",
|
|
PermissionFlags = NodePermissions.WriteOperate,
|
|
};
|
|
|
|
var gate = Gate(grant, Scopes(("tag-1", NodeAclScopeKind.Tag, ["raw", "Plant", "Modbus", "dev1", "Speed"])));
|
|
|
|
gate.Authorize(Identity(), OpcUaOperation.WriteOperate, RawNode("Plant/Modbus/dev1/Speed"), AddressSpaceRealm.Raw)
|
|
.ShouldBeNull();
|
|
Veto(gate.Authorize(Identity(), OpcUaOperation.WriteOperate, RawNode("Plant/Modbus/dev1/speed"), AddressSpaceRealm.Raw));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The realm is a hard gate at level 1 of the walk: a grant on the raw tree does not reach a UNS
|
|
/// node that happens to share a path. Blocker #1 is that raw nodes were unrepresentable at all, so
|
|
/// this pins that both realms now evaluate — and that they stay distinct.
|
|
/// </summary>
|
|
[Fact]
|
|
public void A_raw_grant_does_not_reach_a_uns_node_with_the_same_path()
|
|
{
|
|
var grant = new NodeAcl
|
|
{
|
|
NodeAclId = "acl-raw-realm",
|
|
ClusterId = Cluster,
|
|
Principal = Principal,
|
|
ScopeKind = NodeAclScopeKind.Namespace,
|
|
ScopeId = NodeAclRealms.Raw,
|
|
PermissionFlags = NodePermissions.WriteOperate,
|
|
};
|
|
|
|
var gate = Gate(grant, Scopes((NodeAclRealms.Raw, NodeAclScopeKind.Namespace, ["raw"])));
|
|
|
|
gate.Authorize(Identity(), OpcUaOperation.WriteOperate, RawNode("Area/Line/Eq/Speed"), AddressSpaceRealm.Raw)
|
|
.ShouldBeNull();
|
|
Veto(gate.Authorize(Identity(), OpcUaOperation.WriteOperate, RawNode("Area/Line/Eq/Speed"), AddressSpaceRealm.Uns));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The Part 9 methods map onto distinct permissions, which the single <c>AlarmAck</c> role they
|
|
/// replace could not express: a principal granted acknowledge alone cannot shelve.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Alarm_acknowledge_and_shelve_are_separately_grantable()
|
|
{
|
|
var gate = Gate(ClusterGrant(NodePermissions.AlarmAcknowledge));
|
|
var node = RawNode("Plant/Modbus/dev1/Speed");
|
|
|
|
gate.Authorize(Identity(), OpcUaOperation.AlarmAcknowledge, node, AddressSpaceRealm.Raw).ShouldBeNull();
|
|
Veto(gate.Authorize(Identity(), OpcUaOperation.AlarmShelve, node, AddressSpaceRealm.Raw));
|
|
}
|
|
|
|
/// <summary>
|
|
/// HistoryRead has its own bit — a Read grant is not sufficient. This is the OPC UA Part 11
|
|
/// separation, and it is the first time HistoryRead has been gated at all.
|
|
/// </summary>
|
|
[Fact]
|
|
public void History_read_needs_its_own_permission_not_merely_read()
|
|
{
|
|
var node = RawNode("Plant/Modbus/dev1/Speed");
|
|
|
|
Veto(Gate(ClusterGrant(NodePermissions.Read))
|
|
.Authorize(Identity(), OpcUaOperation.HistoryRead, node, AddressSpaceRealm.Raw));
|
|
Gate(ClusterGrant(NodePermissions.HistoryRead))
|
|
.Authorize(Identity(), OpcUaOperation.HistoryRead, node, AddressSpaceRealm.Raw)
|
|
.ShouldBeNull();
|
|
}
|
|
|
|
// ───────────────────────────── revocation reaches an OPEN session ─────────────────────────────
|
|
|
|
/// <summary>
|
|
/// A deployment that disables a key stops that key's ALREADY-OPEN session, not just future ones.
|
|
/// Without the per-call re-check a revoked credential keeps its authority until the client happens
|
|
/// to reconnect, which for a machine-to-machine consumer can be indefinitely — and on a driver-only
|
|
/// node the artifact is the only revocation channel there is.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Revoking_a_key_denies_its_open_session_on_the_next_deployment()
|
|
{
|
|
var state = new DataPlaneAuthorityState();
|
|
var grant = ClusterGrant(NodePermissions.WriteOperate);
|
|
var node = RawNode("Plant/Modbus/dev1/Speed");
|
|
var gate = new DataPlaneAuthorizer(
|
|
state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance);
|
|
|
|
state.Apply(Cluster, "rev-1", [Key(enabled: true)], [grant], Scopes());
|
|
gate.Authorize(Identity(), OpcUaOperation.WriteOperate, node, AddressSpaceRealm.Raw).ShouldBeNull();
|
|
|
|
state.Apply(Cluster, "rev-2", [Key(enabled: false)], [grant], Scopes());
|
|
Veto(gate.Authorize(Identity(), OpcUaOperation.WriteOperate, node, AddressSpaceRealm.Raw));
|
|
}
|
|
|
|
/// <summary>
|
|
/// An expired key is refused on the same path, and expiry is read against the injected clock rather
|
|
/// than wall time so the test pins the comparison rather than waiting for it.
|
|
/// </summary>
|
|
[Fact]
|
|
public void An_expired_key_denies_its_open_session()
|
|
{
|
|
var clock = new FakeTimeProvider(new DateTime(2026, 8, 1, 12, 0, 0, DateTimeKind.Utc));
|
|
var state = new DataPlaneAuthorityState();
|
|
var key = Key(enabled: true);
|
|
key.ExpiresUtc = new DateTime(2026, 8, 1, 13, 0, 0, DateTimeKind.Utc);
|
|
state.Apply(Cluster, "rev-1", [key], [ClusterGrant(NodePermissions.WriteOperate)], Scopes());
|
|
|
|
var gate = new DataPlaneAuthorizer(
|
|
state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance, clock);
|
|
var node = RawNode("Plant/Modbus/dev1/Speed");
|
|
|
|
gate.Authorize(Identity(), OpcUaOperation.WriteOperate, node, AddressSpaceRealm.Raw).ShouldBeNull();
|
|
|
|
clock.Advance(TimeSpan.FromHours(2));
|
|
Veto(gate.Authorize(Identity(), OpcUaOperation.WriteOperate, node, AddressSpaceRealm.Raw));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Anonymous carries no key id, so the revocation probe must not fire for it — otherwise every
|
|
/// anonymous request would be denied by a lookup for a key that was never presented.
|
|
/// </summary>
|
|
[Fact]
|
|
public void An_anonymous_session_is_not_denied_by_the_key_revocation_check()
|
|
{
|
|
var grant = ClusterGrant(NodePermissions.Read);
|
|
grant.Principal = OpcUaPrincipals.Anonymous;
|
|
var gate = Gate(grant);
|
|
var anonymous = new RoleCarryingUserIdentity(
|
|
new AnonymousIdentityToken(), [], OpcUaPrincipals.Anonymous, apiKeyId: null);
|
|
|
|
gate.Authorize(anonymous, OpcUaOperation.Read, RawNode("Plant/Modbus/dev1/Speed"), AddressSpaceRealm.Raw)
|
|
.ShouldBeNull();
|
|
}
|
|
|
|
// ───────────────────────────── the generation stamp ─────────────────────────────
|
|
|
|
/// <summary>
|
|
/// A deployment that adds a grant takes effect on an open session immediately — the session is
|
|
/// stamped with the current generation on every call, so it never pins itself to the generation it
|
|
/// activated under and then has to be bounced to pick an ACL change up.
|
|
/// </summary>
|
|
[Fact]
|
|
public void A_grant_added_by_a_later_deployment_reaches_an_open_session()
|
|
{
|
|
var state = new DataPlaneAuthorityState();
|
|
var node = RawNode("Plant/Modbus/dev1/Speed");
|
|
var gate = new DataPlaneAuthorizer(
|
|
state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance);
|
|
|
|
state.Apply(Cluster, "rev-1", [Key(enabled: true)], [], Scopes());
|
|
Veto(gate.Authorize(Identity(), OpcUaOperation.WriteOperate, node, AddressSpaceRealm.Raw));
|
|
|
|
state.Apply(Cluster, "rev-2", [Key(enabled: true)], [ClusterGrant(NodePermissions.WriteOperate)], Scopes());
|
|
gate.Authorize(Identity(), OpcUaOperation.WriteOperate, node, AddressSpaceRealm.Raw).ShouldBeNull();
|
|
}
|
|
|
|
/// <summary>
|
|
/// And the reverse: revoking a GRANT (rather than the key) also lands on the open session. Both
|
|
/// directions matter — an ACL edit that only widens would be the easy half.
|
|
/// </summary>
|
|
[Fact]
|
|
public void A_grant_removed_by_a_later_deployment_stops_an_open_session()
|
|
{
|
|
var state = new DataPlaneAuthorityState();
|
|
var node = RawNode("Plant/Modbus/dev1/Speed");
|
|
var gate = new DataPlaneAuthorizer(
|
|
state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance);
|
|
|
|
state.Apply(Cluster, "rev-1", [Key(enabled: true)], [ClusterGrant(NodePermissions.WriteOperate)], Scopes());
|
|
gate.Authorize(Identity(), OpcUaOperation.WriteOperate, node, AddressSpaceRealm.Raw).ShouldBeNull();
|
|
|
|
state.Apply(Cluster, "rev-2", [Key(enabled: true)], [], Scopes());
|
|
Veto(gate.Authorize(Identity(), OpcUaOperation.WriteOperate, node, AddressSpaceRealm.Raw));
|
|
}
|
|
|
|
/// <summary>The flag is carried faithfully; the seams read it to decide whether to consult the gate at all.</summary>
|
|
[Theory]
|
|
[InlineData(true)]
|
|
[InlineData(false)]
|
|
public void The_enabled_flag_is_reported_as_configured(bool enabled)
|
|
{
|
|
var state = new DataPlaneAuthorityState();
|
|
new DataPlaneAuthorizer(state, new TriePermissionEvaluator(state.Tries), enabled, NullLogger.Instance)
|
|
.Enabled.ShouldBe(enabled);
|
|
}
|
|
|
|
// ───────────────────────────── helpers ─────────────────────────────
|
|
|
|
private static void Veto(ServiceResult? result)
|
|
{
|
|
result.ShouldNotBeNull();
|
|
result!.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
|
}
|
|
|
|
private static DataPlaneAuthorizer Gate(
|
|
NodeAcl grant,
|
|
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>? scopePaths = null)
|
|
{
|
|
var state = new DataPlaneAuthorityState();
|
|
state.Apply(Cluster, "rev-1", [Key(enabled: true)], [grant], scopePaths ?? Scopes());
|
|
return new DataPlaneAuthorizer(
|
|
state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance);
|
|
}
|
|
|
|
private static RoleCarryingUserIdentity Identity() =>
|
|
new(new UserNameIdentityToken { UserName = "apikey" }, [], Principal, KeyId);
|
|
|
|
private static NodeId RawNode(string path) => new(path, 2);
|
|
|
|
private static NodeAcl ClusterGrant(NodePermissions flags) => new()
|
|
{
|
|
NodeAclId = "acl-cluster",
|
|
ClusterId = Cluster,
|
|
Principal = Principal,
|
|
ScopeKind = NodeAclScopeKind.Cluster,
|
|
PermissionFlags = flags,
|
|
};
|
|
|
|
private static ApiKey Key(bool enabled) => new()
|
|
{
|
|
ApiKeyId = KeyId,
|
|
Name = "stage5",
|
|
SecretHash = "hash",
|
|
Principal = Principal,
|
|
Enabled = enabled,
|
|
};
|
|
|
|
private static IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> Scopes(
|
|
params (string ScopeId, NodeAclScopeKind Kind, string[] Segments)[] scopes)
|
|
{
|
|
var map = new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>();
|
|
foreach (var (scopeId, kind, segments) in scopes)
|
|
map[new AclScopeRef(kind, scopeId)] = [new NodeAclPath(segments)];
|
|
return map;
|
|
}
|
|
|
|
/// <summary>Minimal controllable clock — the suite needs only "now" and "advance".</summary>
|
|
private sealed class FakeTimeProvider(DateTime utcNow) : TimeProvider
|
|
{
|
|
private DateTimeOffset _now = new(utcNow, TimeSpan.Zero);
|
|
|
|
public override DateTimeOffset GetUtcNow() => _now;
|
|
|
|
public void Advance(TimeSpan by) => _now = _now.Add(by);
|
|
}
|
|
}
|