test(sdk-matrix): place the fixture's UNS Eq folder in the authority snapshot (#888)
v2-ci / unit-tests (push) Has been cancelled
v2-ci / unit-tests (push) Has been cancelled
Since #808 an UNS folder with no placement in the generation is denied (fail closed), so the matrix's ad hoc "Eq" folder made every UNS case fail with BadUserAccessDenied on the candidate server. The fixture now supplies unsFolderScopes (+ the alarm's tagReferencingEquipment) as a deployed generation does. An unplaced "Unplaced" UNS folder is kept as a negative control: the Shared client leg requires it denied against a #808+ server with the ACL on. No server authorization code changed. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
This commit is contained in:
@@ -126,7 +126,16 @@ internal static class ProcessHost
|
|||||||
var nm = server.NodeManager!; var sink = new SdkAddressSpaceSink(nm);
|
var nm = server.NodeManager!; var sink = new SdkAddressSpaceSink(nm);
|
||||||
var authority = new DataPlaneAuthorityState();
|
var authority = new DataPlaneAuthorityState();
|
||||||
authority.Apply("matrix", "rev1", [new ApiKey { ApiKeyId = "matrix", Name = "matrix", SecretHash = "fixture", Principal = "matrix", Enabled = true, CreatedUtc = DateTime.UtcNow.AddDays(-1) }],
|
authority.Apply("matrix", "rev1", [new ApiKey { ApiKeyId = "matrix", Name = "matrix", SecretHash = "fixture", Principal = "matrix", Enabled = true, CreatedUtc = DateTime.UtcNow.AddDays(-1) }],
|
||||||
[new NodeAcl { NodeAclId = "all", ClusterId = "matrix", Principal = "matrix", ScopeKind = NodeAclScopeKind.Cluster, PermissionFlags = (NodePermissions)int.MaxValue }], new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>());
|
[new NodeAcl { NodeAclId = "all", ClusterId = "matrix", Principal = "matrix", ScopeKind = NodeAclScopeKind.Cluster, PermissionFlags = (NodePermissions)int.MaxValue }], new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>()
|
||||||
|
#if SDK2
|
||||||
|
// lmxopcua#888: since #808 an UNS folder with no placement in the authority snapshot is denied (fail closed, no
|
||||||
|
// realm/cluster fallback). A real deployment's generation places every Area/Line/Equipment folder; do the same
|
||||||
|
// for the fixture's "Eq" equipment folder (a root folder, so its id chain is just ["Eq"]). The "Unplaced" folder
|
||||||
|
// below is deliberately left out of the map: it is the negative control that must stay denied.
|
||||||
|
, unsFolderScopes: new Dictionary<string, UnsFolderScope> { ["Eq"] = new UnsFolderScope(new NodeAclPath([NodeAclRealms.Uns, "Eq"]), ["Eq"]) },
|
||||||
|
tagReferencingEquipment: new Dictionary<string, IReadOnlySet<string>> { [Alarm] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "Eq" } }
|
||||||
|
#endif
|
||||||
|
);
|
||||||
nm.DataPlaneAuthority = authority;
|
nm.DataPlaneAuthority = authority;
|
||||||
// OPCUA_MATRIX_ACL=off mirrors the shipped default (Security:Acl:Enabled=false): no node authorizer.
|
// OPCUA_MATRIX_ACL=off mirrors the shipped default (Security:Acl:Enabled=false): no node authorizer.
|
||||||
if (Environment.GetEnvironmentVariable("OPCUA_MATRIX_ACL") != "off")
|
if (Environment.GetEnvironmentVariable("OPCUA_MATRIX_ACL") != "off")
|
||||||
@@ -134,6 +143,10 @@ internal static class ProcessHost
|
|||||||
var history = new Source(Root); nm.HistorianDataSource = history;
|
var history = new Source(Root); nm.HistorianDataSource = history;
|
||||||
sink.EnsureFolder(Folder, null, "dev", AddressSpaceRealm.Raw);
|
sink.EnsureFolder(Folder, null, "dev", AddressSpaceRealm.Raw);
|
||||||
sink.EnsureFolder("Eq", null, "Eq", AddressSpaceRealm.Uns);
|
sink.EnsureFolder("Eq", null, "Eq", AddressSpaceRealm.Uns);
|
||||||
|
// Negative control (#888): an UNS folder + variable the generation does not place must stay denied under the ACL.
|
||||||
|
sink.EnsureFolder("Unplaced", null, "Unplaced", AddressSpaceRealm.Uns);
|
||||||
|
sink.EnsureVariable("Unplaced/Scalar", "Unplaced", "Scalar", "Int32", true, AddressSpaceRealm.Uns);
|
||||||
|
sink.WriteValue("Unplaced/Scalar", 5, 0, Epoch, AddressSpaceRealm.Uns);
|
||||||
foreach (var realm in new[] { AddressSpaceRealm.Raw, AddressSpaceRealm.Uns })
|
foreach (var realm in new[] { AddressSpaceRealm.Raw, AddressSpaceRealm.Uns })
|
||||||
{
|
{
|
||||||
var scalar = realm == AddressSpaceRealm.Raw ? Scalar : "Eq/Scalar";
|
var scalar = realm == AddressSpaceRealm.Raw ? Scalar : "Eq/Scalar";
|
||||||
@@ -247,6 +260,18 @@ internal static class ProcessHost
|
|||||||
Require(new[] { "Eq/Scalar", "Eq/Vector" }.Select(id => new NodeId(id, uns).ToString()).All(id => unsChildren.Any(child => child.NodeId == id)), "Missing UNS scalar/array children");
|
Require(new[] { "Eq/Scalar", "Eq/Vector" }.Select(id => new NodeId(id, uns).ToString()).All(id => unsChildren.Any(child => child.NodeId == id)), "Missing UNS scalar/array children");
|
||||||
});
|
});
|
||||||
await Case("initial-quality-explicit-source-and-server-timestamps", async () => { var initial = await client.ReadValueAsync(ScalarId(raw), Ct); Require(StatusCode.IsGood(initial.StatusCode), "Bad initial quality"); Require(SourceTime(initial) == Epoch && ServerTime(initial) > Epoch, "Lost explicit initial timestamps"); Observations.Add(new { sourceTimestamp = SourceTime(initial).ToString("O"), serverTimestamp = ServerTime(initial).ToString("O"), contract = "Source supplied as 2026-10-01T00:00:00Z; server timestamp observed without normalization or historian algorithm changes" }); });
|
await Case("initial-quality-explicit-source-and-server-timestamps", async () => { var initial = await client.ReadValueAsync(ScalarId(raw), Ct); Require(StatusCode.IsGood(initial.StatusCode), "Bad initial quality"); Require(SourceTime(initial) == Epoch && ServerTime(initial) > Epoch, "Lost explicit initial timestamps"); Observations.Add(new { sourceTimestamp = SourceTime(initial).ToString("O"), serverTimestamp = ServerTime(initial).ToString("O"), contract = "Source supplied as 2026-10-01T00:00:00Z; server timestamp observed without normalization or historian algorithm changes" }); });
|
||||||
|
// #888 negative control: the unplaced UNS folder's variable must be denied against a #808+ server with the ACL on
|
||||||
|
// (a 1.x baseline server predates #808; with OPCUA_MATRIX_ACL=off there is no authorizer, so it reads Good).
|
||||||
|
await Case("uns-unplaced-folder-denied-or-open-by-acl-mode", async () =>
|
||||||
|
{
|
||||||
|
var expectDenied = Environment.GetEnvironmentVariable("OPCUA_MATRIX_ACL") != "off" && (serverSdk?.StartsWith("2.", StringComparison.Ordinal) ?? false);
|
||||||
|
string outcome;
|
||||||
|
try { var v = await client.ReadValueAsync(new NodeId("Unplaced/Scalar", uns), Ct); outcome = v.StatusCode.ToString(); }
|
||||||
|
catch (Exception e) { outcome = e.Message; }
|
||||||
|
Observations.Add(new { unplacedRead = outcome, expectDenied });
|
||||||
|
var denied = outcome.Contains("BadUserAccessDenied", StringComparison.Ordinal);
|
||||||
|
Require(expectDenied ? denied : !denied, $"Unplaced UNS folder read: expected {(expectDenied ? "denied" : "not denied")}, got {outcome}");
|
||||||
|
});
|
||||||
foreach (var ns in new[] { raw, uns })
|
foreach (var ns in new[] { raw, uns })
|
||||||
{
|
{
|
||||||
await Case($"scalar-read-write-ns-{ns}", async () => { Require(Clr(await client.ReadValueAsync(ScalarId(ns), Ct)) is int, "Scalar lost Int32 type"); Require(StatusCode.IsGood(await client.WriteValueAsync(ScalarId(ns), 42, Ct)), "Scalar write failed"); Require((int)Clr(await client.ReadValueAsync(ScalarId(ns), Ct))! == 42, "Scalar round trip failed"); });
|
await Case($"scalar-read-write-ns-{ns}", async () => { Require(Clr(await client.ReadValueAsync(ScalarId(ns), Ct)) is int, "Scalar lost Int32 type"); Require(StatusCode.IsGood(await client.WriteValueAsync(ScalarId(ns), 42, Ct)), "Scalar write failed"); Require((int)Clr(await client.ReadValueAsync(ScalarId(ns), Ct))! == 42, "Scalar round trip failed"); });
|
||||||
|
|||||||
@@ -32,9 +32,10 @@ the ordinary compatibility host remains strict SignAndEncrypt. Before each secur
|
|||||||
leg the probe's `prepare` step provisions the consumer identity through
|
leg the probe's `prepare` step provisions the consumer identity through
|
||||||
ScadaBridge's own certificate code and exports only its public certificate, which
|
ScadaBridge's own certificate code and exports only its public certificate, which
|
||||||
the runner places in the server's trusted store before the server starts.
|
the runner places in the server's trusted store before the server starts.
|
||||||
`OPCUA_MATRIX_ACL=off` disables the server's node-ACL check; with it on, masters
|
`OPCUA_MATRIX_ACL=off` disables the server's node-ACL check. The fixture's `Eq` equipment folder is placed in the
|
||||||
after lmxopcua#808 deny the three UNS cases because this fixture creates the `Eq`
|
authority snapshot (`unsFolderScopes`, as a deployed generation does since lmxopcua#808, fixed in lmxopcua#888), so
|
||||||
folder without a deployed generation (fixture limitation, see lmxopcua#782). The task key is
|
the UNS cases pass with the ACL on and off; an unplaced `Unplaced` UNS folder is kept as a negative control that the
|
||||||
|
Shared client leg requires to stay denied with the ACL on. The task key is
|
||||||
generated per process pair, issuer/trusted/rejected stores are task-local, and
|
generated per process pair, issuer/trusted/rejected stores are task-local, and
|
||||||
server public certificates are explicitly copied. Autoaccept remains false.
|
server public certificates are explicitly copied. Autoaccept remains false.
|
||||||
Never commit an entire output directory: it contains the fixture's private PKI.
|
Never commit an entire output directory: it contains the fixture's private PKI.
|
||||||
|
|||||||
Reference in New Issue
Block a user