From 1e1fad76c460f384ba5a4b0409600ca07242c929 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 12 Nov 2026 11:45:15 -0500 Subject: [PATCH] test(sdk-matrix): place the fixture's UNS Eq folder in the authority snapshot (#888) 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 --- .../opcua-sdk-compatibility/ProcessHost.cs | 27 ++++++++++++++++++- .../scadabridge-sdk-compatibility/README.md | 7 ++--- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/scripts/validation/opcua-sdk-compatibility/ProcessHost.cs b/scripts/validation/opcua-sdk-compatibility/ProcessHost.cs index 290b5a359..24a159805 100644 --- a/scripts/validation/opcua-sdk-compatibility/ProcessHost.cs +++ b/scripts/validation/opcua-sdk-compatibility/ProcessHost.cs @@ -126,7 +126,16 @@ internal static class ProcessHost var nm = server.NodeManager!; var sink = new SdkAddressSpaceSink(nm); 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) }], - [new NodeAcl { NodeAclId = "all", ClusterId = "matrix", Principal = "matrix", ScopeKind = NodeAclScopeKind.Cluster, PermissionFlags = (NodePermissions)int.MaxValue }], new Dictionary>()); + [new NodeAcl { NodeAclId = "all", ClusterId = "matrix", Principal = "matrix", ScopeKind = NodeAclScopeKind.Cluster, PermissionFlags = (NodePermissions)int.MaxValue }], new Dictionary>() +#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 { ["Eq"] = new UnsFolderScope(new NodeAclPath([NodeAclRealms.Uns, "Eq"]), ["Eq"]) }, + tagReferencingEquipment: new Dictionary> { [Alarm] = new HashSet(StringComparer.OrdinalIgnoreCase) { "Eq" } } +#endif + ); nm.DataPlaneAuthority = authority; // OPCUA_MATRIX_ACL=off mirrors the shipped default (Security:Acl:Enabled=false): no node authorizer. if (Environment.GetEnvironmentVariable("OPCUA_MATRIX_ACL") != "off") @@ -134,6 +143,10 @@ internal static class ProcessHost var history = new Source(Root); nm.HistorianDataSource = history; sink.EnsureFolder(Folder, null, "dev", AddressSpaceRealm.Raw); 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 }) { 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"); }); 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 }) { 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"); }); diff --git a/scripts/validation/scadabridge-sdk-compatibility/README.md b/scripts/validation/scadabridge-sdk-compatibility/README.md index de5568094..62a012776 100644 --- a/scripts/validation/scadabridge-sdk-compatibility/README.md +++ b/scripts/validation/scadabridge-sdk-compatibility/README.md @@ -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 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. -`OPCUA_MATRIX_ACL=off` disables the server's node-ACL check; with it on, masters -after lmxopcua#808 deny the three UNS cases because this fixture creates the `Eq` -folder without a deployed generation (fixture limitation, see lmxopcua#782). The task key is +`OPCUA_MATRIX_ACL=off` disables the server's node-ACL check. The fixture's `Eq` equipment folder is placed in the +authority snapshot (`unsFolderScopes`, as a deployed generation does since lmxopcua#808, fixed in lmxopcua#888), so +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 server public certificates are explicitly copied. Autoaccept remains false. Never commit an entire output directory: it contains the fixture's private PKI.