diff --git a/archreview/plans/R2-08-ldap-historyread-async-plan.md.tasks.json b/archreview/plans/R2-08-ldap-historyread-async-plan.md.tasks.json index 824e864e..9f18d8f3 100644 --- a/archreview/plans/R2-08-ldap-historyread-async-plan.md.tasks.json +++ b/archreview/plans/R2-08-ldap-historyread-async-plan.md.tasks.json @@ -6,7 +6,7 @@ { "id": "R2-08-T3", "subject": "LdapAuthResult.IsSystemFailure seam + OtOpcUaLdapAuthService.Adapt mapping (ServiceAccountBindFailed => system-side)", "status": "completed", "blockedBy": [] }, { "id": "R2-08-T4", "subject": "LdapOpcUaUserAuthenticator: WaitAsync wall-clock bound + SemaphoreSlim concurrency cap with release-on-core-completion (turns T1 green)", "status": "completed", "blockedBy": ["R2-08-T1", "R2-08-T2"] }, { "id": "R2-08-T5", "subject": "Directory-outage circuit: fail-fast deny after N consecutive system-side failures, half-open after cooldown", "status": "completed", "blockedBy": ["R2-08-T3", "R2-08-T4"] }, - { "id": "R2-08-T6", "subject": "Outage-sim integration test (real library, unroutable host, via internal HandleImpersonation) + SDK m_eventLock contract doc + docs/security.md", "status": "pending", "blockedBy": ["R2-08-T4"] }, + { "id": "R2-08-T6", "subject": "Outage-sim integration test (real library, unroutable host, via internal HandleImpersonation) + SDK m_eventLock contract doc + docs/security.md", "status": "completed", "blockedBy": ["R2-08-T4"] }, { "id": "R2-08-T7", "subject": "S3 sequential-serving repro test (RED): MaxObservedConcurrency >= 2 on an 8-node Raw batch — fails on current code", "status": "pending", "blockedBy": [] }, { "id": "R2-08-T8", "subject": "ServerHistorianOptions: HistoryReadBatchParallelism/MaxConcurrentHistoryReadBatches/HistoryReadDeadline + Validate warnings + node-manager properties + hosted-service wiring", "status": "pending", "blockedBy": [] }, { "id": "R2-08-T9", "subject": "Async-ify ServeNode + Processed/AtTime arms (ct threading, OCE=>BadTimeout, behavior-neutral)", "status": "pending", "blockedBy": ["R2-08-T7", "R2-08-T8"] }, diff --git a/docs/security.md b/docs/security.md index 0e7df469..3c925110 100644 --- a/docs/security.md +++ b/docs/security.md @@ -162,6 +162,20 @@ LDAP is configured under the `Security:Ldap` section (bound to `LdapOptions`, `s `GroupToRole` maps LDAP group names → roles (case-insensitive); a user gets every role whose source group is in their membership. The values are the canonical control-plane role strings (`Viewer` / `Designer` / `Administrator`, plus the appsettings-only `Operator` for the `DriverOperator` policy); the same map also supplies data-plane role strings (`ReadOnly`, `WriteOperate`, `WriteTune`, `WriteConfigure`, `AlarmAck`) — see [Role grant source (data-plane)](#role-grant-source-data-plane) below. `UserNameAttribute: "sAMAccountName"` is the critical AD override — the GLAuth dev default is `cn`, which is not how AD users are looked up; use `userPrincipalName` instead if operators log in with `user@corp.example.com` form. `LdapOptionsValidator` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapOptionsValidator.cs`) fails startup when `Transport = None` and `AllowInsecure = false` on a real-LDAP (non-DevStub) config. +### LDAP resilience — timeout, concurrency, outage circuit (arch-review 03/S2) + +The OPC UA SDK raises its impersonation (UserName) callback **synchronously**, inside a `SessionManager`-wide event lock (verified against `OPCFoundation.NetStandard.Opc.Ua.Server` 1.5.378). A slow or hung LDAP bind inside that callback therefore does not stall one SDK thread — it **serializes every session activation server-wide** (Anonymous and X509 included). Since the callback is irreducibly synchronous, `LdapOpcUaUserAuthenticator` bounds it at its own boundary with five `Security:Ldap` keys (all validated at startup by `LdapOptionsValidator` when `Enabled && !DevStubMode`): + +| Key | Default | Bounds | +|---|---|---| +| `ConnectionTimeoutMs` | `10000` | Projected into the shared library's `ConnectionTimeoutMs` — bounds the socket connect **and** each per-operation search. First line of defence against an outage. | +| `AuthTimeoutMs` | `15000` | Hard whole-flow wall-clock bound (bind **and** role-map) enforced at the authenticator boundary; a slow/hung flow denies **"Authentication timed out"** (fail-closed) and releases the SDK thread. Deliberately `> ConnectionTimeoutMs` so a healthy-but-slow flow is not cut off. | +| `MaxConcurrentAuthentications` | `8` | Max in-flight authentications; excess denies **"Authentication backend busy"** (local backpressure — does **not** feed the circuit). Bounds accumulation of orphaned/timed-out binds during an outage. | +| `OutageFailureThreshold` | `3` | Consecutive **system-side** failures (boundary timeout, directory-unreachable, or unexpected throw) that open the outage circuit. Any success or user-credential deny resets the streak. `0` disables the circuit. | +| `OutageCooldownSeconds` | `15` | How long the open circuit fast-denies **"Authentication backend unavailable"** without touching LDAP; after cooldown the next call half-open-probes, and one more system failure re-opens immediately. | + +Defaults are behaviour-neutral on a healthy directory. All new paths are **fail-closed** (they deny). A user-credential deny (wrong password) is fast when the directory is up and never trips the circuit; only a directory *outage* opens it. + --- ## Data-Plane Authorization diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs index 8bd0c3a5..7a62694e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs @@ -235,6 +235,19 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable /// the full SDK. Side-effects are confined to mutating /// and logging. /// + /// + /// SDK threading contract (verified against OPCFoundation.NetStandard.Opc.Ua.Server 1.5.378). + /// The SDK raises this handler SYNCHRONOUSLY — it is a plain void delegate, and there is no + /// async impersonation callback in this SDK version. Worse, SessionManager.ActivateSessionAsync + /// invokes it while holding a single SessionManager-wide event lock (m_eventLock), 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 block-bridge below + /// (GetAwaiter().GetResult() under ) 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 (LdapOpcUaUserAuthenticator, arch-review 03/S2) — NOT here. + /// /// The user authenticator to validate credentials. /// The impersonation event arguments to process. /// The logger for diagnostic output. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj index 82dd01a3..e401905e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj @@ -23,6 +23,9 @@ + + diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapAuthResilienceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapAuthResilienceTests.cs index 58555439..f0d27517 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapAuthResilienceTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapAuthResilienceTests.cs @@ -1,12 +1,17 @@ using System.Diagnostics; +using System.Text; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; +using Opc.Ua; +using Opc.Ua.Server; using Shouldly; using Xunit; using ZB.MOM.WW.Auth.Abstractions.Roles; using ZB.MOM.WW.OtOpcUa.Host.OpcUa; +using ZB.MOM.WW.OtOpcUa.OpcUaServer; using ZB.MOM.WW.OtOpcUa.Security.Ldap; +using LdapTransport = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapTransport; namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; @@ -209,6 +214,51 @@ public sealed class LdapAuthResilienceTests ldap.Calls.ShouldBe(6, "a disabled circuit never fast-denies"); } + /// + /// Outage-sim (offline-safe): the REAL + real shared library + /// pointed at an UNROUTABLE host (a TCP blackhole — the same outage shape as "stop the GLAuth + /// container"), driven through the REAL internal + /// block-bridge. The activation must fail bounded (well under the SDK's default ~10-20 s stall) with + /// and no granted identity (03/S2). + /// + [Fact] + public async Task Activation_with_unreachable_directory_fails_within_bound() + { + var options = new LdapOptions + { + Enabled = true, + DevStubMode = false, + Server = "10.255.255.1", // RFC-blackhole: SYN goes nowhere + Port = 3893, + Transport = LdapTransport.None, + AllowInsecure = true, + SearchBase = "dc=zb,dc=local", + ConnectionTimeoutMs = 500, + AuthTimeoutMs = 1000, + }; + + var ldap = new OtOpcUaLdapAuthService(Options.Create(options), NullLogger.Instance); + var mapper = new FakeMapper(g => g.ToArray()); + var authenticator = new LdapOpcUaUserAuthenticator( + ldap, ScopeFactoryWith(mapper), Options.Create(options), + NullLogger.Instance); + + var token = new UserNameIdentityToken { UserName = "alice", DecryptedPassword = Encoding.UTF8.GetBytes("secret") }; + var policy = new UserTokenPolicy(UserTokenType.UserName) { PolicyId = "username_basic256sha256" }; + var args = new ImpersonateEventArgs(token, policy, new EndpointDescription()); + + var sw = Stopwatch.StartNew(); + await Task.Run(() => OpcUaApplicationHost.HandleImpersonation( + authenticator, args, NullLogger.Instance), TestContext.Current.CancellationToken) + .WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + sw.Stop(); + + args.Identity.ShouldBeNull(); + args.IdentityValidationError.ShouldNotBeNull(); + args.IdentityValidationError.Code.ShouldBe(Opc.Ua.StatusCodes.BadIdentityTokenRejected); + sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(3)); + } + private static LdapAuthResult SystemFailure() => new(false, null, "u", Array.Empty(), Array.Empty(), "Unexpected authentication error") { IsSystemFailure = true };