Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs
T
Joseph Doherty f692f6afe4 feat(security): enforce node ACLs on write, alarms and HistoryRead (#520 stage 5)
`Security:Acl:Enabled` (default false) makes scoped `NodeAcl` grants decide
data-plane authorization at the three seams that have one, and gives HistoryRead
a check it never had. Read, Browse and Subscribe stay ungated — stage 6.

The ACL decision REPLACES the server-wide role check rather than chaining with
it. Chained, a grant would have to confer the role as well as pass the scope
check, and only Cluster-scoped grants confer a role — so every narrow grant
would have stayed inert and the flag would have looked inert too. The
load-bearing test is an ALLOW: a session carrying no roles at all writing
successfully on the strength of its grant. A deny-only test passes against a
build where the ACL is never consulted, because the role gate denies too.

Found en route: the principal never reached the session. The authenticator
passed it as OpcUaUserAuthResult.DisplayName, but RoleCarryingUserIdentity
derives DisplayName from the presented token — for a key-bearing client that is
the static username `apikey`, identical fleet-wide. So the trie's matching key
was dropped at the identity boundary, and the audit ClientUserId on a rejected
write recorded `apikey` for every consuming system. Principal and ApiKeyId are
now carried explicitly.

Beyond the stage as sketched:

- Revocation reaches an OPEN session. Every decision re-checks the
  authenticating key against the current snapshot; without it a key revoked by a
  deployment kept its authority until the client chose to reconnect, which for a
  machine-to-machine consumer can be indefinitely.
- UserAuthorizationState is built per CALL, not per session. The per-session
  shape exists so an LDAP session can re-resolve membership that changed out of
  band; nothing can change out of band under API keys, so the generation stamp is
  the whole invalidation signal and a per-call record picks it up for free.
  IsStale/NeedsRefresh therefore cannot fire here and are documented as vestigial.
- The trie is built inside DataPlaneAuthorityState.Apply, under the same
  generation as the snapshot, rather than by a second wiring at the publish
  actor. An uninstalled trie denies everything — indistinguishable from working
  enforcement. Deleting the install turns 13 of the 22 new tests red.
- The deploy log reports what the BUILD dropped instead of re-deriving it. The
  old count agreed with the builder on one of its four drop reasons; a rule with
  a blank ScopeId, a retired scope kind, or a path not starting at a realm
  vanished with the log reading zero.

Both IApiKeyStore implementations widened to the full grant set. The narrowing
was redundant — ApiKeyInterimRoleMapper applies the same filter itself — and
became a trap once scope was enforced. Pinned by asserting the roles are
unchanged, with the two grants carrying different flags so it cannot pass by
accident.

Falsified by mutation: enforcement never engaging, the write seam ignoring the
ACL, the realm dropped from the scope, the revocation check removed, and the
trie never installed each turn the intended tests red and no others.

Full solution suite green.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-08-01 05:00:28 -04:00

576 lines
28 KiB
C#

using System.Text;
using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Configuration;
using Opc.Ua.Server;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// <summary>
/// Transport-security profile served by the OPC UA endpoint. F13b ships the three baseline
/// profiles defined by docs/security.md; the remaining Aes128/Aes256 variants can be added
/// later by extending <see cref="OpcUaSecurityProfile.PolicyUri"/>+<see cref="OpcUaSecurityProfile.Mode"/>
/// — the wiring in <c>BuildConfigurationAsync</c> is profile-agnostic.
/// </summary>
public enum OpcUaSecurityProfile
{
/// <summary>No signing or encryption. Dev / isolated networks only.</summary>
None,
/// <summary>Basic256Sha256 + Sign. Messages signed, payload visible on the wire.</summary>
Basic256Sha256Sign,
/// <summary>Basic256Sha256 + SignAndEncrypt. Full transport protection.</summary>
Basic256Sha256SignAndEncrypt,
}
/// <summary>Configuration options for OPC UA application hosting.</summary>
public sealed class OpcUaApplicationHostOptions
{
/// <summary>Gets or sets the application name (default "OtOpcUa").</summary>
public string ApplicationName { get; set; } = "OtOpcUa";
/// <summary>Gets or sets the application URI (default "urn:OtOpcUa").</summary>
public string ApplicationUri { get; set; } = "urn:OtOpcUa";
/// <summary>Gets or sets the product URI (default "https://zb.com/otopcua").</summary>
public string ProductUri { get; set; } = "https://zb.com/otopcua";
/// <summary>Listening port for the binary endpoint (default 4840).</summary>
public int OpcUaPort { get; set; } = 4840;
/// <summary>Hostname or IP advertised in endpoint descriptions.</summary>
public string PublicHostname { get; set; } = "0.0.0.0";
/// <summary>Application config XML path; when set, loaded instead of building from defaults.</summary>
public string? ApplicationConfigPath { get; set; }
/// <summary>
/// Root of the application's PKI hierarchy. Sub-stores (<c>own</c>, <c>issuer</c>,
/// <c>trusted</c>, <c>rejected</c>) are created under this path on first start. Defaults
/// to "pki" (relative to the host's working directory) to keep dev flows identical to v1.
/// </summary>
public string PkiStoreRoot { get; set; } = "pki";
/// <summary>
/// Whether anonymous sessions may connect at all (#520). When <see langword="false"/> the
/// Anonymous token policy is not advertised AND anonymous activation is rejected, so a client
/// must present an API key.
/// </summary>
/// <remarks>
/// <para>
/// <b>Defaults to <see langword="true"/> deliberately, which preserves today's behaviour.</b>
/// Flipping the default would silently deny every client that has no key the moment this
/// code deploys — including ScadaBridge's Data Connection Layer, which consumes this address
/// space from another repo. Turning it off is the deployment-time decision that makes keys
/// mandatory, and it belongs in the rollout once keys have been issued.
/// </para>
/// <para>
/// Note this controls <i>admission</i>, not authority. An admitted anonymous session is a
/// normal principal (<c>anonymous</c>) whose grants come from <c>NodeAcl</c> rows naming it,
/// exactly like a key's principal — there is no anonymous bypass branch in the evaluator.
/// Until per-node enforcement lands, an admitted session of ANY kind can still read the whole
/// address space, because reads carry no authorization check yet.
/// </para>
/// </remarks>
public bool AllowAnonymous { get; set; } = true;
/// <summary>
/// Transport-security profiles exposed by the server. The SDK publishes one endpoint
/// descriptor per profile and clients choose at session open. Default = all three
/// baseline profiles (None + Basic256Sha256 in both modes); production deployments
/// typically drop None.
/// </summary>
public IList<OpcUaSecurityProfile> EnabledSecurityProfiles { get; set; } = new List<OpcUaSecurityProfile>
{
OpcUaSecurityProfile.None,
OpcUaSecurityProfile.Basic256Sha256Sign,
OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt,
};
/// <summary>
/// When true, unknown client certificates are auto-added to the trusted store on first
/// connection. Convenient for dev; should be false in production (operators promote via
/// the Admin UI). Has no effect on <c>None</c> endpoints, which don't exchange certs.
/// </summary>
public bool AutoAcceptUntrustedClientCertificates { get; set; }
/// <summary>
/// Peer server URIs published in <c>Server.ServerArray</c> after start, in addition to
/// the local <see cref="ApplicationUri"/>. Empty by default — set this on warm-redundancy
/// deployments so OPC UA clients can discover the partner endpoint via the standard
/// Server.ServerArray property (NodeId i=2254). Order does not matter; the local URI
/// is always element 0.
/// </summary>
public IList<string> PeerApplicationUris { get; set; } = new List<string>();
}
/// <summary>
/// Thin facade over the OPC Foundation .NET Standard SDK's application bootstrap.
/// Owns the <see cref="ApplicationInstance"/> + <see cref="ApplicationConfiguration"/> lifetime,
/// builds the <see cref="ApplicationConfiguration"/> programmatically (security profiles + user
/// token policies + PKI stores), starts a <see cref="StandardServer"/>, attaches the
/// <c>ImpersonateUser</c> hook for UserName-token authentication via
/// <see cref="IOpcUaUserAuthenticator"/>, and publishes the redundancy peer URIs through
/// <c>Server.ServerArray</c>. See <c>docs/OpcUaServer.md</c> and <c>docs/security.md</c>.
/// </summary>
public sealed class OpcUaApplicationHost : IAsyncDisposable
{
private readonly OpcUaApplicationHostOptions _options;
private readonly IOpcUaUserAuthenticator _userAuthenticator;
private readonly ILogger<OpcUaApplicationHost> _logger;
private ApplicationInstance? _application;
private StandardServer? _server;
private ImpersonateEventHandler? _impersonateHandler;
/// <summary>Initializes a new instance of the OPC UA application host.</summary>
/// <param name="options">The host configuration options.</param>
/// <param name="logger">The logger for diagnostic output.</param>
/// <param name="userAuthenticator">An optional user authenticator for UserName tokens; uses null implementation if not provided.</param>
public OpcUaApplicationHost(
OpcUaApplicationHostOptions options,
ILogger<OpcUaApplicationHost> logger,
IOpcUaUserAuthenticator? userAuthenticator = null)
{
_options = options;
_logger = logger;
_userAuthenticator = userAuthenticator ?? NullOpcUaUserAuthenticator.Instance;
}
/// <summary>Gets the OPC Foundation application instance, or null if not yet started.</summary>
public ApplicationInstance? ApplicationInstance => _application;
/// <summary>Gets the OPC UA server instance, or null if not yet started.</summary>
public StandardServer? Server => _server;
/// <summary>Starts the OPC UA application and server.</summary>
/// <param name="server">The standard server instance to start.</param>
/// <param name="cancellationToken">A cancellation token for the operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task StartAsync(StandardServer server, CancellationToken cancellationToken)
{
_server = server;
// 1.5.378 requires an ITelemetryContext on the ApplicationInstance ctor (the parameterless ctor
// is obsolete). DefaultTelemetry.Create wires the SDK's internal logging; an empty builder keeps
// the SDK's trace off our ILogger (the host keeps its own _logger) — sufficient for the bootstrap.
_application = new ApplicationInstance(DefaultTelemetry.Create(_ => { }))
{
ApplicationName = _options.ApplicationName,
ApplicationType = ApplicationType.Server,
ConfigSectionName = "OtOpcUa",
};
_ = await BuildConfigurationAsync(cancellationToken);
await EnsureApplicationCertificateAsync(cancellationToken).ConfigureAwait(false);
await _application.StartAsync(server).ConfigureAwait(false);
AttachUserAuthenticator();
PopulateServerArray();
_logger.LogInformation("OPC UA server started on opc.tcp://{Host}:{Port}",
_options.PublicHostname, _options.OpcUaPort);
}
/// <summary>
/// Subscribes to <see cref="SessionManager.ImpersonateUser"/> after the SDK has its
/// <c>SessionManager</c> ready (only after <c>_application.Start</c>). Anonymous tokens
/// pass through; UserName tokens hit <see cref="IOpcUaUserAuthenticator"/> and, on
/// success, attach a <see cref="UserIdentity"/> with the mapped role-set to the session
/// so downstream ACL checks can read it via <c>OperationContext.UserIdentity</c>.
///
/// The SDK calls <c>ImpersonateUser</c> synchronously off the session-activation
/// thread, so the authenticator's async work is run via <c>GetAwaiter().GetResult()</c>.
/// LDAP binds typically complete in &lt;100 ms; if a backing store ever gets that slow
/// it should not block the OPC UA stack — callers must enforce their own timeouts inside
/// <see cref="IOpcUaUserAuthenticator.AuthenticateUserNameAsync"/>.
/// </summary>
private void AttachUserAuthenticator()
{
var sessionManager = _server?.CurrentInstance?.SessionManager;
if (sessionManager is null)
{
_logger.LogWarning("OpcUaApplicationHost: SessionManager unavailable after Start; UserName auth disabled");
return;
}
_impersonateHandler = OnImpersonateUser;
sessionManager.ImpersonateUser += _impersonateHandler;
}
/// <summary>
/// Publishes <see cref="OpcUaApplicationHostOptions.PeerApplicationUris"/> via the OPC UA
/// standard <c>Server.ServerArray</c> property (NodeId i=2254) so warm-redundancy clients
/// can discover the partner endpoint.
///
/// The wire-served value of <c>Server.ServerArray</c> comes from
/// <see cref="IServerInternal.ServerUris"/> (an <see cref="Opc.Ua.StringTable"/>) via the
/// SDK's <c>OnReadServerArray</c> callback — writes to
/// <c>ServerObject.ServerArray.Value</c> are NOT what clients read. The SDK auto-populates
/// slot 0 with the local <c>ApplicationUri</c> on <c>ApplicationInstance.Start</c>; we
/// append the configured peers at slots 1, 2, … here.
///
/// The address-space property is also mirrored for in-process readers (the unit-test
/// observation seam) and as a defensive belt-and-braces measure.
/// </summary>
private void PopulateServerArray()
{
var internalData = _server?.CurrentInstance;
if (internalData is null) return;
// Wire path: append peers to IServerInternal.ServerUris — this is what
// OnReadServerArray serves to remote clients reading VariableIds.Server_ServerArray.
var serverUris = internalData.ServerUris;
var existing = new HashSet<string>(StringComparer.Ordinal);
for (uint i = 0; i < (uint)serverUris.Count; i++)
{
var existingUri = serverUris.GetString(i);
if (existingUri is not null) existing.Add(existingUri);
}
foreach (var peer in _options.PeerApplicationUris)
{
if (string.IsNullOrWhiteSpace(peer)) continue;
if (existing.Contains(peer)) continue;
serverUris.Append(peer);
existing.Add(peer);
}
// In-process mirror: ServerObject.ServerArray.Value is consulted by some tests and
// tooling that read the SDK's address-space model directly rather than going through
// a session. Harmless on the wire (the SDK ignores it) but useful in-VM.
var serverObject = internalData.ServerObject;
if (serverObject is not null)
{
var uris = new List<string> { _options.ApplicationUri };
foreach (var peer in _options.PeerApplicationUris)
{
if (!string.IsNullOrWhiteSpace(peer) && !uris.Contains(peer))
uris.Add(peer);
}
serverObject.ServerArray.Value = uris.ToArray();
}
}
private void OnImpersonateUser(ISession session, ImpersonateEventArgs args) =>
HandleImpersonation(_userAuthenticator, args, _logger, _options.AllowAnonymous);
/// <summary>
/// Pure(-ish) impersonation handler: extracted so unit tests can drive it without booting
/// the full SDK. Side-effects are confined to mutating <see cref="ImpersonateEventArgs"/>
/// and logging.
/// </summary>
/// <remarks>
/// <b>SDK threading contract (verified against OPCFoundation.NetStandard.Opc.Ua.Server 1.5.378).</b>
/// The SDK raises this handler SYNCHRONOUSLY — it is a plain <c>void</c> delegate, and there is no
/// async impersonation callback in this SDK version. Worse, <c>SessionManager.ActivateSessionAsync</c>
/// invokes it while holding a single <c>SessionManager</c>-wide event lock (<c>m_eventLock</c>), so a
/// handler that blocks does not merely stall one SDK request thread — it SERIALIZES every session
/// activation server-wide (Anonymous and X509 included, since even those paths must take the lock to
/// raise the handler). Consequently the <see cref="IOpcUaUserAuthenticator"/> block-bridge below
/// (<c>GetAwaiter().GetResult()</c> under <see cref="CancellationToken.None"/>) is irreducible: the
/// callback must complete inline. Because the SDK gives no timeout, the hard wall-clock bound + bounded
/// concurrency + directory-outage circuit that keep a slow/hung directory from wedging the SDK live
/// inside the authenticator — NOT here. (#520 replaced the LDAP authenticator with
/// <c>ApiKeyOpcUaUserAuthenticator</c>, whose verification does no network I/O at all, so the
/// bound/concurrency/circuit machinery that used to live there is gone rather than relocated.)
/// </remarks>
/// <param name="authenticator">The user authenticator to validate credentials.</param>
/// <param name="args">The impersonation event arguments to process.</param>
/// <param name="logger">The logger for diagnostic output.</param>
internal static void HandleImpersonation(
IOpcUaUserAuthenticator authenticator,
ImpersonateEventArgs args,
ILogger logger,
bool allowAnonymous = true)
{
if (args.NewIdentity is AnonymousIdentityToken anonymousToken)
{
HandleAnonymousImpersonation(authenticator, args, logger, allowAnonymous, anonymousToken);
return;
}
if (args.NewIdentity is not UserNameIdentityToken token)
{
// X509 tokens — let the SDK's default validation stand.
return;
}
string password;
try
{
// 1.5.378 exposes DecryptedPassword as raw bytes (was string); UserName token passwords
// are UTF-8 on the wire.
var decryptedBytes = token.DecryptedPassword;
password = decryptedBytes is null ? string.Empty : Encoding.UTF8.GetString(decryptedBytes);
}
catch (Exception ex)
{
logger.LogWarning(ex, "OpcUaApplicationHost: failed to decrypt UserName token");
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
"UserName token decryption failed");
return;
}
OpcUaUserAuthResult result;
try
{
result = authenticator
.AuthenticateUserNameAsync(token.UserName ?? string.Empty, password, CancellationToken.None)
.GetAwaiter().GetResult();
}
catch (Exception ex)
{
logger.LogError(ex, "OpcUaApplicationHost: UserName authenticator threw for {User}", token.UserName);
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
"Authentication failed");
return;
}
if (!result.Success)
{
logger.LogInformation("OpcUaApplicationHost: UserName auth denied for {User}: {Error}",
token.UserName, result.Error);
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
result.Error ?? "Invalid credentials");
return;
}
args.Identity = new RoleCarryingUserIdentity(token, result.Roles, result.Principal, result.ApiKeyId);
logger.LogInformation("OpcUaApplicationHost: UserName auth granted for {User} as {Principal} ({Roles})",
token.UserName, result.Principal, string.Join(",", result.Roles));
}
/// <summary>
/// Admits or rejects an anonymous session, and — when admitted — attaches the roles the
/// <c>anonymous</c> principal has been granted (#520).
/// </summary>
/// <remarks>
/// Before #520 this path simply returned, leaving the SDK's default identity in place: anonymous
/// always connected and carried no roles. Two things change. Anonymous can now be refused
/// outright, and when admitted it is a normal principal whose grants come from ACL rows, so it
/// can be given read or write like any key. The identity attached is the same
/// <see cref="RoleCarryingUserIdentity"/> the UserName path uses, so every downstream gate reads
/// it identically and there is no anonymous-shaped branch in any of them.
/// </remarks>
private static void HandleAnonymousImpersonation(
IOpcUaUserAuthenticator authenticator,
ImpersonateEventArgs args,
ILogger logger,
bool allowAnonymous,
AnonymousIdentityToken token)
{
if (!allowAnonymous)
{
logger.LogInformation(
"OpcUaApplicationHost: anonymous session rejected (OpcUa:AllowAnonymous is false)");
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
"Anonymous access is disabled on this server.");
return;
}
OpcUaUserAuthResult result;
try
{
// Same synchronous bridge, and for the same irreducible reason, as the UserName path above.
result = authenticator.AuthenticateAnonymousAsync(CancellationToken.None)
.GetAwaiter().GetResult();
}
catch (Exception ex)
{
// Fail closed. An anonymous session whose grants could not be resolved must not fall through
// to the SDK default identity, because that would silently ADMIT it — the pre-#520
// behaviour — at exactly the moment we could not determine what it is allowed to do.
logger.LogError(ex, "OpcUaApplicationHost: anonymous authenticator threw");
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
"Anonymous access is unavailable.");
return;
}
if (!result.Success)
{
logger.LogInformation("OpcUaApplicationHost: anonymous session denied: {Error}", result.Error);
args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
result.Error ?? "Anonymous access is denied.");
return;
}
args.Identity = new RoleCarryingUserIdentity(token, result.Roles, result.Principal, apiKeyId: null);
logger.LogInformation("OpcUaApplicationHost: anonymous session admitted as {Principal} ({Roles})",
OpcUaPrincipals.Anonymous,
result.Roles.Count == 0 ? "no roles" : string.Join(",", result.Roles));
}
/// <summary>
/// Guarantees the application instance certificate exists in <c>{PkiStoreRoot}/own</c>.
/// The SDK auto-creates a self-signed certificate the first time this is called on a fresh
/// PKI tree; subsequent boots reuse the existing cert. Replaces v1's manual "you must
/// pre-create the PKI directory tree" friction. Partial slice of follow-up F13 — the
/// remaining endpoint-security, user-token validator, and observability wiring stays in
/// the follow-up queue.
/// </summary>
private async Task EnsureApplicationCertificateAsync(CancellationToken cancellationToken)
{
// silent: false → SDK logs cert creation events through its own trace plumbing.
// minimumKeySize/lifetimeInMonths: 0 → use SDK defaults (2048-bit, 12-month lifetime).
var ok = await _application!.CheckApplicationInstanceCertificatesAsync(
false, null, cancellationToken).ConfigureAwait(false);
if (!ok)
{
throw new InvalidOperationException(
$"OPC UA application certificate validation failed for {_options.ApplicationName}. " +
$"Cert store root: {Path.GetFullPath(_options.PkiStoreRoot)}");
}
}
private async Task<ApplicationConfiguration> BuildConfigurationAsync(CancellationToken ct)
{
if (!string.IsNullOrWhiteSpace(_options.ApplicationConfigPath))
{
return await _application!.LoadApplicationConfigurationAsync(_options.ApplicationConfigPath, true, ct);
}
var serverConfig = new ServerConfiguration
{
BaseAddresses = { $"opc.tcp://{_options.PublicHostname}:{_options.OpcUaPort}/OtOpcUa" },
MinRequestThreadCount = 5,
MaxRequestThreadCount = 100,
MaxQueuedRequestCount = 200,
};
foreach (var policy in BuildSecurityPolicies(_options.EnabledSecurityProfiles))
{
serverConfig.SecurityPolicies.Add(policy);
}
foreach (var token in BuildUserTokenPolicies(_options.AllowAnonymous))
{
serverConfig.UserTokenPolicies.Add(token);
}
var config = new ApplicationConfiguration
{
ApplicationName = _options.ApplicationName,
ApplicationUri = _options.ApplicationUri,
ProductUri = _options.ProductUri,
ApplicationType = ApplicationType.Server,
ServerConfiguration = serverConfig,
SecurityConfiguration = new SecurityConfiguration
{
ApplicationCertificate = new CertificateIdentifier
{
StoreType = "Directory",
StorePath = Path.Combine(_options.PkiStoreRoot, "own"),
SubjectName = $"CN={_options.ApplicationName}",
},
TrustedIssuerCertificates = new CertificateTrustList { StoreType = "Directory", StorePath = Path.Combine(_options.PkiStoreRoot, "issuer") },
TrustedPeerCertificates = new CertificateTrustList { StoreType = "Directory", StorePath = Path.Combine(_options.PkiStoreRoot, "trusted") },
RejectedCertificateStore = new CertificateTrustList { StoreType = "Directory", StorePath = Path.Combine(_options.PkiStoreRoot, "rejected") },
AutoAcceptUntrustedCertificates = _options.AutoAcceptUntrustedClientCertificates,
},
TransportQuotas = new TransportQuotas(),
ClientConfiguration = new ClientConfiguration(),
TraceConfiguration = new TraceConfiguration(),
};
await config.ValidateAsync(ApplicationType.Server, ct).ConfigureAwait(false);
_application!.ApplicationConfiguration = config;
return config;
}
/// <summary>
/// Maps each configured <see cref="OpcUaSecurityProfile"/> to a SDK
/// <see cref="ServerSecurityPolicy"/>. Duplicate profiles are silently de-duped because
/// the SDK rejects duplicate (policy,mode) pairs at <c>Validate</c> time. Empty input
/// falls back to a single None entry so the server doesn't refuse to start with no
/// listening endpoints — the misconfiguration is logged and very visible.
/// </summary>
/// <param name="profiles">The security profiles to build policies for.</param>
/// <returns>The SDK security policies for the requested profiles.</returns>
internal static IEnumerable<ServerSecurityPolicy> BuildSecurityPolicies(IEnumerable<OpcUaSecurityProfile> profiles)
{
var seen = new HashSet<OpcUaSecurityProfile>();
var any = false;
foreach (var profile in profiles)
{
if (!seen.Add(profile)) continue;
any = true;
yield return profile switch
{
OpcUaSecurityProfile.None => new ServerSecurityPolicy
{
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = SecurityPolicies.None,
},
OpcUaSecurityProfile.Basic256Sha256Sign => new ServerSecurityPolicy
{
SecurityMode = MessageSecurityMode.Sign,
SecurityPolicyUri = SecurityPolicies.Basic256Sha256,
},
OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt => new ServerSecurityPolicy
{
SecurityMode = MessageSecurityMode.SignAndEncrypt,
SecurityPolicyUri = SecurityPolicies.Basic256Sha256,
},
_ => throw new InvalidOperationException($"Unknown OpcUaSecurityProfile: {profile}"),
};
}
if (!any)
{
yield return new ServerSecurityPolicy
{
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = SecurityPolicies.None,
};
}
}
/// <summary>
/// Anonymous + UserName token policies. UserName tokens are always SDK-encrypted with
/// the server certificate (see docs/security.md "UserName token encryption") so the
/// policy works on None endpoints too. The real LDAP-bound validator is wired through
/// <see cref="IOpcUaUserAuthenticator"/> on <c>StandardServer.SessionManager.ImpersonateUser</c>
/// (see <see cref="AttachUserAuthenticator"/> / <see cref="HandleImpersonation"/>); the Host
/// project supplies the LDAP adapter, and <see cref="NullOpcUaUserAuthenticator"/> denies all
/// UserName logins when none is registered.
/// </summary>
/// <returns>The Anonymous and UserName token policies.</returns>
internal static IEnumerable<UserTokenPolicy> BuildUserTokenPolicies(bool allowAnonymous = true)
{
// Withholding the policy is what makes the refusal visible to a client at DISCOVERY rather than
// only at activation: it simply is not offered an anonymous option. HandleImpersonation still
// rejects anonymous activation independently, because a client may attempt a token type the
// endpoint never advertised.
if (allowAnonymous)
{
yield return new UserTokenPolicy(UserTokenType.Anonymous)
{
PolicyId = "anonymous",
SecurityPolicyUri = SecurityPolicies.None,
};
}
yield return new UserTokenPolicy(UserTokenType.UserName)
{
PolicyId = "username_basic256sha256",
SecurityPolicyUri = SecurityPolicies.Basic256Sha256,
};
}
/// <summary>Disposes the application host and cleans up resources.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
if (_impersonateHandler is not null && _server?.CurrentInstance?.SessionManager is { } sessionManager)
{
try { sessionManager.ImpersonateUser -= _impersonateHandler; }
catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: detaching ImpersonateUser threw"); }
}
_impersonateHandler = null;
if (_application is not null)
{
// 1.5.378: ApplicationInstance.Stop() → StopAsync().
try { await _application.StopAsync().ConfigureAwait(false); }
catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: Stop threw on dispose"); }
}
}
}