Files
Marc Schier eb40a59566 More format and diagnostic fixes (#3780)
Drive the solution to zero warnings / zero errors with analyzers on,
promote the consistently-fixed rules to error, and lock in coverage
on the only line of new logic.
2026-05-26 06:06:09 +02:00

949 lines
40 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2025 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 Microsoft.Extensions.Logging;
namespace Opc.Ua.Server
{
/// <summary>
/// Binds the standard <c>RoleSet</c> object and its child <c>RoleType</c>
/// instances to an <see cref="IRoleManager"/> using the source-generated
/// typed proxies (<see cref="RoleSetState"/>, <see cref="RoleState"/>,
/// <see cref="AddRoleMethodState"/>, etc.).
/// </summary>
/// <remarks>
/// <para>
/// Every typed method state's <c>OnCallAsync</c> delegate is wired through
/// <see cref="RoleAuthorizationGate.CheckAdmin"/>, mutates the
/// <see cref="IRoleManager"/>, reports a
/// <c>RoleMappingRuleChangedAuditEventType</c> via
/// <see cref="AuditEvents.ReportAuditRoleMappingRuleChangedEvent"/> and
/// keeps the property values exposed on the address-space role node in
/// sync with the manager state (so reads observe live values).
/// </para>
/// <para>
/// The binding relies on <see cref="DiagnosticsNodeManager"/>'s
/// <c>AddBehaviourToPredefinedNodeAsync</c> override having upgraded the
/// passive <c>BaseObjectState</c> nodes representing <c>RoleSet</c> and
/// each well-known role to the typed proxies before <see cref="Bind"/>
/// is invoked.
/// </para>
/// </remarks>
public sealed class RoleStateBinding : IDisposable
{
private readonly AsyncCustomNodeManager m_nodeManager;
private readonly IRoleManager m_roleManager;
private readonly IAuditEventServer? m_auditServer;
private readonly ILogger m_logger;
private readonly Dictionary<NodeId, RoleState> m_boundRoles = [];
private RoleSetState? m_roleSet;
private bool m_disposed;
private RoleStateBinding(
AsyncCustomNodeManager nodeManager,
IRoleManager roleManager,
IAuditEventServer? auditServer)
{
m_nodeManager = nodeManager;
m_roleManager = roleManager;
m_auditServer = auditServer;
m_logger = nodeManager.Server?.Telemetry?.CreateLogger<RoleStateBinding>()
?? Microsoft.Extensions.Logging.Abstractions.NullLogger<RoleStateBinding>.Instance;
}
/// <summary>
/// Resolves the <see cref="RoleSetState"/> in <paramref name="nodeManager"/>'s
/// predefined nodes, wires every child role to <paramref name="roleManager"/>
/// and starts listening for <see cref="IRoleManager.RoleConfigurationChanged"/>
/// events. Returns the binding instance so the caller can dispose it
/// on server shutdown.
/// </summary>
/// <exception cref="ArgumentNullException">
/// <paramref name="nodeManager"/> is null.
/// </exception>
/// <exception cref="ArgumentNullException">
/// <paramref name="roleManager"/> is null.
/// </exception>
public static RoleStateBinding? Bind(
AsyncCustomNodeManager nodeManager,
IRoleManager roleManager,
IAuditEventServer? auditServer)
{
if (nodeManager == null)
{
throw new ArgumentNullException(nameof(nodeManager));
}
if (roleManager == null)
{
throw new ArgumentNullException(nameof(roleManager));
}
RoleSetState? roleSet = nodeManager.FindPredefinedNode<RoleSetState>(
ObjectIds.Server_ServerCapabilities_RoleSet);
if (roleSet == null)
{
return null;
}
var binding = new RoleStateBinding(nodeManager, roleManager, auditServer);
binding.Initialize(roleSet);
return binding;
}
/// <inheritdoc/>
public void Dispose()
{
if (m_disposed)
{
return;
}
m_disposed = true;
m_roleManager.RoleConfigurationChanged -= OnRoleConfigurationChanged;
}
private void Initialize(RoleSetState roleSet)
{
m_roleSet = roleSet;
ushort dynamicNamespaceIndex = ResolveDynamicNamespaceIndex();
if (roleSet.AddRole != null)
{
roleSet.AddRole.OnCall = null;
roleSet.AddRole.OnCallAsync = (context, method, objectId, roleName, namespaceUri, ct) =>
OnAddRoleAsync(context, method, roleName, namespaceUri, dynamicNamespaceIndex, ct);
}
if (roleSet.RemoveRole != null)
{
roleSet.RemoveRole.OnCall = null;
roleSet.RemoveRole.OnCallAsync = (context, method, objectId, roleNodeId, ct) =>
OnRemoveRoleAsync(context, method, roleNodeId, ct);
}
var children = new List<BaseInstanceState>();
roleSet.GetChildren(m_nodeManager.SystemContext, children);
foreach (BaseInstanceState child in children)
{
if (child is RoleState roleStateChild)
{
BindRoleState(roleStateChild);
}
}
// The standard nodeset wires the well-known role nodes to the
// RoleSet via a HasComponent reference but GetChildren on the
// typed RoleSetState proxy only enumerates the typed AddRole /
// RemoveRole method children. Walk the raw references and look
// each role up in the manager's PredefinedNodes index.
var refs = new List<IReference>();
roleSet.GetReferences(m_nodeManager.SystemContext, refs);
foreach (IReference reference in refs)
{
if (reference.IsInverse ||
reference.ReferenceTypeId != ReferenceTypeIds.HasComponent)
{
continue;
}
var targetId = ExpandedNodeId.ToNodeId(reference.TargetId,
m_nodeManager.SystemContext.NamespaceUris);
if (targetId.IsNull)
{
continue;
}
NodeState? target = m_nodeManager.FindPredefinedNode<NodeState>(targetId);
if (target is RoleState roleStateRef && !m_boundRoles.ContainsKey(targetId))
{
BindRoleState(roleStateRef);
}
}
m_roleManager.RoleConfigurationChanged += OnRoleConfigurationChanged;
}
private ushort ResolveDynamicNamespaceIndex()
{
if (m_nodeManager.NamespaceUris == null)
{
return 0;
}
// The diagnostics manager owns both the OPC UA core namespace
// and a dedicated "Diagnostics" namespace. Pick the first one
// that is NOT the OPC UA core URI so dynamically allocated role
// NodeIds don't collide with reserved Part 5 identifiers
// (e.g. i=1 = Boolean in ns=0).
foreach (string uri in m_nodeManager.NamespaceUris)
{
if (string.IsNullOrEmpty(uri))
{
continue;
}
if (string.Equals(uri, "http://opcfoundation.org/UA/", StringComparison.Ordinal))
{
continue;
}
int idx = m_nodeManager.SystemContext.NamespaceUris.GetIndex(uri);
if (idx is > 0 and <= ushort.MaxValue)
{
return (ushort)idx;
}
}
return 0;
}
private void BindRoleState(RoleState roleState)
{
NodeId roleId = roleState.NodeId;
m_boundRoles[roleId] = roleState;
// When the role node was loaded from the standard nodeset XML,
// its method/property children are plain MethodState /
// PropertyState instances rather than the typed proxies the
// source-gen RoleState exposes. Promote them so the typed
// OnCallAsync / OnWriteValue plumbing applies uniformly.
EnsureTypedChildrenAttached(roleState);
if (roleState.AddIdentity != null)
{
roleState.AddIdentity.OnCall = null;
roleState.AddIdentity.OnCallAsync = (context, method, objectId, rule, ct) =>
OnAddIdentityAsync(context, method, roleId, rule);
}
if (roleState.RemoveIdentity != null)
{
roleState.RemoveIdentity.OnCall = null;
roleState.RemoveIdentity.OnCallAsync = (context, method, objectId, rule, ct) =>
OnRemoveIdentityAsync(context, method, roleId, rule);
}
if (roleState.AddApplication != null)
{
roleState.AddApplication.OnCall = null;
roleState.AddApplication.OnCallAsync = (context, method, objectId, applicationUri, ct) =>
OnAddApplicationAsync(context, method, roleId, applicationUri);
}
if (roleState.RemoveApplication != null)
{
roleState.RemoveApplication.OnCall = null;
roleState.RemoveApplication.OnCallAsync = (context, method, objectId, applicationUri, ct) =>
OnRemoveApplicationAsync(context, method, roleId, applicationUri);
}
if (roleState.AddEndpoint != null)
{
roleState.AddEndpoint.OnCall = null;
roleState.AddEndpoint.OnCallAsync = (context, method, objectId, endpoint, ct) =>
OnAddEndpointAsync(context, method, roleId, endpoint);
}
if (roleState.RemoveEndpoint != null)
{
roleState.RemoveEndpoint.OnCall = null;
roleState.RemoveEndpoint.OnCallAsync = (context, method, objectId, endpoint, ct) =>
OnRemoveEndpointAsync(context, method, roleId, endpoint);
}
if (roleState.ApplicationsExclude != null)
{
roleState.ApplicationsExclude.UserAccessLevel = AccessLevels.CurrentReadOrWrite;
roleState.ApplicationsExclude.AccessLevel = AccessLevels.CurrentReadOrWrite;
roleState.ApplicationsExclude.OnWriteValue = WriteApplicationsExcludeHandler(roleId);
}
if (roleState.EndpointsExclude != null)
{
roleState.EndpointsExclude.UserAccessLevel = AccessLevels.CurrentReadOrWrite;
roleState.EndpointsExclude.AccessLevel = AccessLevels.CurrentReadOrWrite;
roleState.EndpointsExclude.OnWriteValue = WriteEndpointsExcludeHandler(roleId);
}
if (roleState.CustomConfiguration != null)
{
roleState.CustomConfiguration.UserAccessLevel = AccessLevels.CurrentReadOrWrite;
roleState.CustomConfiguration.AccessLevel = AccessLevels.CurrentReadOrWrite;
roleState.CustomConfiguration.OnWriteValue = WriteCustomConfigurationHandler(roleId);
}
SyncPropertiesFromManager(roleId, roleState);
}
/// <summary>
/// Promotes plain <see cref="MethodState"/> / <see cref="PropertyState"/>
/// children of <paramref name="roleState"/> to the typed proxies
/// exposed by the source-generated <see cref="RoleState"/> shape.
/// </summary>
/// <remarks>
/// <para>
/// The standard nodeset XML loader instantiates each child as the
/// generic base type. The <see cref="RoleState.AddIdentity"/> and
/// related typed properties remain <see langword="null"/> unless we
/// explicitly construct an <c>AddIdentityMethodState</c> (etc.)
/// from the existing passive child and assign it back. Without this
/// promotion the typed <c>OnCallAsync</c> delegates that
/// <see cref="BindRoleState"/> wires up are never reached and the
/// server returns <c>BadNotImplemented</c>.
/// </para>
/// <para>
/// Children that are already typed (e.g. ones materialized via
/// <see cref="MaterializeDynamicRoleAsync"/> for dynamically added
/// roles) are left untouched.
/// </para>
/// </remarks>
private void EnsureTypedChildrenAttached(RoleState roleState)
{
ISystemContext context = m_nodeManager.SystemContext;
roleState.AddIdentity ??= PromoteMethodChild(
m_nodeManager, context, roleState, BrowseNames.AddIdentity,
parent => new AddIdentityMethodState(parent));
roleState.RemoveIdentity ??= PromoteMethodChild(
m_nodeManager, context, roleState, BrowseNames.RemoveIdentity,
parent => new RemoveIdentityMethodState(parent));
roleState.AddApplication ??= PromoteMethodChild(
m_nodeManager, context, roleState, BrowseNames.AddApplication,
parent => new AddApplicationMethodState(parent));
roleState.RemoveApplication ??= PromoteMethodChild(
m_nodeManager, context, roleState, BrowseNames.RemoveApplication,
parent => new RemoveApplicationMethodState(parent));
roleState.AddEndpoint ??= PromoteMethodChild(
m_nodeManager, context, roleState, BrowseNames.AddEndpoint,
parent => new AddEndpointMethodState(parent));
roleState.RemoveEndpoint ??= PromoteMethodChild(
m_nodeManager, context, roleState, BrowseNames.RemoveEndpoint,
parent => new RemoveEndpointMethodState(parent));
roleState.ApplicationsExclude ??= PromoteBoolPropertyChild(
m_nodeManager, context, roleState, BrowseNames.ApplicationsExclude);
roleState.EndpointsExclude ??= PromoteBoolPropertyChild(
m_nodeManager, context, roleState, BrowseNames.EndpointsExclude);
roleState.CustomConfiguration ??= PromoteBoolPropertyChild(
m_nodeManager, context, roleState, BrowseNames.CustomConfiguration);
}
private static TTyped? PromoteMethodChild<TTyped>(
AsyncCustomNodeManager manager,
ISystemContext context,
NodeState parent,
string browseName,
Func<NodeState, TTyped> factory)
where TTyped : MethodState
{
BaseInstanceState? passive = FindChildByBrowseName(context, parent, browseName);
if (passive is not MethodState passiveMethod)
{
return null;
}
if (passiveMethod is TTyped typedAlready)
{
return typedAlready;
}
// Construct the typed proxy and re-bind it in place of the
// passive method. Create(context, source) copies the NodeId,
// BrowseName, references, etc. so the existing method
// dispatch on the original NodeId continues to work.
TTyped active = factory(parent);
active.Create(context, passiveMethod);
parent.ReplaceChild(context, active);
// The manager's PredefinedNodes index still points at the
// passive instance. Refresh it so server-side method dispatch
// (which looks up by NodeId) lands on the typed proxy.
manager.ReplacePredefinedNode(active.NodeId, active);
return active;
}
private static PropertyState<bool>? PromoteBoolPropertyChild(
AsyncCustomNodeManager manager,
ISystemContext context,
NodeState parent,
string browseName)
{
BaseInstanceState? passive = FindChildByBrowseName(context, parent, browseName);
if (passive == null)
{
return null;
}
if (passive is PropertyState<bool> typedAlready)
{
return typedAlready;
}
if (passive is not BaseVariableState passiveVar)
{
return null;
}
var active = PropertyState<bool>.With<VariantBuilder>(parent);
active.Create(context, passiveVar);
parent.ReplaceChild(context, active);
manager.ReplacePredefinedNode(active.NodeId, active);
return active;
}
private static BaseInstanceState? FindChildByBrowseName(
ISystemContext context, NodeState parent, string browseName)
{
var children = new List<BaseInstanceState>();
parent.GetChildren(context, children);
for (int ii = 0; ii < children.Count; ii++)
{
BaseInstanceState child = children[ii];
if (child.BrowseName.Name == browseName)
{
return child;
}
}
return null;
}
private NodeValueEventHandler WriteApplicationsExcludeHandler(NodeId roleId)
{
return (context, node, indexRange,
dataEncoding, ref value,
ref statusCode, ref timestamp) =>
OnWriteExclude(context, roleId, ref value, isApplications: true);
}
private NodeValueEventHandler WriteEndpointsExcludeHandler(NodeId roleId)
{
return (context, node, indexRange,
dataEncoding, ref value,
ref statusCode, ref timestamp) =>
OnWriteExclude(context, roleId, ref value, isApplications: false);
}
private NodeValueEventHandler WriteCustomConfigurationHandler(NodeId roleId)
{
return (context, node, indexRange,
dataEncoding, ref value,
ref statusCode, ref timestamp) =>
OnWriteCustomConfiguration(context, roleId, ref value);
}
private void SyncPropertiesFromManager(NodeId roleId, RoleState roleState)
{
RoleEntry? entry = m_roleManager.GetRole(roleId);
if (entry == null)
{
return;
}
roleState.Identities?.Value = ArrayOf.Wrapped(entry.Identities.ToArray());
roleState.Applications?.Value = ArrayOf.Wrapped(entry.Applications.ToArray());
roleState.ApplicationsExclude?.Value = entry.ApplicationsExclude;
roleState.Endpoints?.Value = ArrayOf.Wrapped(entry.Endpoints.ToArray());
roleState.EndpointsExclude?.Value = entry.EndpointsExclude;
roleState.CustomConfiguration?.Value = entry.CustomConfiguration;
roleState.ClearChangeMasks(m_nodeManager.SystemContext, includeChildren: true);
}
private void OnRoleConfigurationChanged(object? sender, RoleConfigurationChangedEventArgs e)
{
if (m_disposed)
{
return;
}
if (e.Kind == RoleConfigurationChangeKind.RoleRemoved)
{
m_boundRoles.Remove(e.RoleId);
return;
}
if (m_boundRoles.TryGetValue(e.RoleId, out RoleState? roleState))
{
SyncPropertiesFromManager(e.RoleId, roleState);
}
}
private async ValueTask<AddRoleMethodStateResult> OnAddRoleAsync(
ISystemContext context,
MethodState method,
string roleName,
string namespaceUri,
ushort dynamicNamespaceIndex,
CancellationToken cancellationToken)
{
await Task.Yield();
var result = new AddRoleMethodStateResult { RoleNodeId = NodeId.Null };
ServiceResult auth = RoleAuthorizationGate.CheckAdmin(context);
if (ServiceResult.IsBad(auth))
{
result.ServiceResult = auth;
return result;
}
ServiceResult add = m_roleManager.AddRole(
roleName,
namespaceUri,
context.NamespaceUris,
dynamicNamespaceIndex,
out NodeId newRoleId);
if (ServiceResult.IsGood(add))
{
result.RoleNodeId = newRoleId;
// Materialize the RoleType subtree into the address space so
// browsers and method callers see the dynamic role straight
// away. Failures here are logged but not surfaced as a status
// code so the RoleManager state stays consistent with what
// AddRole reported.
try
{
await MaterializeDynamicRoleAsync(
context,
newRoleId,
roleName,
cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
m_logger.LogWarning(ex,
"AddRole({RoleName}) succeeded in the RoleManager but address-space materialization failed.",
roleName);
}
}
result.ServiceResult = add;
return result;
}
private async ValueTask<RemoveRoleMethodStateResult> OnRemoveRoleAsync(
ISystemContext context,
MethodState method,
NodeId roleNodeId,
CancellationToken cancellationToken)
{
await Task.Yield();
ServiceResult auth = RoleAuthorizationGate.CheckAdmin(context);
if (ServiceResult.IsBad(auth))
{
return new RemoveRoleMethodStateResult { ServiceResult = auth };
}
ServiceResult remove = m_roleManager.RemoveRole(roleNodeId);
if (ServiceResult.IsGood(remove))
{
// Drop the address-space subtree so subsequent browses don't
// see the deleted role. Mirror the AddRole behaviour: failures
// are logged and swallowed so the RoleManager state stays
// authoritative.
try
{
await DematerializeDynamicRoleAsync(roleNodeId, cancellationToken)
.ConfigureAwait(false);
}
catch (Exception ex)
{
m_logger.LogWarning(ex,
"RemoveRole({RoleId}) succeeded in the RoleManager but address-space removal failed.",
roleNodeId);
}
}
return new RemoveRoleMethodStateResult { ServiceResult = remove };
}
/// <summary>
/// Materializes a dynamically added <c>RoleType</c> instance
/// underneath the bound <see cref="RoleSetState"/> using the
/// source-generated typed proxies.
/// </summary>
/// <remarks>
/// The well-known roles ship pre-built in the standard nodeset; this
/// method only fires for roles created at runtime via
/// <see cref="IRoleManager.AddRole"/>. The factory
/// <c>CreateInstanceOfRoleType</c> sets the well-known RoleType NodeId
/// (15620) as the default — we overwrite it with the dynamic role's
/// allocated NodeId and allocate fresh NodeIds for each optional child
/// via <see cref="ISystemContext.NodeIdFactory"/>.
/// </remarks>
private async ValueTask MaterializeDynamicRoleAsync(
ISystemContext context,
NodeId roleNodeId,
string roleName,
CancellationToken cancellationToken)
{
if (m_roleSet == null)
{
return;
}
ushort browseNs = roleNodeId.NamespaceIndex;
RoleState roleState = context.CreateInstanceOfRoleType(
parent: m_roleSet,
browseName: new QualifiedName(roleName, browseNs));
roleState.NodeId = roleNodeId;
roleState.SymbolicName = roleName;
roleState.DisplayName = new LocalizedText(roleName);
// Optional method children — wire each one in via the typed
// factories so the OnCallAsync delegates on the source-generated
// method state become available for binding. Each child must
// be added BOTH to the typed property (for binding lookups)
// AND to the base m_children collection (so Browse traversal
// sees the HasComponent reference). Browse walks GetReferences
// (not m_children), so we also need to add explicit forward
// and inverse references for every materialized child.
// Pass the spec-defined BrowseName so the standard browse-path
// lookups (e.g. "/AddIdentity") match the instance.
const ushort opcUaNs = 0;
roleState.AddIdentity = context.CreateInstanceOfAddIdentityMethodType(
roleState, new QualifiedName(BrowseNames.AddIdentity, opcUaNs));
AssignChildNodeId(context, roleState.AddIdentity);
LinkChild(roleState, roleState.AddIdentity);
roleState.RemoveIdentity = context.CreateInstanceOfRemoveIdentityMethodType(
roleState, new QualifiedName(BrowseNames.RemoveIdentity, opcUaNs));
AssignChildNodeId(context, roleState.RemoveIdentity);
LinkChild(roleState, roleState.RemoveIdentity);
roleState.AddApplication = context.CreateInstanceOfAddApplicationMethodType(
roleState, new QualifiedName(BrowseNames.AddApplication, opcUaNs));
AssignChildNodeId(context, roleState.AddApplication);
LinkChild(roleState, roleState.AddApplication);
roleState.RemoveApplication = context.CreateInstanceOfRemoveApplicationMethodType(
roleState, new QualifiedName(BrowseNames.RemoveApplication, opcUaNs));
AssignChildNodeId(context, roleState.RemoveApplication);
LinkChild(roleState, roleState.RemoveApplication);
roleState.AddEndpoint = context.CreateInstanceOfAddEndpointMethodType(
roleState, new QualifiedName(BrowseNames.AddEndpoint, opcUaNs));
AssignChildNodeId(context, roleState.AddEndpoint);
LinkChild(roleState, roleState.AddEndpoint);
roleState.RemoveEndpoint = context.CreateInstanceOfRemoveEndpointMethodType(
roleState, new QualifiedName(BrowseNames.RemoveEndpoint, opcUaNs));
AssignChildNodeId(context, roleState.RemoveEndpoint);
LinkChild(roleState, roleState.RemoveEndpoint);
// Optional property children — ApplicationsExclude / EndpointsExclude
// / CustomConfiguration are all PropertyState<bool>. Use the
// HasProperty reference type rather than HasComponent so the
// browse classification matches the standard nodeset.
roleState.ApplicationsExclude = BuildBoolProperty(context, roleState, BrowseNames.ApplicationsExclude);
LinkChild(roleState, roleState.ApplicationsExclude, ReferenceTypeIds.HasProperty);
roleState.EndpointsExclude = BuildBoolProperty(context, roleState, BrowseNames.EndpointsExclude);
LinkChild(roleState, roleState.EndpointsExclude, ReferenceTypeIds.HasProperty);
roleState.CustomConfiguration = BuildBoolProperty(context, roleState, BrowseNames.CustomConfiguration);
LinkChild(roleState, roleState.CustomConfiguration, ReferenceTypeIds.HasProperty);
// The Identities child is created by the factory but its NodeId
// still points at the type definition — give it a dynamic id too
// so it is addressable as an instance.
if (roleState.Identities != null)
{
AssignChildNodeId(context, roleState.Identities);
}
// Attach to RoleSet so browse references are established before
// the manager indexes the subtree. AddChild only updates the
// typed children collection; the actual HasComponent reference
// pair that Browse traverses is added explicitly so the new
// role shows up when clients walk the RoleSet's children.
m_roleSet.AddChild(roleState);
m_roleSet.AddReference(ReferenceTypeIds.HasComponent, isInverse: false, roleState.NodeId);
roleState.AddReference(ReferenceTypeIds.HasComponent, isInverse: true, m_roleSet.NodeId);
await m_nodeManager.AddPredefinedNodeAsync(roleState, cancellationToken)
.ConfigureAwait(false);
// Now that the state is in PredefinedNodes, wire the OnCallAsync
// delegates and OnWriteValue handlers via the existing binding
// path. This keeps materialization and wiring identical to the
// well-known role path.
BindRoleState(roleState);
m_logger.LogDebug(
"Materialized dynamic role {RoleName} ({RoleId}) under RoleSet.",
roleName, roleNodeId);
}
private async ValueTask DematerializeDynamicRoleAsync(
NodeId roleNodeId,
CancellationToken cancellationToken)
{
// Drop the binding bookkeeping first so any concurrent
// RoleConfigurationChanged listener walks the new state.
m_boundRoles.Remove(roleNodeId);
await m_nodeManager.DeleteNodeAsync(
m_nodeManager.SystemContext,
roleNodeId,
cancellationToken)
.ConfigureAwait(false);
}
private static void AssignChildNodeId(ISystemContext context, BaseInstanceState? child)
{
if (child == null || context.NodeIdFactory == null)
{
return;
}
child.NodeId = context.NodeIdFactory.New(context, child);
}
/// <summary>
/// Attaches <paramref name="child"/> to <paramref name="parent"/> by
/// registering the typed child link (<c>AddChild</c>) and adding
/// explicit forward + inverse references so Browse traversal sees
/// the new HasComponent / HasProperty relationship.
/// </summary>
private static void LinkChild(NodeState parent, BaseInstanceState? child, NodeId? referenceTypeId = null)
{
if (child == null || child.NodeId.IsNull)
{
return;
}
NodeId refTypeId = referenceTypeId ?? ReferenceTypeIds.HasComponent;
parent.AddChild(child);
parent.AddReference(refTypeId, isInverse: false, child.NodeId);
child.AddReference(refTypeId, isInverse: true, parent.NodeId);
}
private static PropertyState<bool> BuildBoolProperty(
ISystemContext context,
NodeState parent,
string browseName)
{
// Property BrowseNames in the standard nodeset live in the OPC UA
// base namespace (always index 0). Look it up rather than relying
// on the internal Namespaces.OpcUa constant so this code stays
// compatible if the runtime resequences known namespaces.
int opcUaNsIndex = context.NamespaceUris.GetIndex("http://opcfoundation.org/UA/");
ushort ns = opcUaNsIndex >= 0 ? (ushort)opcUaNsIndex : (ushort)0;
var property = PropertyState<bool>.With<VariantBuilder>(parent);
property.BrowseName = new QualifiedName(browseName, ns);
property.DisplayName = new LocalizedText(browseName);
property.SymbolicName = browseName;
property.DataType = DataTypeIds.Boolean;
property.ValueRank = ValueRanks.Scalar;
AssignChildNodeId(context, property);
return property;
}
private async ValueTask<AddIdentityMethodStateResult> OnAddIdentityAsync(
ISystemContext context,
MethodState method,
NodeId roleId,
IdentityMappingRuleType rule)
{
await Task.Yield();
var ruleVariant = Variant.FromStructure(rule);
var result = new AddIdentityMethodStateResult();
ServiceResult auth = RoleAuthorizationGate.CheckAdmin(context);
if (ServiceResult.IsBad(auth))
{
result.ServiceResult = auth;
ReportAudit(context, roleId, method, [ruleVariant], success: false);
return result;
}
ServiceResult sr = m_roleManager.AddIdentity(roleId, rule);
result.ServiceResult = sr;
ReportAudit(context, roleId, method, [ruleVariant], ServiceResult.IsGood(sr));
return result;
}
private async ValueTask<RemoveIdentityMethodStateResult> OnRemoveIdentityAsync(
ISystemContext context,
MethodState method,
NodeId roleId,
IdentityMappingRuleType rule)
{
await Task.Yield();
var ruleVariant = Variant.FromStructure(rule);
var result = new RemoveIdentityMethodStateResult();
ServiceResult auth = RoleAuthorizationGate.CheckAdmin(context);
if (ServiceResult.IsBad(auth))
{
result.ServiceResult = auth;
ReportAudit(context, roleId, method, [ruleVariant], success: false);
return result;
}
ServiceResult sr = m_roleManager.RemoveIdentity(roleId, rule);
result.ServiceResult = sr;
ReportAudit(context, roleId, method, [ruleVariant], ServiceResult.IsGood(sr));
return result;
}
private async ValueTask<AddApplicationMethodStateResult> OnAddApplicationAsync(
ISystemContext context,
MethodState method,
NodeId roleId,
string applicationUri)
{
await Task.Yield();
var uriVariant = Variant.From(applicationUri);
var result = new AddApplicationMethodStateResult();
ServiceResult auth = RoleAuthorizationGate.CheckAdmin(context);
if (ServiceResult.IsBad(auth))
{
result.ServiceResult = auth;
ReportAudit(context, roleId, method, [uriVariant], success: false);
return result;
}
ServiceResult sr = m_roleManager.AddApplication(roleId, applicationUri);
result.ServiceResult = sr;
ReportAudit(context, roleId, method, [uriVariant], ServiceResult.IsGood(sr));
return result;
}
private async ValueTask<RemoveApplicationMethodStateResult> OnRemoveApplicationAsync(
ISystemContext context,
MethodState method,
NodeId roleId,
string applicationUri)
{
await Task.Yield();
var uriVariant = Variant.From(applicationUri);
var result = new RemoveApplicationMethodStateResult();
ServiceResult auth = RoleAuthorizationGate.CheckAdmin(context);
if (ServiceResult.IsBad(auth))
{
result.ServiceResult = auth;
ReportAudit(context, roleId, method, [uriVariant], success: false);
return result;
}
ServiceResult sr = m_roleManager.RemoveApplication(roleId, applicationUri);
result.ServiceResult = sr;
ReportAudit(context, roleId, method, [uriVariant], ServiceResult.IsGood(sr));
return result;
}
private async ValueTask<AddEndpointMethodStateResult> OnAddEndpointAsync(
ISystemContext context,
MethodState method,
NodeId roleId,
EndpointType endpoint)
{
await Task.Yield();
var epVariant = Variant.FromStructure(endpoint);
var result = new AddEndpointMethodStateResult();
ServiceResult auth = RoleAuthorizationGate.CheckAdmin(context);
if (ServiceResult.IsBad(auth))
{
result.ServiceResult = auth;
ReportAudit(context, roleId, method, [epVariant], success: false);
return result;
}
ServiceResult sr = m_roleManager.AddEndpoint(roleId, endpoint);
result.ServiceResult = sr;
ReportAudit(context, roleId, method, [epVariant], ServiceResult.IsGood(sr));
return result;
}
private async ValueTask<RemoveEndpointMethodStateResult> OnRemoveEndpointAsync(
ISystemContext context,
MethodState method,
NodeId roleId,
EndpointType endpoint)
{
await Task.Yield();
var epVariant = Variant.FromStructure(endpoint);
var result = new RemoveEndpointMethodStateResult();
ServiceResult auth = RoleAuthorizationGate.CheckAdmin(context);
if (ServiceResult.IsBad(auth))
{
result.ServiceResult = auth;
ReportAudit(context, roleId, method, [epVariant], success: false);
return result;
}
ServiceResult sr = m_roleManager.RemoveEndpoint(roleId, endpoint);
result.ServiceResult = sr;
ReportAudit(context, roleId, method, [epVariant], ServiceResult.IsGood(sr));
return result;
}
private ServiceResult OnWriteExclude(
ISystemContext context, NodeId roleId, ref Variant value, bool isApplications)
{
ServiceResult auth = RoleAuthorizationGate.CheckAdmin(context);
if (ServiceResult.IsBad(auth))
{
return auth;
}
if (!value.TryGetValue(out bool b))
{
return new ServiceResult(StatusCodes.BadTypeMismatch);
}
return isApplications
? m_roleManager.SetApplicationsExclude(roleId, b)
: m_roleManager.SetEndpointsExclude(roleId, b);
}
private ServiceResult OnWriteCustomConfiguration(
ISystemContext context, NodeId roleId, ref Variant value)
{
ServiceResult auth = RoleAuthorizationGate.CheckAdmin(context);
if (ServiceResult.IsBad(auth))
{
return auth;
}
if (!value.TryGetValue(out bool b))
{
return new ServiceResult(StatusCodes.BadTypeMismatch);
}
return m_roleManager.SetCustomConfiguration(roleId, b);
}
private void ReportAudit(
ISystemContext context,
NodeId roleId,
MethodState method,
Variant[] inputArguments,
bool success)
{
if (m_auditServer == null)
{
return;
}
m_auditServer.ReportAuditRoleMappingRuleChangedEvent(
context,
roleId,
method,
ArrayOf.Wrapped(inputArguments),
success,
m_logger);
}
}
}