/* ======================================================================== * 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 Opc.Ua.Identity; using Opc.Ua.Security.Certificates; namespace Opc.Ua.Server { /// /// In-memory default implementation of . /// Pre-populates the nine well-known roles per OPC UA Part 3 §4.9.2 and /// their default identities per Part 18 §4.3. All operations are /// thread-safe. /// /// /// Per Part 18 §6.4 "the management of these Roles is server-specific" — /// this default keeps everything in memory and does not persist across /// server restarts. Integrators that need persistence should implement /// directly and inject the instance via /// . /// public sealed class RoleManager : IRoleManager, IDisposable { private static readonly NodeId s_anonymous = ObjectIds.WellKnownRole_Anonymous; private static readonly NodeId s_authenticatedUser = ObjectIds.WellKnownRole_AuthenticatedUser; private static readonly NodeId s_trustedApplication = ObjectIds.WellKnownRole_TrustedApplication; private readonly ReaderWriterLockSlim m_lock = new(LockRecursionPolicy.NoRecursion); private readonly Dictionary m_roles = []; private readonly Dictionary m_browseNameIndex = new(StringComparer.Ordinal); private uint m_nextDynamicId = 1; private bool m_disposed; /// /// Creates a new role manager pre-populated with the nine well-known /// roles per Part 3 §4.9.2 and the default identity rules mandated by /// Part 18 §4.3. /// public RoleManager() { // Anonymous role: identities = { Anonymous, AuthenticatedUser } AddBuiltInRole(s_anonymous, BrowseNames.WellKnownRole_Anonymous, isReserved: true) .Identities.Add(new IdentityMappingRuleType { CriteriaType = IdentityCriteriaType.Anonymous }); m_roles[s_anonymous].Identities.Add(new IdentityMappingRuleType { CriteriaType = IdentityCriteriaType.AuthenticatedUser }); // AuthenticatedUser role: identities = { AuthenticatedUser } AddBuiltInRole(s_authenticatedUser, BrowseNames.WellKnownRole_AuthenticatedUser, isReserved: true) .Identities.Add(new IdentityMappingRuleType { CriteriaType = IdentityCriteriaType.AuthenticatedUser }); // TrustedApplication role: identities = { TrustedApplication } AddBuiltInRole(s_trustedApplication, BrowseNames.WellKnownRole_TrustedApplication, isReserved: true) .Identities.Add(new IdentityMappingRuleType { CriteriaType = IdentityCriteriaType.TrustedApplication }); // Configurable well-known roles (no default identities). AddBuiltInRole(ObjectIds.WellKnownRole_Observer, BrowseNames.WellKnownRole_Observer, isReserved: false); AddBuiltInRole(ObjectIds.WellKnownRole_Operator, BrowseNames.WellKnownRole_Operator, isReserved: false); AddBuiltInRole(ObjectIds.WellKnownRole_Engineer, BrowseNames.WellKnownRole_Engineer, isReserved: false); AddBuiltInRole(ObjectIds.WellKnownRole_Supervisor, BrowseNames.WellKnownRole_Supervisor, isReserved: false); AddBuiltInRole(ObjectIds.WellKnownRole_ConfigureAdmin, BrowseNames.WellKnownRole_ConfigureAdmin, isReserved: false); AddBuiltInRole(ObjectIds.WellKnownRole_SecurityAdmin, BrowseNames.WellKnownRole_SecurityAdmin, isReserved: false); } /// public event EventHandler? RoleConfigurationChanged; /// public IReadOnlyList RoleIds { get { m_lock.EnterReadLock(); try { return [.. m_roles.Keys]; } finally { m_lock.ExitReadLock(); } } } /// public RoleEntry? GetRole(NodeId roleId) { if (roleId.IsNull) { return null; } m_lock.EnterReadLock(); try { return m_roles.TryGetValue(roleId, out MutableRole? role) ? role.Snapshot() : null; } finally { m_lock.ExitReadLock(); } } /// public ServiceResult AddIdentity(NodeId roleId, IdentityMappingRuleType rule) { ServiceResult validation = IdentityRuleValidator.Validate(rule); if (ServiceResult.IsBad(validation)) { return validation; } m_lock.EnterWriteLock(); try { ServiceResult lookup = TryGetMutableRole(roleId, requireMutable: true, out MutableRole? role); if (ServiceResult.IsBad(lookup)) { return lookup; } if (role!.Identities.Any(r => IdentityRuleValidator.AreEquivalent(r, rule))) { return new ServiceResult(StatusCodes.BadAlreadyExists, new LocalizedText("An equivalent identity rule already exists.")); } role.Identities.Add(Clone(rule)); } finally { m_lock.ExitWriteLock(); } RaiseChanged(roleId, RoleConfigurationChangeKind.IdentityAdded); return ServiceResult.Good; } /// public ServiceResult RemoveIdentity(NodeId roleId, IdentityMappingRuleType rule) { if (rule == null) { return new ServiceResult(StatusCodes.BadInvalidArgument); } m_lock.EnterWriteLock(); try { ServiceResult lookup = TryGetMutableRole(roleId, requireMutable: true, out MutableRole? role); if (ServiceResult.IsBad(lookup)) { return lookup; } int idx = role!.Identities.FindIndex(r => IdentityRuleValidator.AreEquivalent(r, rule)); if (idx < 0) { return new ServiceResult(StatusCodes.BadNotFound); } role.Identities.RemoveAt(idx); } finally { m_lock.ExitWriteLock(); } RaiseChanged(roleId, RoleConfigurationChangeKind.IdentityRemoved); return ServiceResult.Good; } /// public ServiceResult AddApplication(NodeId roleId, string applicationUri) { if (string.IsNullOrEmpty(applicationUri)) { return new ServiceResult(StatusCodes.BadInvalidArgument, new LocalizedText("ApplicationUri must be non-empty.")); } m_lock.EnterWriteLock(); try { ServiceResult lookup = TryGetMutableRole(roleId, requireMutable: true, out MutableRole? role); if (ServiceResult.IsBad(lookup)) { return lookup; } if (role!.Applications.Contains(applicationUri)) { return new ServiceResult(StatusCodes.BadAlreadyExists, new LocalizedText($"ApplicationUri '{applicationUri}' is already assigned to this role.")); } role.Applications.Add(applicationUri); } finally { m_lock.ExitWriteLock(); } RaiseChanged(roleId, RoleConfigurationChangeKind.ApplicationAdded); return ServiceResult.Good; } /// public ServiceResult RemoveApplication(NodeId roleId, string applicationUri) { if (string.IsNullOrEmpty(applicationUri)) { return new ServiceResult(StatusCodes.BadInvalidArgument); } m_lock.EnterWriteLock(); try { ServiceResult lookup = TryGetMutableRole(roleId, requireMutable: true, out MutableRole? role); if (ServiceResult.IsBad(lookup)) { return lookup; } if (!role!.Applications.Remove(applicationUri)) { return new ServiceResult(StatusCodes.BadNotFound); } } finally { m_lock.ExitWriteLock(); } RaiseChanged(roleId, RoleConfigurationChangeKind.ApplicationRemoved); return ServiceResult.Good; } /// public ServiceResult AddEndpoint(NodeId roleId, EndpointType endpoint) { if (endpoint == null || string.IsNullOrEmpty(endpoint.EndpointUrl)) { return new ServiceResult(StatusCodes.BadInvalidArgument, new LocalizedText("Endpoint or its EndpointUrl must be non-empty.")); } m_lock.EnterWriteLock(); try { ServiceResult lookup = TryGetMutableRole(roleId, requireMutable: true, out MutableRole? role); if (ServiceResult.IsBad(lookup)) { return lookup; } if (role!.Endpoints.Any(e => EndpointTypeComparer.RulesEqual(e, endpoint))) { return new ServiceResult(StatusCodes.BadAlreadyExists, new LocalizedText("An equivalent endpoint is already assigned to this role.")); } role.Endpoints.Add(EndpointTypeComparer.Clone(endpoint)); } finally { m_lock.ExitWriteLock(); } RaiseChanged(roleId, RoleConfigurationChangeKind.EndpointAdded); return ServiceResult.Good; } /// public ServiceResult RemoveEndpoint(NodeId roleId, EndpointType endpoint) { if (endpoint == null) { return new ServiceResult(StatusCodes.BadInvalidArgument); } m_lock.EnterWriteLock(); try { ServiceResult lookup = TryGetMutableRole(roleId, requireMutable: true, out MutableRole? role); if (ServiceResult.IsBad(lookup)) { return lookup; } int idx = role!.Endpoints.FindIndex(e => EndpointTypeComparer.RulesEqual(e, endpoint)); if (idx < 0) { return new ServiceResult(StatusCodes.BadNotFound); } role.Endpoints.RemoveAt(idx); } finally { m_lock.ExitWriteLock(); } RaiseChanged(roleId, RoleConfigurationChangeKind.EndpointRemoved); return ServiceResult.Good; } /// public ServiceResult SetApplicationsExclude(NodeId roleId, bool value) { return SetFlag(roleId, value, role => role.ApplicationsExclude, (role, v) => role.ApplicationsExclude = v, RoleConfigurationChangeKind.ApplicationsExcludeChanged); } /// public ServiceResult SetEndpointsExclude(NodeId roleId, bool value) { return SetFlag(roleId, value, role => role.EndpointsExclude, (role, v) => role.EndpointsExclude = v, RoleConfigurationChangeKind.EndpointsExcludeChanged); } /// public ServiceResult SetCustomConfiguration(NodeId roleId, bool value) { return SetFlag(roleId, value, role => role.CustomConfiguration, (role, v) => role.CustomConfiguration = v, RoleConfigurationChangeKind.CustomConfigurationChanged); } private ServiceResult SetFlag( NodeId roleId, bool value, Func getter, Action setter, RoleConfigurationChangeKind kind) { bool changed; m_lock.EnterWriteLock(); try { ServiceResult lookup = TryGetMutableRole(roleId, requireMutable: true, out MutableRole? role); if (ServiceResult.IsBad(lookup)) { return lookup; } changed = getter(role!) != value; setter(role!, value); } finally { m_lock.ExitWriteLock(); } if (changed) { RaiseChanged(roleId, kind); } return ServiceResult.Good; } /// public ServiceResult AddRole( string roleName, string? namespaceUri, NamespaceTable namespaces, ushort defaultNamespaceIndex, out NodeId newRoleId) { newRoleId = NodeId.Null; if (string.IsNullOrEmpty(roleName)) { return new ServiceResult(StatusCodes.BadInvalidArgument, new LocalizedText("RoleName must be non-empty.")); } if (namespaces == null) { throw new ArgumentNullException(nameof(namespaces)); } bool isWellKnown = false; bool useOpcUaNamespace = string.IsNullOrEmpty(namespaceUri) || string.Equals(namespaceUri, Ua.Namespaces.OpcUa, StringComparison.Ordinal); // If naming a well-known role under the OPC UA namespace, reuse // the well-known NodeId per Part 18 §4.2.2. NodeId? candidate = null; ushort namespaceIndex = defaultNamespaceIndex; if (useOpcUaNamespace) { candidate = ResolveWellKnownNodeId(roleName); if (candidate != null) { isWellKnown = true; namespaceIndex = 0; } } if (candidate == null) { if (!useOpcUaNamespace) { int idx = namespaces.GetIndex(namespaceUri!); if (idx < 0) { return new ServiceResult(StatusCodes.BadInvalidArgument, new LocalizedText($"Namespace URI '{namespaceUri}' is not registered.")); } namespaceIndex = (ushort)idx; } else { // The caller asked for the default namespace (either no // URI provided or the bare OPC UA URI) but the role name // is not one of the reserved Part 3 §4.9 names. Allocate // in the manager's dynamic namespace rather than ns=0, // which is reserved for OPC UA core nodes per Part 5. namespaceIndex = defaultNamespaceIndex; } } m_lock.EnterWriteLock(); try { if (m_browseNameIndex.ContainsKey(roleName)) { return new ServiceResult(StatusCodes.BadAlreadyExists, new LocalizedText($"A role with browse name '{roleName}' already exists.")); } NodeId allocated; if (candidate != null) { if (m_roles.ContainsKey(candidate.Value)) { return new ServiceResult(StatusCodes.BadAlreadyExists, new LocalizedText($"Well-known role '{roleName}' is already registered.")); } allocated = candidate.Value; } else { allocated = AllocateDynamicNodeId(namespaceIndex); } // Per §4.2.2: initial values of ApplicationsExclude/EndpointsExclude // shall be TRUE on newly created roles when the properties exist. m_roles[allocated] = new MutableRole(allocated, roleName, namespaceIndex, isReserved: false, isWellKnown: isWellKnown) { ApplicationsExclude = true, EndpointsExclude = true }; m_browseNameIndex[roleName] = allocated; newRoleId = allocated; } finally { m_lock.ExitWriteLock(); } RaiseChanged(newRoleId, RoleConfigurationChangeKind.RoleAdded); return ServiceResult.Good; } /// public ServiceResult RemoveRole(NodeId roleId) { if (roleId.IsNull) { return new ServiceResult(StatusCodes.BadInvalidArgument); } m_lock.EnterWriteLock(); try { if (!m_roles.TryGetValue(roleId, out MutableRole? role)) { return new ServiceResult(StatusCodes.BadNodeIdUnknown); } if (role.IsReserved) { return new ServiceResult(StatusCodes.BadRequestNotAllowed, new LocalizedText($"Role '{role.BrowseName}' cannot be removed (Part 18 §4.3).")); } m_roles.Remove(roleId); if (role.BrowseName != null) { m_browseNameIndex.Remove(role.BrowseName); } } finally { m_lock.ExitWriteLock(); } RaiseChanged(roleId, RoleConfigurationChangeKind.RoleRemoved); return ServiceResult.Good; } /// public IList ResolveGrantedRoles( IUserIdentity identity, Certificate? clientCertificate, EndpointDescription? endpoint) { if (identity == null) { throw new ArgumentNullException(nameof(identity)); } IReadOnlyList clientApplicationUris = clientCertificate != null ? X509Utils.GetApplicationUrisFromCertificate(clientCertificate) : []; string clientThumbprint = clientCertificate != null ? IdentityRuleValidator.NormaliseThumbprint(clientCertificate.Thumbprint) : string.Empty; string clientSubject = clientCertificate != null ? IdentityRuleValidator.NormaliseX509Subject(clientCertificate.Subject) : string.Empty; string endpointUrl = endpoint?.EndpointUrl ?? string.Empty; bool isSignedChannel = endpoint != null && endpoint.SecurityMode is MessageSecurityMode.Sign or MessageSecurityMode.SignAndEncrypt; bool isEncryptedChannel = endpoint?.SecurityMode == MessageSecurityMode.SignAndEncrypt; var candidate = new EndpointType { EndpointUrl = endpointUrl, SecurityMode = endpoint?.SecurityMode ?? MessageSecurityMode.Invalid, SecurityPolicyUri = endpoint?.SecurityPolicyUri, TransportProfileUri = endpoint?.TransportProfileUri }; var granted = new List(); m_lock.EnterReadLock(); try { foreach (MutableRole role in m_roles.Values) { if (RoleMatches(role, identity, clientCertificate, clientApplicationUris, clientThumbprint, clientSubject, candidate, isSignedChannel, isEncryptedChannel)) { granted.Add(role.RoleId); } } } finally { m_lock.ExitReadLock(); } return granted; } /// public void Dispose() { if (m_disposed) { return; } m_disposed = true; m_lock.Dispose(); } private static bool RoleMatches( MutableRole role, IUserIdentity identity, Certificate? clientCertificate, IReadOnlyList clientApplicationUris, string clientThumbprint, string clientSubject, EndpointType candidateEndpoint, bool isSignedChannel, bool isEncryptedChannel) { // Apply Application filter (Part 18 §4.4.1). if (role.Applications.Count > 0) { // "If Applications has entries in the array, the Role shall only // be granted if the Session uses a signed or signed and encrypted // communication channel." — §4.4.1 if (!isSignedChannel || clientApplicationUris.Count == 0) { if (!role.ApplicationsExclude) { return false; } } // The certificate may advertise multiple ApplicationUris (Subject // Alternative Names); any match against the role's Applications list // counts as inclusion. bool inList = clientApplicationUris.Any(role.Applications.Contains); if (role.ApplicationsExclude ? inList : !inList) { return false; } } // Apply Endpoint filter (Part 18 §4.4.1, §4.4.2). if (role.Endpoints.Count > 0) { bool matchesEndpoint = role.Endpoints.Any( e => EndpointTypeComparer.Matches(e, candidateEndpoint)); if (role.EndpointsExclude ? matchesEndpoint : !matchesEndpoint) { return false; } } // Evaluate identity rules. if (role.Identities.Count == 0) { // Per §4.4.1: "If this Property is an empty array and // CustomConfiguration is not TRUE, then the Role cannot be // granted to any Session." return role.CustomConfiguration; } foreach (IdentityMappingRuleType rule in role.Identities) { if (IdentityRuleMatches(rule, identity, clientCertificate, clientApplicationUris, clientThumbprint, clientSubject, isSignedChannel, isEncryptedChannel)) { return true; } } return false; } private static bool IdentityRuleMatches( IdentityMappingRuleType rule, IUserIdentity identity, Certificate? clientCertificate, IReadOnlyList clientApplicationUris, string clientThumbprint, string clientSubject, bool isSignedChannel, bool isEncryptedChannel) { UserTokenType tokenType = identity.TokenType; string criteria = rule.Criteria ?? string.Empty; var claims = identity as IIdentityClaims; return rule.CriteriaType switch { IdentityCriteriaType.Anonymous => tokenType == UserTokenType.Anonymous, IdentityCriteriaType.AuthenticatedUser => tokenType != UserTokenType.Anonymous, IdentityCriteriaType.UserName => tokenType == UserTokenType.UserName && string.Equals(identity.DisplayName, criteria, StringComparison.Ordinal), IdentityCriteriaType.Thumbprint => clientCertificate != null && string.Equals(clientThumbprint, criteria, StringComparison.Ordinal), IdentityCriteriaType.X509Subject => clientCertificate != null && !string.IsNullOrEmpty(clientSubject) && string.Equals(clientSubject, criteria, StringComparison.Ordinal), IdentityCriteriaType.Role => claims != null && MatchClaimRole(claims, criteria), // The certificate may advertise multiple ApplicationUris; any one // matching the rule's criteria is sufficient. IdentityCriteriaType.Application => clientCertificate != null && isSignedChannel && clientApplicationUris.Any(uri => string.Equals(uri, criteria, StringComparison.Ordinal)), IdentityCriteriaType.TrustedApplication => clientCertificate != null && isSignedChannel, IdentityCriteriaType.GroupId => claims != null && claims.Groups.Contains(criteria, StringComparer.Ordinal), _ => false }; } /// /// Matches a Part 18 §4.4.4 Role criterion against roles asserted in /// an access token, including the optional iss/roleName prefix. /// private static bool MatchClaimRole(IIdentityClaims claims, string criteria) { if (string.IsNullOrEmpty(criteria)) { return false; } int separator = criteria.LastIndexOf('/'); if (separator < 0) { return claims.Roles.Contains(criteria, StringComparer.Ordinal); } string issuer = criteria[..separator]; string roleName = criteria[(separator + 1)..]; return !string.IsNullOrEmpty(issuer) && !string.IsNullOrEmpty(roleName) && string.Equals(claims.Issuer, issuer, StringComparison.Ordinal) && claims.Roles.Contains(roleName, StringComparer.Ordinal); } private MutableRole AddBuiltInRole(NodeId roleId, string browseName, bool isReserved) { var role = new MutableRole(roleId, browseName, roleId.NamespaceIndex, isReserved: isReserved, isWellKnown: true); m_roles[roleId] = role; m_browseNameIndex[browseName] = roleId; return role; } private NodeId AllocateDynamicNodeId(ushort namespaceIndex) { return new NodeId(m_nextDynamicId++, namespaceIndex); } private ServiceResult TryGetMutableRole(NodeId roleId, bool requireMutable, out MutableRole? role) { role = null; if (roleId.IsNull) { return new ServiceResult(StatusCodes.BadInvalidArgument); } if (!m_roles.TryGetValue(roleId, out role)) { return new ServiceResult(StatusCodes.BadNodeIdUnknown); } if (requireMutable && role.IsReserved) { return new ServiceResult(StatusCodes.BadRequestNotAllowed, new LocalizedText( $"Role '{role.BrowseName}' is reserved and cannot be modified (Part 18 §4.3).")); } return ServiceResult.Good; } private void RaiseChanged(NodeId roleId, RoleConfigurationChangeKind kind) { RoleConfigurationChanged?.Invoke(this, new RoleConfigurationChangedEventArgs(roleId, kind)); } private static IdentityMappingRuleType Clone(IdentityMappingRuleType rule) { return new IdentityMappingRuleType { CriteriaType = rule.CriteriaType, Criteria = rule.Criteria }; } private static NodeId? ResolveWellKnownNodeId(string roleName) { return roleName switch { BrowseNames.WellKnownRole_Anonymous => ObjectIds.WellKnownRole_Anonymous, BrowseNames.WellKnownRole_AuthenticatedUser => ObjectIds.WellKnownRole_AuthenticatedUser, BrowseNames.WellKnownRole_TrustedApplication => ObjectIds.WellKnownRole_TrustedApplication, BrowseNames.WellKnownRole_Observer => ObjectIds.WellKnownRole_Observer, BrowseNames.WellKnownRole_Operator => ObjectIds.WellKnownRole_Operator, BrowseNames.WellKnownRole_Engineer => ObjectIds.WellKnownRole_Engineer, BrowseNames.WellKnownRole_Supervisor => ObjectIds.WellKnownRole_Supervisor, BrowseNames.WellKnownRole_ConfigureAdmin => ObjectIds.WellKnownRole_ConfigureAdmin, BrowseNames.WellKnownRole_SecurityAdmin => ObjectIds.WellKnownRole_SecurityAdmin, _ => null }; } /// /// Mutable per-role state held inside the manager. Not exposed /// outside this class; callers see immutable /// snapshots. /// private sealed class MutableRole { public MutableRole(NodeId roleId, string browseName, ushort namespaceIndex, bool isReserved, bool isWellKnown) { RoleId = roleId; BrowseName = browseName; NamespaceIndex = namespaceIndex; IsReserved = isReserved; IsWellKnown = isWellKnown; Identities = []; Applications = []; Endpoints = []; } public NodeId RoleId { get; } public string BrowseName { get; } public ushort NamespaceIndex { get; } public bool IsReserved { get; } public bool IsWellKnown { get; } public List Identities { get; } public List Applications { get; } public List Endpoints { get; } public bool ApplicationsExclude { get; set; } public bool EndpointsExclude { get; set; } public bool CustomConfiguration { get; set; } public RoleEntry Snapshot() { return new RoleEntry( RoleId, BrowseName, NamespaceIndex, IsReserved, IsWellKnown, [.. Identities.Select(Clone)], [.. Applications], ApplicationsExclude, [.. Endpoints.Select(EndpointTypeComparer.Clone)], EndpointsExclude, CustomConfiguration); } } } }