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.