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
This commit is contained in:
@@ -44,14 +44,21 @@ public sealed class ArtifactApiKeyStoreTests
|
||||
found.Grants.ShouldHaveSingleItem().PermissionFlags.ShouldBe(NodePermissions.WriteOperate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The FULL grant set is returned, narrow scopes included (#520 stage 5). It used to narrow to
|
||||
/// <c>Cluster</c> here, which was redundant — <see cref="ApiKeyInterimRoleMapper"/> applies exactly
|
||||
/// that filter itself — and became a trap once scope was enforced, because a caller reading
|
||||
/// <c>ApiKeyLookup.Grants</c> as "this principal's grants" got a silently truncated set.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Cluster confinement still happens, just not here: <c>ParseDataPlaneAuthority</c> already kept
|
||||
/// only this node's cluster's rows, which is where this store differs from
|
||||
/// <c>ConfigDbApiKeyStore</c> (that one reads the fleet-wide database and confines it itself; see
|
||||
/// <c>ConfigDbApiKeyStoreTests</c>).
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task Only_cluster_scoped_grants_are_returned()
|
||||
public async Task Grants_narrower_than_cluster_are_returned_too()
|
||||
{
|
||||
// Narrower scopes are not yet enforceable (stage 5), and ApiKeyInterimRoleMapper deliberately
|
||||
// under-grants rather than promoting a scoped grant's flags to server-wide authority. Scope kind
|
||||
// is the only narrowing this store performs — cluster confinement already happened in
|
||||
// ParseDataPlaneAuthority, which is where it differs from ConfigDbApiKeyStore (that one filters
|
||||
// on cluster itself; see ConfigDbApiKeyStoreTests).
|
||||
var state = new DataPlaneAuthorityState();
|
||||
state.Apply("cluster-a", "rev-1",
|
||||
[Key("abc", "p")],
|
||||
@@ -62,7 +69,32 @@ public sealed class ArtifactApiKeyStoreTests
|
||||
|
||||
var found = await new ArtifactApiKeyStore(state).FindAsync("abc", Now, default);
|
||||
|
||||
found!.Grants.ShouldHaveSingleItem().NodeAclId.ShouldBe("acl-cluster");
|
||||
found!.Grants.Select(g => g.NodeAclId).ShouldBe(["acl-cluster", "acl-equipment"], ignoreOrder: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Widening the store did not widen the ROLES. The narrow grant reaches the mapper and still
|
||||
/// confers nothing, so a rule authored as "WriteOperate on one equipment" cannot become
|
||||
/// server-wide write authority on a node with ACL enforcement switched off.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This assertion is the reason the widening is safe, and it lives here rather than only in the
|
||||
/// mapper's own tests because the store is what changed: a test of the mapper alone would have
|
||||
/// stayed green no matter what the store returned.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task Widening_the_store_does_not_widen_the_interim_roles()
|
||||
{
|
||||
var state = new DataPlaneAuthorityState();
|
||||
state.Apply("cluster-a", "rev-1",
|
||||
[Key("abc", "p")],
|
||||
[Acl("acl-equipment", "p", NodeAclScopeKind.Equipment, NodePermissions.WriteOperate)],
|
||||
NoScopePaths);
|
||||
|
||||
var found = await new ArtifactApiKeyStore(state).FindAsync("abc", Now, default);
|
||||
|
||||
found!.Grants.ShouldHaveSingleItem();
|
||||
ApiKeyInterimRoleMapper.Map(found.Grants, "p").ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -6,6 +6,7 @@ using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
|
||||
@@ -146,24 +147,35 @@ public sealed class ConfigDbApiKeyStoreTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Narrower-than-Cluster scopes are still excluded (stage 5 widens this) — the cluster filter is
|
||||
/// an ADDITIONAL narrowing, not a replacement for the scope-kind one.
|
||||
/// Narrower-than-Cluster scopes ARE returned (#520 stage 5 widened this) — the cluster filter is
|
||||
/// now the only narrowing this store performs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The scope-kind filter that used to sit alongside it was redundant: <c>ApiKeyInterimRoleMapper</c>
|
||||
/// applies exactly that filter itself, so removing it changes no role — pinned below — while a
|
||||
/// truncated <c>Grants</c> collection would have quietly misled anything that read it once scope
|
||||
/// started being enforced.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task A_narrower_scope_in_this_cluster_is_still_excluded()
|
||||
public async Task A_narrower_scope_in_this_cluster_is_returned()
|
||||
{
|
||||
var factory = NewInMemoryDbFactory();
|
||||
Seed(factory,
|
||||
keys: [Key("abc", "p")],
|
||||
acls:
|
||||
[
|
||||
Acl("acl-cluster", ThisCluster, "p", NodeAclScopeKind.Cluster, NodePermissions.Read),
|
||||
Acl("acl-cluster", ThisCluster, "p", NodeAclScopeKind.Cluster, NodePermissions.AlarmAcknowledge),
|
||||
Acl("acl-equipment", ThisCluster, "p", NodeAclScopeKind.Equipment, NodePermissions.WriteOperate),
|
||||
]);
|
||||
|
||||
var found = await new ConfigDbApiKeyStore(factory, ThisCluster).FindAsync("abc", Now, default);
|
||||
|
||||
found!.Grants.ShouldHaveSingleItem().NodeAclId.ShouldBe("acl-cluster");
|
||||
found!.Grants.Select(g => g.NodeAclId).ShouldBe(["acl-cluster", "acl-equipment"], ignoreOrder: true);
|
||||
|
||||
// Both grants now reach the mapper, and it still takes flags from the CLUSTER-scoped one only:
|
||||
// AlarmAck is conferred, WriteOperate is not. The two grants carry different flags precisely so
|
||||
// this cannot pass by accident — which is why widening the store is safe with enforcement off.
|
||||
ApiKeyInterimRoleMapper.Map(found.Grants, "p").ShouldBe([OpcUaDataPlaneRoles.AlarmAck]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user