From 6a450f01421b3c7572b04819fe56f2cab8e5a5cb Mon Sep 17 00:00:00 2001 From: Roman Ettlinger Date: Wed, 7 Oct 2026 07:03:35 +0200 Subject: [PATCH 1/3] GDS: pin SecurityAdmin-only delivery of ApplicationRegistrationChangedAuditEvents; document CTT C57 Since #4675 the server enforces ReceiveEvents on the EventType node (OPC 10000-3 8.55). The GDS NodeSet grants it on ApplicationRegistrationChangedAuditEventType (AuditEventDefaultPermissions) only to SecurityAdmin, so a DiscoveryAdmin that calls RegisterApplication, UpdateApplication or UnregisterApplication no longer receives the audit events. CTT GDS Application Directory 011/018/028 subscribe as the DiscoveryAdmin user and fail since; documented as CTT defect C57. The new test calls the three Methods as DiscoveryAdmin and asserts that a SecurityAdmin subscriber on Server.EventNotifier receives all three audit events with SourceNode = Directory, and that a DiscoveryAdmin subscriber receives none. Co-Authored-By: Claude Opus 5.5 --- plans/ctt-issues.md | 18 +- ...sApplicationRegistrationAuditEventTests.cs | 301 ++++++++++++++++++ 2 files changed, 318 insertions(+), 1 deletion(-) create mode 100644 tests/Opc.Ua.Gds.Tests/GdsApplicationRegistrationAuditEventTests.cs diff --git a/plans/ctt-issues.md b/plans/ctt-issues.md index 7cdf4b4168..bb007b0313 100644 --- a/plans/ctt-issues.md +++ b/plans/ctt-issues.md @@ -8,7 +8,7 @@ CTT project configuration notes follow the tables. The procedure for running the - "Resolved / fixed" in Mantis means the fix is in the CTT script repository. It ships with a script build after 1.05.513, so an installed 1.05.513 still shows the failure. -- The ids (1–19, C1–C56, U1–U5) are stable references for notes and commit messages; missing ids were +- The ids (1–19, C1–C57, U1–U5) are stable references for notes and commit messages; missing ids were withdrawn or no longer fail against the reference server. - Mantis states were last checked on 2026-09-25. @@ -174,6 +174,22 @@ CTT project configuration notes follow the tables. The procedure for running the EURange is not configured", which the server returns since #4629 (Create and Modify alike). Monitor Basic `Err-022.js` already accepts `Bad_FilterNotAllowed` for non-analog items; the accepted list of `Err-006.js` needs `Bad_DeadbandFilterInvalid`. Draft ready, not filed yet. +- **C57. GDS Application Directory `011.js`, `018.js`, `028.js`** subscribe to the events of the Server Object on + the Session of the GDS `AdministrativeUser` (a DiscoveryAdmin) and expect the + `ApplicationRegistrationChangedAuditEventType` events of the RegisterApplication, UnregisterApplication and + UpdateApplication calls they make ("Step 3: Did not receive any Events after calling Publish."). A Client only + receives an Event if it has ReceiveEvents on the EventType Node (OPC 10000-3 §8.55), and the GDS NodeSet + grants it on that EventType (AuditEventDefaultPermissions) only to SecurityAdmin. The server enforces this + since #4675, so the three test cases pass only if the subscription is made on a second Session of the + `SecurityAdminUser`, which the CU already defines (a script patch with that change passes all three; + `GdsApplicationRegistrationAuditEventTests` covers both sides). Draft Mantis text: + > **GDS Application Directory 011/018/028: audit events are expected on a DiscoveryAdmin Session.** The test + > cases subscribe to the Server Object as the AdministrativeUser (DiscoveryAdmin) and expect the + > ApplicationRegistrationChangedAuditEventType events of the Methods they call. The GDS NodeSet grants + > ReceiveEvents on ApplicationRegistrationChangedAuditEventType only to SecurityAdmin + > (AuditEventDefaultPermissions), and OPC 10000-3 §8.55 delivers an Event only if the Client has + > ReceiveEvents on its EventType, so a compliant GDS does not send these events to a DiscoveryAdmin. Please + > create the event subscription on a separate Session activated with the SecurityAdminUser. - **Aggregate expected-value differences.** - Non-numeric nodes: status-only aggregates (DurationGood/Bad, PercentGood/Bad, WorstQuality2, DurationInState*) differ on Boolean/String nodes from numeric nodes with the same status timeline, and the CTT returns diff --git a/tests/Opc.Ua.Gds.Tests/GdsApplicationRegistrationAuditEventTests.cs b/tests/Opc.Ua.Gds.Tests/GdsApplicationRegistrationAuditEventTests.cs new file mode 100644 index 0000000000..e1c3519938 --- /dev/null +++ b/tests/Opc.Ua.Gds.Tests/GdsApplicationRegistrationAuditEventTests.cs @@ -0,0 +1,301 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.Client; + +namespace Opc.Ua.Gds.Tests +{ + /// + /// The ApplicationRegistrationChangedAuditEvents of RegisterApplication, + /// UpdateApplication and UnregisterApplication (OPC 10000-12 6.5.12) + /// reach a subscriber on Server.EventNotifier. + /// + /// + /// A Client only receives an Event if it has ReceiveEvents on the + /// EventType Node (OPC 10000-3 8.55), and the GDS NodeSet grants it on + /// ApplicationRegistrationChangedAuditEventType (AuditEventDefaultPermissions) + /// only to SecurityAdmin. A DiscoveryAdmin may call the Methods but + /// does not receive their audit events; the CTT GDS Application + /// Directory test cases 011, 018 and 028 subscribe as the DiscoveryAdmin + /// user and therefore have to subscribe with a SecurityAdmin Session. + /// + [TestFixture] + [Category("GDS")] + [Category("GDSApplicationDirectory")] + [Category("Audit")] + public class GdsApplicationRegistrationAuditEventTests : GdsTestFixture + { + [OneTimeSetUp] + public async Task AuditEventSetUpAsync() + { + m_directoryNodeId = ToNodeId(ObjectIds.Directory); + m_eventTypeId = ToNodeId(ObjectTypeIds.ApplicationRegistrationChangedAuditEventType); + + m_discoveryAdminSession = await ClientFixture + .ConnectAsync( + ServerUrl, + SecurityPolicies.Basic256Sha256, + default, + new UserIdentity("DiscoveryAdmin", "demo"u8)) + .ConfigureAwait(false); + if (!m_discoveryAdminSession.MessageContext.Factory.ContainsEncodeableType( + DataTypeIds.ApplicationRecordDataType)) + { + m_discoveryAdminSession.MessageContext.Factory.Builder.AddOpcUaGds().Commit(); + } + } + + [OneTimeTearDown] + public async Task AuditEventTearDownAsync() + { + if (m_discoveryAdminSession != null) + { + await m_discoveryAdminSession.CloseAsync(5000, true).ConfigureAwait(false); + m_discoveryAdminSession.Dispose(); + m_discoveryAdminSession = null; + } + } + + [Test] + public async Task ApplicationRegistrationAuditEventsReachSecurityAdminSubscriberAsync() + { + // The fixture Session is a SecurityAdmin; the Methods are called by + // a DiscoveryAdmin, like the CTT does. + uint securityAdminSubscription = await SubscribeToServerEventsAsync(Session) + .ConfigureAwait(false); + uint discoveryAdminSubscription = await SubscribeToServerEventsAsync(m_discoveryAdminSession) + .ConfigureAwait(false); + try + { + ApplicationRecordDataType record = CreateTestApplicationRecord("AuditEvents"); + NodeId applicationId = await RegisterApplicationAsync(record).ConfigureAwait(false); + record.ApplicationId = applicationId; + record.ProductUri = "urn:opcfoundation.org:tests:test:product:AuditEventsUpdated"; + await CallDirectoryMethodAsync( + MethodIds.Directory_UpdateApplication, + new Variant(new ExtensionObject(record))).ConfigureAwait(false); + await CallDirectoryMethodAsync( + MethodIds.Directory_UnregisterApplication, + new Variant(applicationId)).ConfigureAwait(false); + + NodeId[] expectedMethods = + [ + ToNodeId(MethodIds.Directory_RegisterApplication), + ToNodeId(MethodIds.Directory_UpdateApplication), + ToNodeId(MethodIds.Directory_UnregisterApplication) + ]; + + List received = await CollectRegistrationEventsAsync( + Session, + events => expectedMethods.All(m => events.Any(e => e.MethodId == m))) + .ConfigureAwait(false); + + foreach (NodeId methodId in expectedMethods) + { + RegistrationAuditEvent auditEvent = received.FirstOrDefault(e => e.MethodId == methodId); + Assert.That(auditEvent, Is.Not.Null, + $"No ApplicationRegistrationChangedAuditEvent for Method {methodId}; received " + + $"{received.Count} event(s) of that type."); + Assert.That(auditEvent.SourceNode, Is.EqualTo(m_directoryNodeId), + "The SourceNode of the audit event shall be the Directory Object."); + } + + // The DiscoveryAdmin has no ReceiveEvents on the EventType. + List discoveryAdminEvents = await CollectRegistrationEventsAsync( + m_discoveryAdminSession, + _ => false, + TimeSpan.FromSeconds(1)).ConfigureAwait(false); + Assert.That(discoveryAdminEvents, Is.Empty, + "Only SecurityAdmin has ReceiveEvents on ApplicationRegistrationChangedAuditEventType."); + } + finally + { + await DeleteSubscriptionAsync(Session, securityAdminSubscription).ConfigureAwait(false); + await DeleteSubscriptionAsync(m_discoveryAdminSession, discoveryAdminSubscription) + .ConfigureAwait(false); + } + } + + private static async Task SubscribeToServerEventsAsync(ISession session) + { + CreateSubscriptionResponse subscription = await session.CreateSubscriptionAsync( + null, 100, 1000, 10, 0, true, 0, CancellationToken.None).ConfigureAwait(false); + + var filter = new EventFilter + { + SelectClauses = + [ + Field(Ua.ObjectTypeIds.BaseEventType, Ua.BrowseNames.EventType), + Field(Ua.ObjectTypeIds.BaseEventType, Ua.BrowseNames.SourceNode), + Field(Ua.ObjectTypeIds.AuditUpdateMethodEventType, Ua.BrowseNames.MethodId) + ], + WhereClause = new ContentFilter() + }; + CreateMonitoredItemsResponse items = await session.CreateMonitoredItemsAsync( + null, + subscription.SubscriptionId, + TimestampsToReturn.Neither, + new MonitoredItemCreateRequest[] + { + new() + { + ItemToMonitor = new ReadValueId + { + NodeId = Ua.ObjectIds.Server, + AttributeId = Attributes.EventNotifier + }, + MonitoringMode = MonitoringMode.Reporting, + RequestedParameters = new MonitoringParameters + { + ClientHandle = 1, + SamplingInterval = 0, + Filter = new ExtensionObject(filter), + QueueSize = 100, + DiscardOldest = true + } + } + }.ToArrayOf(), + CancellationToken.None).ConfigureAwait(false); + + Assert.That(items.Results.Count, Is.EqualTo(1)); + Assert.That(StatusCode.IsGood(items.Results[0].StatusCode), Is.True, + $"Subscribing to the events of the Server failed: {items.Results[0].StatusCode}"); + return subscription.SubscriptionId; + } + + private static SimpleAttributeOperand Field(NodeId typeDefinitionId, string browseName) + { + return new SimpleAttributeOperand + { + TypeDefinitionId = typeDefinitionId, + BrowsePath = [new QualifiedName(browseName)], + AttributeId = Attributes.Value + }; + } + + /// + /// Publishes until returns true or the + /// timeout expires and returns the ApplicationRegistrationChangedAuditEvents. + /// + private async Task> CollectRegistrationEventsAsync( + ISession session, + Func, bool> isComplete, + TimeSpan? timeout = null) + { + var received = new List(); + DateTime deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(10)); + while (!isComplete(received) && DateTime.UtcNow < deadline) + { + PublishResponse response = await session.PublishAsync( + null, + Array.Empty().ToArrayOf(), + CancellationToken.None).ConfigureAwait(false); + + foreach (ExtensionObject notification in response.NotificationMessage.NotificationData) + { + if (!notification.TryGetValue(out EventNotificationList eventList)) + { + continue; + } + foreach (EventFieldList fields in eventList.Events) + { + if (fields.EventFields.Count == 3 && + fields.EventFields[0].TryGetValue(out NodeId eventType) && + eventType == m_eventTypeId) + { + fields.EventFields[1].TryGetValue(out NodeId sourceNode); + fields.EventFields[2].TryGetValue(out NodeId methodId); + received.Add(new RegistrationAuditEvent(sourceNode, methodId)); + } + } + } + } + return received; + } + + private static async Task DeleteSubscriptionAsync(ISession session, uint subscriptionId) + { + try + { + await session.DeleteSubscriptionsAsync( + null, + new uint[] { subscriptionId }.ToArrayOf(), + CancellationToken.None).ConfigureAwait(false); + } + catch (ServiceResultException) + { + // best effort + } + } + + private async Task RegisterApplicationAsync(ApplicationRecordDataType record) + { + CallMethodResult result = await CallDirectoryMethodAsync( + MethodIds.Directory_RegisterApplication, + new Variant(new ExtensionObject(record))).ConfigureAwait(false); + Assert.That(result.OutputArguments.Count, Is.EqualTo(1)); + return (NodeId)result.OutputArguments[0]; + } + + private async Task CallDirectoryMethodAsync( + ExpandedNodeId methodId, + Variant argument) + { + CallResponse response = await m_discoveryAdminSession.CallAsync( + null, + new CallMethodRequest[] + { + new() + { + ObjectId = m_directoryNodeId, + MethodId = ToNodeId(methodId), + InputArguments = new Variant[] { argument }.ToArrayOf() + } + }.ToArrayOf(), + CancellationToken.None).ConfigureAwait(false); + + Assert.That(response.Results.Count, Is.EqualTo(1)); + Assert.That(StatusCode.IsGood(response.Results[0].StatusCode), Is.True, + $"Calling {methodId} as DiscoveryAdmin failed: {response.Results[0].StatusCode}"); + return response.Results[0]; + } + + private sealed record RegistrationAuditEvent(NodeId SourceNode, NodeId MethodId); + + private NodeId m_directoryNodeId; + private NodeId m_eventTypeId; + private ISession m_discoveryAdminSession; + } +} From 6171e43ac05eddb1352d4c5d1ac25563fdd38733 Mon Sep 17 00:00:00 2001 From: Roman Ettlinger Date: Wed, 7 Oct 2026 07:20:20 +0200 Subject: [PATCH 2/3] plans/ctt-issues.md: C57 filed as Mantis 11482 Co-Authored-By: Claude Opus 5.5 --- plans/ctt-issues.md | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/plans/ctt-issues.md b/plans/ctt-issues.md index bb007b0313..5975254819 100644 --- a/plans/ctt-issues.md +++ b/plans/ctt-issues.md @@ -98,6 +98,7 @@ CTT project configuration notes follow the tables. The procedure for running the | C30 | Query Applications `036.js` | Expects `rcp+` DiscoveryUrls the test never registered. | [11412](https://mantis.opcfoundation.org/view.php?id=11412) | | C31 | Query Applications `038.js` | Treats applicationType 3 as invalid. | [11413](https://mantis.opcfoundation.org/view.php?id=11413) | | C51 | Application Directory `066.js`, `079.js`; Query Applications `039.js` (pass only because the server returns NA records) | Register the reference Servers with ServerCapabilities `NA` and expect them in query results, which OPC 10000-12 §6.5.10/§6.5.11 excludes; with the exclusion 40 test cases fail. | [11458](https://mantis.opcfoundation.org/view.php?id=11458) | +| C57 | Application Directory `011.js`, `018.js`, `028.js` | Expect the ApplicationRegistrationChangedAuditEvents on the Session of the DiscoveryAdmin user, but the GDS NodeSet grants ReceiveEvents on that EventType only to SecurityAdmin (OPC 10000-3 §8.55); the subscription belongs on a second Session of the `SecurityAdminUser`. | [11482](https://mantis.opcfoundation.org/view.php?id=11482) | ### Alarms and Conditions @@ -174,22 +175,6 @@ CTT project configuration notes follow the tables. The procedure for running the EURange is not configured", which the server returns since #4629 (Create and Modify alike). Monitor Basic `Err-022.js` already accepts `Bad_FilterNotAllowed` for non-analog items; the accepted list of `Err-006.js` needs `Bad_DeadbandFilterInvalid`. Draft ready, not filed yet. -- **C57. GDS Application Directory `011.js`, `018.js`, `028.js`** subscribe to the events of the Server Object on - the Session of the GDS `AdministrativeUser` (a DiscoveryAdmin) and expect the - `ApplicationRegistrationChangedAuditEventType` events of the RegisterApplication, UnregisterApplication and - UpdateApplication calls they make ("Step 3: Did not receive any Events after calling Publish."). A Client only - receives an Event if it has ReceiveEvents on the EventType Node (OPC 10000-3 §8.55), and the GDS NodeSet - grants it on that EventType (AuditEventDefaultPermissions) only to SecurityAdmin. The server enforces this - since #4675, so the three test cases pass only if the subscription is made on a second Session of the - `SecurityAdminUser`, which the CU already defines (a script patch with that change passes all three; - `GdsApplicationRegistrationAuditEventTests` covers both sides). Draft Mantis text: - > **GDS Application Directory 011/018/028: audit events are expected on a DiscoveryAdmin Session.** The test - > cases subscribe to the Server Object as the AdministrativeUser (DiscoveryAdmin) and expect the - > ApplicationRegistrationChangedAuditEventType events of the Methods they call. The GDS NodeSet grants - > ReceiveEvents on ApplicationRegistrationChangedAuditEventType only to SecurityAdmin - > (AuditEventDefaultPermissions), and OPC 10000-3 §8.55 delivers an Event only if the Client has - > ReceiveEvents on its EventType, so a compliant GDS does not send these events to a DiscoveryAdmin. Please - > create the event subscription on a separate Session activated with the SecurityAdminUser. - **Aggregate expected-value differences.** - Non-numeric nodes: status-only aggregates (DurationGood/Bad, PercentGood/Bad, WorstQuality2, DurationInState*) differ on Boolean/String nodes from numeric nodes with the same status timeline, and the CTT returns From 7220e6a6e14ba9a80238399016b987271b45f224 Mon Sep 17 00:00:00 2001 From: Roman Ettlinger Date: Wed, 7 Oct 2026 07:24:04 +0200 Subject: [PATCH 3/3] Seal GdsApplicationRegistrationAuditEventTests Co-Authored-By: Claude Opus 5.5 --- .../GdsApplicationRegistrationAuditEventTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Opc.Ua.Gds.Tests/GdsApplicationRegistrationAuditEventTests.cs b/tests/Opc.Ua.Gds.Tests/GdsApplicationRegistrationAuditEventTests.cs index e1c3519938..2e6670c4cf 100644 --- a/tests/Opc.Ua.Gds.Tests/GdsApplicationRegistrationAuditEventTests.cs +++ b/tests/Opc.Ua.Gds.Tests/GdsApplicationRegistrationAuditEventTests.cs @@ -55,7 +55,7 @@ namespace Opc.Ua.Gds.Tests [Category("GDS")] [Category("GDSApplicationDirectory")] [Category("Audit")] - public class GdsApplicationRegistrationAuditEventTests : GdsTestFixture + public sealed class GdsApplicationRegistrationAuditEventTests : GdsTestFixture { [OneTimeSetUp] public async Task AuditEventSetUpAsync()