89630ee418
With Security:Acl:Enabled=true, a session holding any grant narrower than
Cluster could not browse the served address space at all — confirmed by the
live gate.
Mechanism. Both realm trees hang under ONE anchor folder minted in
CreateAddressSpace: NodeId("OtOpcUa", <raw ns>). ResolveParentFolder parents
every top-level raw folder AND every top-level UNS area under it, and the
Browse override throws BadUserAccessDenied for the WHOLE call when its source
node is denied. DataPlaneAuthorizer.BuildScope split that identifier like a
path, producing {Kind: Raw, Segments: ["OtOpcUa"]} — but trie grant paths are
["raw", <RawPath segments…>] and "OtOpcUa" is never a path segment, so
PermissionTrie.HasBrowseTransit failed at the realm-child lookup and
CollectMatches yielded only Cluster-scoped grants. Deny, at the front door.
The trie already handled a zero-segment scope as "the realm root, itself an
ancestor of everything granted in that realm" (PermissionTrie.cs:101-118) —
no production path had ever produced that scope. This wires it up.
- V3NodeIds.SharedRootFolderId names the anchor identity; CreateAddressSpace
now mints the NodeId/BrowseName/DisplayName from it (behaviour-identical,
makes the coupling explicit).
- BuildScope maps that identifier (realm-qualified to Raw — in the UNS
namespace s=OtOpcUa is an ordinary Area) onto the zero-segment Raw scope.
- Evaluate adds a second-realm OR-leg for Browse on the anchor only: it fronts
both trees, so a principal whose only Browse grant lives in the UNS tree
still enters through this raw node. Browse only; transit confers navigation,
never read/write/subscribe.
- No trie, evaluator or builder change. No reordering above the decision cache
— key revocation is still re-checked before the lookup — and the cache key
is unchanged (the combined verdict caches under the anchor's own
identifier/realm; a generation advance clears it).
Falsifications actually run (implement-then-revert-the-line, plus one
genuine red-first):
- DataPlaneAuthorizerTests: the 7 new facts were written BEFORE the fix and 4
went red (the 3 deny-only ones passed vacuously, as a deny-only test must).
- Deleting the UNS OR-leg reddens exactly one fact,
The_shared_root_folder_is_browsable_on_a_uns_only_browse_grant (26/27 pass).
- Dropping the realm qualification from IsSharedRootFolder reddens exactly
A_uns_area_named_like_the_anchor_gets_no_anchor_semantics. Its first draft
did NOT catch this — a raw-only grant is refused either way; the
discriminating grant had to be an unrelated UNS one, whose own transit marks
the zero-segment UNS scope the un-qualified mapping would land on.
- Disabling the BuildScope anchor branch reddens the new end-to-end fact
NodeManagerStage6EnforcementTests.The_shared_root_folder_is_browsable_on_a_narrow_grant_and_still_filters
(real booted server, real Browse dispatch: BadUserAccessDenied on the whole
call), which also pins that the reference filter still withholds the
non-granted sibling.
- Core PermissionTrieBrowseTransitTests gains 3 characterization facts pinning
the previously-dead realm-root branch (transit true for the granting realm's
root, false for the other realm's, false without the Browse bit). These were
green on arrival — the trie was always correct; nothing called it.
Tests: Core 351 -> 354 passed; OpcUaServer 482 -> 490 passed. Build clean.
627 lines
30 KiB
C#
627 lines
30 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));
|
|
}
|
|
|
|
// ───────────────────────────── the cached hot path ─────────────────────────────
|
|
|
|
/// <summary>
|
|
/// A second identical question is answered from the decision cache without re-walking the grants —
|
|
/// which on the reordered hot path also means without rebuilding the <see cref="NodeScope"/> the
|
|
/// walk would need, because the lookup happens before the scope is built at all.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The evaluator count is the observable half: a hit that still built a scope would have nothing to
|
|
/// do with it, so the only way to see the short-circuit from outside is that the walk never ran. The
|
|
/// cache size pins that the second call was a hit rather than a second miss that happened to
|
|
/// overwrite the first entry.
|
|
/// </remarks>
|
|
[Fact]
|
|
public void A_cache_hit_does_not_rebuild_the_scope()
|
|
{
|
|
var gate = CountingGate(ClusterGrant(NodePermissions.Read), out var evaluator);
|
|
var node = RawNode("Plant/Modbus/dev1/Speed");
|
|
|
|
gate.AuthorizeCached(Identity(), OpcUaOperation.Read, node, AddressSpaceRealm.Raw).ShouldBeNull();
|
|
gate.AuthorizeCached(Identity(), OpcUaOperation.Read, node, AddressSpaceRealm.Raw).ShouldBeNull();
|
|
|
|
evaluator.Calls.ShouldBe(1);
|
|
gate.Decisions.Count.ShouldBe(1);
|
|
}
|
|
|
|
/// <summary>
|
|
/// <b>Pins the ordering the hot path must keep:</b> the key's usability is re-checked BEFORE the
|
|
/// decision cache is consulted, so a cached allow cannot outlive the key that earned it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Expiry rather than a disabling redeploy is the falsifiable form of this. A redeploy advances the
|
|
/// generation, which drops the cache wholesale — so a gate that consulted the cache first would
|
|
/// still deny, and the test would pass against the broken ordering. Expiry moves with the CLOCK and
|
|
/// leaves the generation (and therefore the memoized allow) exactly where it was, so the denial can
|
|
/// only have come from the key check running ahead of the lookup.
|
|
/// </remarks>
|
|
[Fact]
|
|
public void Key_revocation_is_rechecked_on_a_cache_hit()
|
|
{
|
|
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.Read)], Scopes());
|
|
|
|
var gate = new DataPlaneAuthorizer(
|
|
state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance, clock);
|
|
var node = RawNode("Plant/Modbus/dev1/Speed");
|
|
|
|
gate.AuthorizeCached(Identity(), OpcUaOperation.Read, node, AddressSpaceRealm.Raw).ShouldBeNull();
|
|
gate.Decisions.Count.ShouldBe(1, "the allow was memoized, so the next call takes the hit path");
|
|
|
|
clock.Advance(TimeSpan.FromHours(2));
|
|
|
|
Veto(gate.AuthorizeCached(Identity(), OpcUaOperation.Read, node, AddressSpaceRealm.Raw));
|
|
gate.Decisions.Generation.ShouldBe(state.Current.GenerationId);
|
|
gate.Decisions.Count.ShouldBe(1, "the memoized allow is untouched — the denial came from the key check");
|
|
}
|
|
|
|
/// <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);
|
|
}
|
|
|
|
// ───────────────────────────── the shared root folder ─────────────────────────────
|
|
|
|
/// <summary>
|
|
/// <b>The load-bearing allow.</b> Both realm trees hang under one anchor folder
|
|
/// (<c>s=OtOpcUa</c>, raw namespace), and the node manager's Browse gate refuses the WHOLE browse
|
|
/// when its source node is denied — so a session whose only grant is a narrow Browse deep in the raw
|
|
/// tree could not get past the front door at all. The anchor's identifier is a display name rather
|
|
/// than a path, so the ordinary split produced a one-segment scope (<c>["OtOpcUa"]</c>) that no
|
|
/// grant path can ever contain.
|
|
/// </summary>
|
|
[Fact]
|
|
public void The_shared_root_folder_is_browsable_on_a_narrow_raw_browse_grant()
|
|
{
|
|
var gate = Gate(
|
|
ScopedGrant(NodePermissions.Browse, NodeAclScopeKind.Tag, "tag-1"),
|
|
Scopes(("tag-1", NodeAclScopeKind.Tag, ["raw", "Plant", "Modbus", "dev1", "Speed"])));
|
|
|
|
gate.AuthorizeCached(Identity(), OpcUaOperation.Browse, Anchor(), AddressSpaceRealm.Raw)
|
|
.ShouldBeNull();
|
|
}
|
|
|
|
/// <summary>
|
|
/// The anchor fronts BOTH trees, so a principal whose only Browse grant lives in the UNS tree still
|
|
/// enters through this raw-namespace node — the gate must consider transit in either realm.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// This is the test that falsifies the second walk: delete the UNS OR-leg from
|
|
/// <c>DataPlaneAuthorizer.Evaluate</c> and only this fact goes red, because the first walk asks the
|
|
/// raw realm root, which a purely-UNS grant never marks.
|
|
/// </remarks>
|
|
[Fact]
|
|
public void The_shared_root_folder_is_browsable_on_a_uns_only_browse_grant()
|
|
{
|
|
var gate = Gate(
|
|
ScopedGrant(NodePermissions.Browse, NodeAclScopeKind.Equipment, "eq-1"),
|
|
Scopes(("eq-1", NodeAclScopeKind.Equipment, ["uns", "Area", "Line", "Eq"])));
|
|
|
|
gate.AuthorizeCached(Identity(), OpcUaOperation.Browse, Anchor(), AddressSpaceRealm.Raw)
|
|
.ShouldBeNull();
|
|
}
|
|
|
|
/// <summary>
|
|
/// The anchor is not a bypass: a principal holding no grant at all still cannot browse it, so the
|
|
/// mapping opens the front door only for a session that has somewhere to go.
|
|
/// </summary>
|
|
[Fact]
|
|
public void The_shared_root_folder_is_denied_without_any_grant()
|
|
{
|
|
var state = new DataPlaneAuthorityState();
|
|
state.Apply(Cluster, "rev-1", [Key(enabled: true)], [], Scopes());
|
|
var gate = new DataPlaneAuthorizer(
|
|
state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance);
|
|
|
|
Veto(gate.AuthorizeCached(Identity(), OpcUaOperation.Browse, Anchor(), AddressSpaceRealm.Raw));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Transit is marked only by grants that actually carry <c>Browse</c>. A deep Read-only grant leaves
|
|
/// the anchor closed — the node is readable by NodeId, not discoverable by navigating.
|
|
/// </summary>
|
|
[Fact]
|
|
public void The_shared_root_folder_is_denied_when_the_deep_grant_lacks_browse()
|
|
{
|
|
var gate = Gate(
|
|
ScopedGrant(NodePermissions.Read, NodeAclScopeKind.Tag, "tag-1"),
|
|
Scopes(("tag-1", NodeAclScopeKind.Tag, ["raw", "Plant", "Modbus", "dev1", "Speed"])));
|
|
|
|
Veto(gate.AuthorizeCached(Identity(), OpcUaOperation.Browse, Anchor(), AddressSpaceRealm.Raw));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Transit confers navigation and nothing else, at the anchor exactly as at every other ancestor: a
|
|
/// deep Browse grant does not make the root folder readable.
|
|
/// </summary>
|
|
[Fact]
|
|
public void The_shared_root_folder_is_not_readable_on_transit_alone()
|
|
{
|
|
var gate = Gate(
|
|
ScopedGrant(NodePermissions.Browse, NodeAclScopeKind.Tag, "tag-1"),
|
|
Scopes(("tag-1", NodeAclScopeKind.Tag, ["raw", "Plant", "Modbus", "dev1", "Speed"])));
|
|
|
|
gate.AuthorizeCached(Identity(), OpcUaOperation.Browse, Anchor(), AddressSpaceRealm.Raw).ShouldBeNull();
|
|
Veto(gate.AuthorizeCached(Identity(), OpcUaOperation.Read, Anchor(), AddressSpaceRealm.Raw));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Guard order is unchanged by the anchor branch: the key's usability is re-checked before any walk
|
|
/// runs, so a revoked key is refused the anchor even though its grant would confer transit.
|
|
/// </summary>
|
|
[Fact]
|
|
public void A_revoked_key_is_denied_the_shared_root_folder()
|
|
{
|
|
var state = new DataPlaneAuthorityState();
|
|
var grant = ScopedGrant(NodePermissions.Browse, NodeAclScopeKind.Tag, "tag-1");
|
|
var scopes = Scopes(("tag-1", NodeAclScopeKind.Tag, ["raw", "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.AuthorizeCached(Identity(), OpcUaOperation.Browse, Anchor(), AddressSpaceRealm.Raw).ShouldBeNull();
|
|
|
|
state.Apply(Cluster, "rev-2", [Key(enabled: false)], [grant], scopes);
|
|
Veto(gate.AuthorizeCached(Identity(), OpcUaOperation.Browse, Anchor(), AddressSpaceRealm.Raw));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Anchor semantics are realm-qualified. In the UNS namespace <c>s=OtOpcUa</c> is an ordinary Area
|
|
/// that an operator happened to name after the root folder, and it must be evaluated as the path it
|
|
/// is — a Browse grant in an unrelated UNS subtree must not open it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The discriminating grant is a <b>UNS</b> one deliberately. Un-qualifying
|
|
/// <c>IsSharedRootFolder</c> maps this node onto the zero-segment UNS scope, which the unrelated
|
|
/// grant's own transit marks — so the Area silently becomes browsable for a principal granted
|
|
/// nothing anywhere near it. A raw-only grant would have been refused either way and would have
|
|
/// proven nothing.
|
|
/// </remarks>
|
|
[Fact]
|
|
public void A_uns_area_named_like_the_anchor_gets_no_anchor_semantics()
|
|
{
|
|
var unsArea = new NodeId(V3NodeIds.SharedRootFolderId, 3);
|
|
var gate = Gate(
|
|
ScopedGrant(NodePermissions.Browse, NodeAclScopeKind.Equipment, "eq-1"),
|
|
Scopes(("eq-1", NodeAclScopeKind.Equipment, ["uns", "Area", "Line", "Eq"])));
|
|
|
|
Veto(gate.AuthorizeCached(Identity(), OpcUaOperation.Browse, unsArea, AddressSpaceRealm.Uns));
|
|
|
|
// Non-vacuity, twice over: the grant's OWN ancestor Area is browsable on transit, and the same
|
|
// identifier in the RAW namespace — the real anchor — is browsable on the same grant.
|
|
gate.AuthorizeCached(Identity(), OpcUaOperation.Browse, new NodeId("Area", 3), AddressSpaceRealm.Uns)
|
|
.ShouldBeNull();
|
|
gate.AuthorizeCached(Identity(), OpcUaOperation.Browse, Anchor(), AddressSpaceRealm.Raw)
|
|
.ShouldBeNull();
|
|
}
|
|
|
|
// ───────────────────────────── helpers ─────────────────────────────
|
|
|
|
/// <summary>The single shared root folder both realm trees hang under, in the raw namespace.</summary>
|
|
private static NodeId Anchor() => new(V3NodeIds.SharedRootFolderId, 2);
|
|
|
|
private static NodeAcl ScopedGrant(NodePermissions flags, NodeAclScopeKind kind, string scopeId) => new()
|
|
{
|
|
NodeAclId = $"acl-{scopeId}",
|
|
ClusterId = Cluster,
|
|
Principal = Principal,
|
|
ScopeKind = kind,
|
|
ScopeId = scopeId,
|
|
PermissionFlags = flags,
|
|
};
|
|
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The same gate <see cref="Gate"/> builds, with the REAL evaluator wrapped in a call counter — the
|
|
/// chain under test stays end-to-end, and only the number of walks becomes observable.
|
|
/// </summary>
|
|
private static DataPlaneAuthorizer CountingGate(NodeAcl grant, out CountingEvaluator evaluator)
|
|
{
|
|
var state = new DataPlaneAuthorityState();
|
|
state.Apply(Cluster, "rev-1", [Key(enabled: true)], [grant], Scopes());
|
|
evaluator = new CountingEvaluator(new TriePermissionEvaluator(state.Tries));
|
|
return new DataPlaneAuthorizer(state, evaluator, 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>Counts grant walks without changing any of their answers.</summary>
|
|
private sealed class CountingEvaluator(IPermissionEvaluator inner) : IPermissionEvaluator
|
|
{
|
|
public int Calls { get; private set; }
|
|
|
|
public AuthorizationDecision Authorize(
|
|
UserAuthorizationState session, OpcUaOperation operation, NodeScope scope)
|
|
{
|
|
Calls++;
|
|
return inner.Authorize(session, operation, scope);
|
|
}
|
|
}
|
|
|
|
/// <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);
|
|
}
|
|
}
|