diff --git a/CLAUDE.md b/CLAUDE.md index 39254018..bb9b51f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -274,7 +274,7 @@ central SQL Server. > **Infra index:** [`infra/README.md`](infra/README.md) is the canonical index of all testing + > deployment infrastructure — the Docker host layout, driver fixtures, the env-gated **live-gate** -> recipes (S7 blackhole, GLAuth outage, HistorianGateway LiveIntegration), the integration-test +> recipes (S7 blackhole, HistorianGateway LiveIntegration), the integration-test > harness, the docker-dev rig, and the deploy setup. Start there; the sections below + `docs/v2/dev-environment.md` carry the detail. > **Migrated 2026-04-28**: Docker config + host moved off this dev VM (DESKTOP-6JL3KKO) onto the shared Linux Docker host (`DOCKER`, 10.100.0.35) so the dev VM could shed WSL2/Hyper-V and have its GPU re-attached via ESXi passthrough. Docker Desktop is no longer installed here. All checked-in `appsettings.json` defaults, fixture-class default endpoints, and `e2e-config.sample.json` were rewritten to target `10.100.0.35`. See `docs/v2/dev-environment.md` for the full rewrite (header dated 2026-04-28). @@ -591,9 +591,39 @@ Merged to master (`26fad75c`); **live gate PASSED** (`1281aebf` — driver nodes Task 9 landed (`3a590a0c`). See `docs/plans/2026-07-23-mesh-phase4-cut-driver-configdb.md` and `docs/plans/2026-07-22-per-cluster-mesh-program.md` §Phase 4. -## LDAP Authentication +## Authentication: API keys on the data plane, LDAP on AdminUI (#520) -The server uses LDAP-based user authentication via the `Security:Ldap` section in `appsettings.json`. When enabled, credentials are validated by LDAP bind against a GLAuth server, and LDAP group membership maps to OPC UA permissions: `ReadOnly` (browse/read), `WriteOperate` (write FreeAccess/Operate attributes), `WriteTune` (write Tune attributes), `WriteConfigure` (write Configure attributes), `AlarmAck` (alarm acknowledgment). `LdapOpcUaUserAuthenticator` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs`) implements `IOpcUaUserAuthenticator`, delegating the LDAP bind + group lookup to `OtOpcUaLdapAuthService` (`src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaLdapAuthService.cs`, an `ILdapAuthService`). See `docs/security.md` for the full guide. +⚠️ **Changed 2026-07-31. The OPC UA data plane no longer uses LDAP at all.** It is machine-to-machine, +so a client presents an OPC UA **UserName** token with the fixed username **`apikey`** and an +`otua__` credential as the password. `ApiKeyOpcUaUserAuthenticator` +(`src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/`) implements `IOpcUaUserAuthenticator`: one indexed lookup +on `keyId` plus one peppered HMAC-SHA256, `FixedTimeEquals`, **no network I/O**. The key resolves to a +**principal**; `NodeAcl.Principal` rows grant it permissions. `LdapOpcUaUserAuthenticator` is +**deleted**, along with four `Security:Ldap` keys that existed only for it (`AuthTimeoutMs`, +`MaxConcurrentAuthentications`, `OutageFailureThreshold`, `OutageCooldownSeconds`) — they bounded an +LDAP bind running inline in the SDK's synchronous, `SessionManager`-lock-holding impersonation +callback, and nothing on the remaining LDAP path has that property. + +**LDAP remains, for AdminUI login only** (`Security:Ldap`, `OtOpcUaLdapAuthService` → +`ILdapAuthService`, group→role mapping via `GroupToRole` + `LdapGroupRoleMapping`). Do not "restore" +the data-plane authenticator when you see LDAP wiring in `Program.cs` — it is there for the AdminUI. + +Two things to know before relying on any of this: + +- ⚠️ **Node ACLs are still not enforced per node.** A key's grants collapse into the two existing + server-wide role gates (`WriteOperate` on Value writes, `AlarmAck` on Part 9 methods), and + **only `Cluster`-scoped grants count** — `ApiKeyInterimRoleMapper` deliberately under-grants, because + honouring a narrower grant's flags while unable to honour its *scope* would turn "WriteOperate on + Equipment X" into server-wide write authority. Read, Browse, HistoryRead and CreateMonitoredItems + remain ungated for any admitted session, **including Anonymous**. +- ⚠️ **A driver-only node has no key source yet.** `ConfigDbApiKeyStore` needs a ConfigDb, which Phase 4 + removed from driver-only nodes; until the artifact-backed store lands they register + `NullOpcUaUserAuthenticator` and deny every UserName token. Note `OtOpcUaServerHostedService` takes + `IOpcUaUserAuthenticator` as a **required** ctor parameter — leaving it unregistered fails DI and the + node does not boot, so the deny-all must be registered explicitly. + +Remaining stages, and the rationale for every decision above, are in +`docs/plans/2026-07-31-apikey-acl-enforcement-design.md`. See `docs/security.md` for the full guide. Dev/test LDAP is the **shared GLAuth** running on the Linux Docker host at `10.100.0.35:3893` (baseDN `dc=zb,dc=local`, plaintext/`Transport=None`). It is managed via `scadaproj/infra/glauth/` (source of truth + deploy runbook). Single bind account `cn=serviceaccount,dc=zb,dc=local` / `serviceaccount123`; all test users password `password`. The docker-dev compose binds this shared instance directly — `DevStubMode` is no longer used. The per-VM NSSM GLAuth at `C:\publish\glauth\` and the old base DNs `dc=lmxopcua,dc=local` / `dc=otopcua,dc=local` are obsolete. (The integration-test harness under `tests/.../Host.IntegrationTests/` runs its own ephemeral **GLAuth** container on port 3894 — seeded by `tests/.../Host.IntegrationTests/glauth/config.toml` with dev users alice/bob + the `cn=serviceaccount` bind account — for the opt-in `OTOPCUA_HARNESS_USE_LDAP=1` real-bind mode; distinct from the shared dev GLAuth on 3893. It replaced the retired `bitnami/openldap:2.6` in PR #451, 2026-07-15.) diff --git a/docs/security.md b/docs/security.md index 5604c76d..a48a01cf 100644 --- a/docs/security.md +++ b/docs/security.md @@ -162,19 +162,67 @@ 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) +### API-key identity (OPC UA data plane) -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`): +The OPC UA data plane is **machine-to-machine**, so a consuming system authenticates with an API key +rather than an LDAP user (Gitea #520). The client presents an OPC UA **UserName** token whose username +is the fixed literal `apikey` and whose password is the key: + +``` +otua__ +``` + +`ApiKeyOpcUaUserAuthenticator` verifies it — one indexed lookup on `keyId` plus one peppered +HMAC-SHA256 over the secret, compared with `CryptographicOperations.FixedTimeEquals`. No network I/O. +The key resolves to a **principal**, and `NodeAcl` rows naming that principal are what grant it +permissions. + +| Key | Notes | +|---|---| +| `Security:ApiKeys:Username` | The static username clients present. Default `apikey`. Checked rather than ignored, so a typo is an explicit denial. | +| `Security:ApiKeys:Pepper` | Server-side pepper for the stored hashes. **Supply via `Security__ApiKeys__Pepper` or `${secret:}` — never commit it.** Rotating it invalidates every existing key. Has no default: an empty pepper still hashes "successfully", so `ApiKeySecret.ComputeHash` throws on one rather than producing a working-looking deployment with no protection. | + +Secrets are never stored — only the hash. The plaintext is shown **once** at creation and is +unrecoverable afterwards. + +**Every rejection returns the same opaque message** ("Invalid API key"), with the real reason logged +server-side only, so the server cannot be used as an oracle for whether a key id exists or which half +of a credential was wrong. + +⚠️ **Revocation requires a deploy.** Per-cluster mesh Phase 4 leaves driver-only nodes with no +ConfigDb, so keys reach them inside the deployment artifact exactly as `NodeAcl` rows do. Clearing +`Enabled` takes effect on a node only once a new artifact reaches it. + +⚠️ **Node ACLs are still not enforced per node.** Until the enforcement stages land, a key's grants are +collapsed into the two existing server-wide role gates, and **only `Cluster`-scoped grants count** — +a narrower grant confers nothing rather than silently widening to server-wide authority. Reads, +browses, subscriptions and history remain ungated for any admitted session. See +`docs/plans/2026-07-31-apikey-acl-enforcement-design.md`. + +### LDAP resilience — mostly retired with the data-plane authenticator (#520) + +> **Changed 2026-07-31.** The OPC UA **data plane** no longer uses LDAP at all — it authenticates with +> API keys (see [API-key identity](#api-key-identity-opc-ua-data-plane)). `LdapOpcUaUserAuthenticator` +> is deleted, and with it four of the five `Security:Ldap` resilience keys: **`AuthTimeoutMs`**, +> **`MaxConcurrentAuthentications`**, **`OutageFailureThreshold`** and **`OutageCooldownSeconds`**. +> Setting them now does nothing (options binding ignores unknown keys); remove them from any config +> that carries them. + +Those four existed for one specific reason. The OPC UA SDK raises its impersonation callback +**synchronously**, inside a `SessionManager`-wide event lock (verified against +`OPCFoundation.NetStandard.Opc.Ua.Server` 1.5.378), so a slow or hung LDAP bind in that callback did +not stall one SDK thread — it **serialized every session activation server-wide**, Anonymous and X509 +included. That constraint is gone: key verification is one indexed lookup plus one HMAC, with no +network I/O on the path. + +LDAP now runs only on **AdminUI login**, an ordinary async request path where a slow directory affects +the one request making it. One key remains: | 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. | +| `ConnectionTimeoutMs` | `10000` | Projected into the shared library's `ConnectionTimeoutMs` — bounds the socket connect **and** each per-operation search. Validated `> 0` at startup by `LdapOptionsValidator`. | + -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. --- diff --git a/infra/README.md b/infra/README.md index a22f6233..e8cc302a 100644 --- a/infra/README.md +++ b/infra/README.md @@ -135,17 +135,16 @@ export S7_TIMEOUT_OUTAGE_STOP_CMD='ssh dohertj2@10.100.0.35 "docker unpause otop dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests --filter "FullyQualifiedName~ConnectTimeoutOutage" ``` -### 5b. LDAP GLAuth outage/recovery (`LdapGlauthOutageLiveTests`) -Drives the directory-outage circuit through the production auth path against the harness GLAuth -(§4), paused/unpaused. Also verifies the GLAuth swap binds correctly. +### 5b. LDAP GLAuth outage/recovery — **RETIRED 2026-07-31 (#520)** -```bash -ssh dohertj2@10.100.0.35 'cd ~/otopcua-harness && docker compose up -d ldap' -export GLAUTH_LIVE_HOST=10.100.0.35 # port defaults to 3894 (GLAUTH_LIVE_PORT) -export GLAUTH_OUTAGE_START_CMD='ssh dohertj2@10.100.0.35 "docker pause otopcua-harness-ldap-1"' -export GLAUTH_OUTAGE_STOP_CMD='ssh dohertj2@10.100.0.35 "docker unpause otopcua-harness-ldap-1"' -dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests --filter "FullyQualifiedName~LdapGlauthOutageLive" -``` +`LdapGlauthOutageLiveTests` and the directory-outage circuit it drove were deleted with +`LdapOpcUaUserAuthenticator`. The OPC UA data plane authenticates with **API keys**, not LDAP, so no +LDAP bind runs inside the SDK's synchronous impersonation callback any more — which is the only +reason that circuit, its wall-clock bound, and its concurrency cap existed. LDAP remains only on the +AdminUI login path, where a slow directory stalls one request rather than serializing every OPC UA +session activation. + +The harness GLAuth (§4) is still used by `OTOPCUA_HARNESS_USE_LDAP=1` and is unaffected. ### 5c. HistorianGateway LiveIntegration soak (`Category=LiveIntegration`) — needs VPN Exercises the gateway read/write/alarm/EnsureTags paths against a **real AVEVA historian**. Requires diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapOptionsValidator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapOptionsValidator.cs index 18309fe2..de39a261 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapOptionsValidator.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapOptionsValidator.cs @@ -48,17 +48,12 @@ public sealed class LdapOptionsValidator : OptionsValidatorBase !(options.Transport == LdapTransport.None && !options.AllowInsecure), "LDAP transport is None (plaintext) but AllowInsecure is false — set Transport to Ldaps/StartTls or set AllowInsecure for dev."); - // S2 resilience knobs — a non-positive timeout / concurrency bound would silently disable the - // wall-clock/concurrency protection the OPC UA data-plane authenticator relies on, so fail fast. + // A non-positive connect/search timeout would leave the shared library unbounded against a hung + // directory, so fail fast. The other four S2 knobs validated here (AuthTimeoutMs, + // MaxConcurrentAuthentications, OutageFailureThreshold, OutageCooldownSeconds) went with + // LdapOpcUaUserAuthenticator in #520 — they guarded an LDAP bind running inline on OPC UA + // session activation, and no remaining LDAP consumer runs on that path. builder.RequireThat(options.ConnectionTimeoutMs > 0, "Ldap:ConnectionTimeoutMs must be > 0."); - builder.RequireThat(options.AuthTimeoutMs > 0, - "Ldap:AuthTimeoutMs must be > 0."); - builder.RequireThat(options.MaxConcurrentAuthentications > 0, - "Ldap:MaxConcurrentAuthentications must be > 0."); - // An open circuit with no cooldown would re-probe LDAP on every call — pointless. Only enforce - // a positive cooldown when the circuit is actually enabled (OutageFailureThreshold > 0; 0 disables). - builder.RequireThat(options.OutageFailureThreshold <= 0 || options.OutageCooldownSeconds > 0, - "Ldap:OutageCooldownSeconds must be > 0 when Ldap:OutageFailureThreshold > 0 (0 disables the circuit)."); } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs deleted file mode 100644 index bd97cd93..00000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs +++ /dev/null @@ -1,260 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using ZB.MOM.WW.Auth.Abstractions.Roles; -using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; -using ZB.MOM.WW.OtOpcUa.Security.Ldap; - -namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa; - -/// -/// Production adapter that bridges OPC UA UserName -/// tokens to the same the Admin UI cookie/JWT flows use, so a -/// single LDAP source-of-truth governs both control-plane (Admin) and data-plane (OPC UA) -/// session identities. Roles are resolved through the shared -/// seam from the LDAP groups returned by the directory — -/// the same seam the login endpoint uses — and the resolved set is attached to the OPC UA -/// session identity for the downstream data-plane ACL evaluator. -/// -/// -/// -/// This authenticator is registered as a singleton, but -/// (and its DbContext-backed mapping service) is scoped. A per-call DI scope is opened to -/// resolve the mapper so the singleton never captures a scoped dependency. -/// -/// -/// Resilience (arch-review 03/S2). The OPC UA SDK raises the impersonation callback that -/// reaches here synchronously, inside a SessionManager-wide event lock (verified -/// against OPCFoundation.NetStandard.Opc.Ua.Server 1.5.378 — -/// SessionManager.ActivateSessionAsync holds m_eventLock while invoking the -/// void impersonation delegate). A slow / hung LDAP bind inside that callback therefore does -/// not merely stall one SDK thread — it serializes every session activation server-wide -/// (Anonymous and X509 included). The callback contract is irreducibly synchronous (there is no -/// async impersonation callback), so the fix lives entirely at this boundary: a hard whole-flow -/// wall-clock bound () and a bounded in-flight concurrency cap -/// (), both fail-closed. All new paths deny. -/// -/// -public sealed class LdapOpcUaUserAuthenticator : IOpcUaUserAuthenticator -{ - private readonly ILdapAuthService _ldap; - private readonly IServiceScopeFactory _scopeFactory; - private readonly ILogger _logger; - private readonly LdapOptions _options; - - /// Bounds in-flight authentication flows. A boundary timeout abandons its core task, which - /// keeps holding its slot until it actually completes — so an outage cannot accumulate unbounded - /// orphan binds. - private readonly SemaphoreSlim _gate; - - /// Guards the outage-circuit state ( + - /// ). - private readonly object _circuitLock = new(); - - /// Count of CONSECUTIVE system-side failures; reset by any success / user-side deny. - private int _consecutiveSystemFailures; - - /// the circuit stays open until; 0 = closed. - private long _circuitOpenUntilTicks; - - /// Constructs the authenticator from its dependencies and the bound LDAP options. - /// The app LDAP auth service (shared with the Admin UI login flow). - /// Opens a per-call DI scope to resolve the scoped role mapper. - /// The bound (timeout + concurrency knobs). - /// The logger. - public LdapOpcUaUserAuthenticator( - ILdapAuthService ldap, - IServiceScopeFactory scopeFactory, - IOptions options, - ILogger logger) - { - _ldap = ldap; - _scopeFactory = scopeFactory; - _options = options.Value; - _logger = logger; - _gate = new SemaphoreSlim(Math.Max(1, _options.MaxConcurrentAuthentications)); - } - - /// - public async Task AuthenticateUserNameAsync(string username, string password, CancellationToken ct) - { - var authTimeout = TimeSpan.FromMilliseconds(Math.Max(1, _options.AuthTimeoutMs)); - - // Directory-outage circuit: after a run of consecutive system-side failures, fail fast WITHOUT - // touching LDAP for the cooldown window — so a sustained outage cannot serialize every session - // activation behind a per-call stall (the SDK raises this callback under a server-wide lock). - if (IsCircuitOpen()) - { - _logger.LogDebug( - "LDAP outage circuit open — fast-denying OPC UA user {User} without a directory call", username); - return OpcUaUserAuthResult.Deny("Authentication backend unavailable"); - } - - // Bounded in-flight concurrency: acquire a slot within our own wall-clock budget. All slots - // busy is LOCAL backpressure, not directory evidence — deny "busy" (fail-closed) and do NOT - // feed the outage circuit. - if (!await _gate.WaitAsync(authTimeout, ct).ConfigureAwait(false)) - { - _logger.LogWarning( - "LDAP authentication backend busy (concurrency cap {Cap}) — denying OPC UA user {User}", - _options.MaxConcurrentAuthentications, username); - return OpcUaUserAuthResult.Deny("Authentication backend busy"); - } - - // Release the slot when the CORE task completes — deliberately NOT in a finally here. A boundary - // timeout below abandons the core task (it runs to completion in the background; the underlying - // library is documented never-throw, so there are no unobserved exceptions), and it must keep - // counting against the cap until it finishes. The point of the boundary is to release the SDK - // thread (and m_eventLock), not to cancel the bind. - var core = AuthenticateCoreAsync(username, password, ct); - _ = core.ContinueWith( - static (_, state) => ((SemaphoreSlim)state!).Release(), - _gate, - CancellationToken.None, - TaskContinuationOptions.None, - TaskScheduler.Default); - - try - { - var outcome = await core.WaitAsync(authTimeout, ct).ConfigureAwait(false); - RecordOutcome(outcome.SystemFailure, username); - return outcome.Result; - } - catch (TimeoutException) - { - // A boundary timeout is directory evidence — it feeds the circuit. - RecordOutcome(systemFailure: true, username); - _logger.LogWarning( - "LDAP authentication timed out after {Timeout} for OPC UA user {User} — denying (fail-closed)", - authTimeout, username); - return OpcUaUserAuthResult.Deny("Authentication timed out"); - } - } - - /// Whether the outage circuit is currently open (fast-deny window active). Always false when - /// the circuit is disabled ( ≤ 0). - private bool IsCircuitOpen() - { - if (_options.OutageFailureThreshold <= 0) return false; - lock (_circuitLock) - { - return _circuitOpenUntilTicks > Environment.TickCount64; - } - } - - /// - /// Updates the outage-circuit state from one authentication outcome. A system-side failure advances - /// the consecutive-failure streak and opens the circuit once it reaches - /// ; any non-system outcome (success or user-side - /// deny) resets the streak and closes the circuit. Half-open behaviour is implicit: once the cooldown - /// elapses lets the next call through with the streak still at threshold, - /// so a single further system failure re-opens immediately. - /// - /// Whether this outcome was a system-side (directory) failure. - /// The login name, for diagnostics. - private void RecordOutcome(bool systemFailure, string username) - { - if (_options.OutageFailureThreshold <= 0) return; // circuit disabled - - lock (_circuitLock) - { - if (systemFailure) - { - _consecutiveSystemFailures++; - if (_consecutiveSystemFailures >= _options.OutageFailureThreshold) - { - var wasOpen = _circuitOpenUntilTicks > Environment.TickCount64; - _circuitOpenUntilTicks = - Environment.TickCount64 + (long)Math.Max(1, _options.OutageCooldownSeconds) * 1000L; - if (!wasOpen) - { - _logger.LogWarning( - "LDAP outage circuit OPENED after {Failures} consecutive system-side failures — " + - "fast-denying for {Cooldown}s (last user {User})", - _consecutiveSystemFailures, _options.OutageCooldownSeconds, username); - } - } - } - else - { - if (_consecutiveSystemFailures >= _options.OutageFailureThreshold) - { - _logger.LogInformation( - "LDAP outage circuit CLOSED — directory recovered (probe succeeded for {User})", username); - } - _consecutiveSystemFailures = 0; - _circuitOpenUntilTicks = 0; - } - } - } - - /// - /// The unbounded core authenticate flow (bind + role-map) that the outer - /// wraps in a wall-clock bound + concurrency cap. Fail-closed: - /// any backend throw becomes a denial rather than escaping into the SDK. The returned - /// flags directory-side faults (a system-side - /// deny, or an unexpected throw) so the caller can feed - /// the outage circuit; a user-credential deny is NOT a system failure. - /// - /// The username to authenticate. - /// The cleartext password. - /// Cancellation token. - private async Task AuthenticateCoreAsync(string username, string password, CancellationToken ct) - { - try - { - var result = await _ldap.AuthenticateAsync(username, password, ct).ConfigureAwait(false); - if (!result.Success) - { - return new CoreOutcome( - OpcUaUserAuthResult.Deny(result.Error ?? "Invalid credentials"), result.IsSystemFailure); - } - - var roles = await ResolveRolesAsync(result.Groups, result.Roles, username, ct).ConfigureAwait(false); - return new CoreOutcome(OpcUaUserAuthResult.Allow(result.DisplayName ?? username, roles), SystemFailure: false); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _logger.LogWarning(ex, "LDAP authentication threw for OPC UA user {User}", username); - return new CoreOutcome(OpcUaUserAuthResult.Deny("Authentication backend error"), SystemFailure: true); - } - } - - /// The result of the core authenticate flow plus whether it was a system-side (directory) - /// failure — the signal that drives the outage circuit. - /// The auth result to return to the SDK. - /// Whether the outcome was a directory-side fault. - private readonly record struct CoreOutcome(OpcUaUserAuthResult Result, bool SystemFailure); - - /// - /// Resolves the user's roles from their LDAP groups via the scoped - /// , unioned with any pre-resolved roles (the DevStub - /// FleetAdmin grant). A mapper fault (e.g. a DB outage) must not deny an otherwise-authenticated - /// session: it falls back to the pre-resolved roles, matching the login endpoint's behaviour. - /// - /// The LDAP groups returned by the directory. - /// Pre-resolved roles (empty on the real path; FleetAdmin under DevStub). - /// The login name, for diagnostics. - /// Cancellation token. - private async Task> ResolveRolesAsync( - IReadOnlyList groups, IReadOnlyList preResolved, string username, CancellationToken ct) - { - try - { - await using var scope = _scopeFactory.CreateAsyncScope(); - var mapper = scope.ServiceProvider.GetRequiredService>(); - var mapping = await mapper.MapAsync(groups, ct).ConfigureAwait(false); - - var roles = new HashSet(preResolved, StringComparer.OrdinalIgnoreCase); - foreach (var role in mapping.Roles) - roles.Add(role); - return [.. roles]; - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _logger.LogWarning(ex, - "Role-map lookup failed for OPC UA user {User}; using pre-resolved baseline roles", username); - return preResolved; - } - } -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs index 7a62694e..037feed2 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs @@ -246,7 +246,9 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable /// (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. + /// inside the authenticator — NOT here. (#520 replaced the LDAP authenticator with + /// ApiKeyOpcUaUserAuthenticator, 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.) /// /// The user authenticator to validate credentials. /// The impersonation event arguments to process. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs index ffb5db57..3eb973bd 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs @@ -88,44 +88,18 @@ public sealed class LdapOptions /// per-operation search inside a single bind flow. Projected into the library's /// via . The /// default (10000) matches the library default, so behaviour is unchanged unless configured. - /// This is the FIRST line of defence against a directory outage; the whole-flow - /// is the backstop (03/S2). /// + /// + /// This is now the ONLY directory-outage bound. Its former backstops — AuthTimeoutMs, + /// MaxConcurrentAuthentications, OutageFailureThreshold and + /// OutageCooldownSeconds — were removed with LdapOpcUaUserAuthenticator in #520 + /// (see the note on this type). They existed because an LDAP bind ran inline on OPC UA session + /// activation, under a SessionManager-wide lock; nothing on the remaining AdminUI login path has + /// that property, so they had no consumer left. Setting any of them in configuration is now + /// silently ignored rather than an error, which is how options binding treats unknown keys. + /// public int ConnectionTimeoutMs { get; set; } = 10_000; - /// - /// Hard whole-flow wall-clock bound (in milliseconds) the OPC UA data-plane authenticator - /// (LdapOpcUaUserAuthenticator) applies across the ENTIRE authenticate flow — bind plus - /// role-map — so the OPC UA SDK's synchronous impersonation callback (raised under a - /// SessionManager-wide event lock) can never be wedged by a slow / hung directory. - /// Deliberately > (default 15000) so a healthy-but-slow - /// full flow is not cut off while an outage is bounded by the library timeout first (03/S2). - /// - public int AuthTimeoutMs { get; set; } = 15_000; - - /// - /// Maximum number of in-flight OPC UA authentication flows the data-plane authenticator admits - /// at once. A caller that cannot acquire a slot within its budget is denied "backend busy" - /// (local backpressure, not counted against the outage circuit). Bounds accumulation of - /// orphaned / timed-out bind tasks during an outage (03/S2). Default 8. - /// - public int MaxConcurrentAuthentications { get; set; } = 8; - - /// - /// Number of CONSECUTIVE system-side failures (boundary timeout, directory-unreachable result, - /// or unexpected throw) after which the data-plane authenticator's outage circuit opens and - /// denies instantly for without touching LDAP. Any success - /// or user-side (credential) deny resets the counter. 0 disables the circuit (03/S2). - /// Default 3. - /// - public int OutageFailureThreshold { get; set; } = 3; - - /// - /// How long (in seconds) the outage circuit stays open before a half-open probe is allowed - /// through. Only meaningful when > 0. Default 15 (03/S2). - /// - public int OutageCooldownSeconds { get; set; } = 15; - /// /// Projects the wire fields onto the shared ZB.MOM.WW.Auth.Ldap /// the directory client consumes. App-only concerns diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs index 1a15e0d5..6561d662 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs @@ -57,7 +57,7 @@ public static class ServiceCollectionExtensions // Singleton — OtOpcUaLdapAuthService is stateless (the shared-library directory client it // wraps opens/disposes an LdapConnection per call) and must be consumable by the Singleton - // LdapOpcUaUserAuthenticator on driver-role nodes. This is the app's ILdapAuthService: it + // AdminUI login on admin-role nodes. This is the app's ILdapAuthService: it // adds the Enabled master switch + DevStubMode bypass on top of the shared ZB.MOM.WW.Auth.Ldap // service. TryAdd so a fused admin+driver node (which also registers it in Program.cs for the // driver path) ends up with exactly one descriptor regardless of registration order. @@ -65,7 +65,7 @@ public static class ServiceCollectionExtensions // Shared ZB.MOM.WW.Auth group→role mapper seam. Wraps the existing RoleMapper.Map + // RoleMapper.Merge logic; consumed by AuthEndpoints.cs (/auth/login) and by - // LdapOpcUaUserAuthenticator (OPC UA UserName session auth). Scoped to match + // AdminUI login (the OPC UA data plane uses API keys since #520). Scoped to match // ILdapGroupRoleMappingService (DbContext-backed, registered Scoped) — a singleton here // would capture the scoped DB service. services.TryAddScoped, OtOpcUaGroupRoleMapper>(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapAuthResilienceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapAuthResilienceTests.cs deleted file mode 100644 index f0d27517..00000000 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapAuthResilienceTests.cs +++ /dev/null @@ -1,389 +0,0 @@ -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; - -/// -/// Resilience guard for (arch-review 03/S2). The OPC UA SDK -/// raises the impersonation callback synchronously under a SessionManager-wide event lock, so a -/// slow / hung LDAP bind inside it does not merely stall one SDK thread — it serializes EVERY session -/// activation server-wide. These tests prove the authenticator enforces a hard wall-clock bound, -/// bounded in-flight concurrency, and a directory-outage circuit at its own boundary (the SDK callback -/// being irreducibly synchronous), all fail-closed. No real LDAP server is used — a delaying fake -/// reproduces the outage shape offline. -/// -public sealed class LdapAuthResilienceTests -{ - /// - /// RED repro (03/S2): a backend that takes 5 s must NOT hold the caller for 5 s — the authenticator's - /// wall-clock bound denies (fail-closed) well before then. On current code (no bound) this fails: the - /// call returns Allow after the full 5 s. - /// - [Fact] - public async Task Authenticate_slow_backend_denies_within_AuthTimeout() - { - var ldap = new DelayingFakeLdap( - TimeSpan.FromSeconds(5), - new LdapAuthResult(true, "Alice", "alice", new[] { "configeditor" }, Array.Empty(), null)); - var mapper = new FakeMapper(g => g.ToArray()); - var sut = Build(ldap, mapper, new LdapOptions { AuthTimeoutMs = 200 }); - - var sw = Stopwatch.StartNew(); - var result = await sut - .AuthenticateUserNameAsync("alice", "secret", CancellationToken.None) - .WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None); - sw.Stop(); - - result.Success.ShouldBeFalse(); - result.Error.ShouldNotBeNull(); - result.Error!.ShouldContain("timed out"); - sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(1.5)); - } - - /// A fast, healthy backend is unaffected by the resilience wrapping — success + roles pass - /// through untouched (03/S2). - [Fact] - public async Task Authenticate_fast_success_unaffected() - { - var ldap = new DelayingFakeLdap( - TimeSpan.Zero, - new LdapAuthResult(true, "Alice", "alice", new[] { "configeditor" }, Array.Empty(), null)); - var mapper = new FakeMapper(g => g.Select(x => x == "configeditor" ? "Designer" : x).ToArray()); - var sut = Build(ldap, mapper, new LdapOptions()); - - var result = await sut - .AuthenticateUserNameAsync("alice", "secret", CancellationToken.None) - .WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None); - - result.Success.ShouldBeTrue(); - result.DisplayName.ShouldBe("Alice"); - result.Roles.ShouldBe(new[] { "Designer" }); - } - - /// With the concurrency cap saturated (a parked in-flight bind holding the only slot), a - /// second call is denied "backend busy" within its own budget — it never waits behind the parked - /// bind indefinitely (03/S2). - [Fact] - public async Task Authenticate_saturated_concurrency_denies_busy() - { - var ldap = new GatedFakeLdap( - new LdapAuthResult(true, "Alice", "alice", new[] { "configeditor" }, Array.Empty(), null)); - var mapper = new FakeMapper(g => g.ToArray()); - // MaxConcurrentAuthentications = 1 → the second call cannot get a slot; small AuthTimeoutMs so the - // "busy" denial is quick and the first call's boundary also trips fast (its core stays parked, - // holding the slot). - var sut = Build(ldap, mapper, new LdapOptions { MaxConcurrentAuthentications = 1, AuthTimeoutMs = 300 }); - - // First call: acquires the only slot; its core parks in the gated fake. - var first = sut.AuthenticateUserNameAsync("alice", "secret", CancellationToken.None); - - // Wait until the fake has actually been entered (slot acquired, core parked). - var spin = Stopwatch.StartNew(); - while (ldap.Entered == 0 && spin.Elapsed < TimeSpan.FromSeconds(5)) - await Task.Delay(10, TestContext.Current.CancellationToken); - ldap.Entered.ShouldBe(1); - - // Second concurrent call: the slot is held by the parked core, so acquisition times out → busy. - var second = await sut - .AuthenticateUserNameAsync("bob", "secret", CancellationToken.None) - .WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None); - - second.Success.ShouldBeFalse(); - second.Error.ShouldNotBeNull(); - second.Error!.ShouldContain("busy"); - ldap.Entered.ShouldBe(1, "the second call must be denied without ever reaching the backend"); - - // Release the parked bind so the first call's core completes cleanly (no orphaned resources). - ldap.Release(); - await first.WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None); - } - - /// After OutageFailureThreshold consecutive system-side failures the circuit opens and - /// the next call is fast-denied "backend unavailable" WITHOUT touching LDAP (03/S2). - [Fact] - public async Task Consecutive_system_failures_open_circuit_and_fast_deny() - { - var ldap = new DelayingFakeLdap(TimeSpan.Zero, SystemFailure()); - var mapper = new FakeMapper(g => g.ToArray()); - var sut = Build(ldap, mapper, - new LdapOptions { OutageFailureThreshold = 3, OutageCooldownSeconds = 60, AuthTimeoutMs = 5000 }); - - for (var i = 0; i < 3; i++) - { - var r = await sut.AuthenticateUserNameAsync("u", "p", CancellationToken.None) - .WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None); - r.Success.ShouldBeFalse(); - } - - ldap.Calls.ShouldBe(3); - - var sw = Stopwatch.StartNew(); - var blocked = await sut.AuthenticateUserNameAsync("u", "p", CancellationToken.None) - .WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None); - sw.Stop(); - - blocked.Success.ShouldBeFalse(); - blocked.Error.ShouldNotBeNull(); - blocked.Error!.ShouldContain("unavailable"); - ldap.Calls.ShouldBe(3, "an open circuit must fast-deny without reaching the backend"); - sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(1)); - } - - /// A user-side (credential) deny between system failures resets the streak, so the circuit - /// never opens — user denies are not directory evidence (03/S2). - [Fact] - public async Task User_side_deny_resets_the_system_failure_streak() - { - var ldap = new ScriptedFakeLdap( - SystemFailure(), SystemFailure(), UserDeny(), SystemFailure(), SystemFailure()); - var mapper = new FakeMapper(g => g.ToArray()); - var sut = Build(ldap, mapper, - new LdapOptions { OutageFailureThreshold = 3, OutageCooldownSeconds = 60, AuthTimeoutMs = 5000 }); - - // 5 scripted calls: sys, sys, user(reset), sys, sys → streak ends at 2 (< 3), circuit stays closed. - for (var i = 0; i < 5; i++) - { - await sut.AuthenticateUserNameAsync("u", "p", CancellationToken.None) - .WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None); - } - - // A 6th call must still REACH the backend (circuit never opened). - await sut.AuthenticateUserNameAsync("u", "p", CancellationToken.None) - .WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None); - ldap.Calls.ShouldBe(6); - } - - /// After the cooldown elapses the circuit half-opens: the next call probes the backend and a - /// success closes the circuit (03/S2). - [Fact] - public async Task Half_open_probe_after_cooldown_closes_on_success() - { - var ldap = new ScriptedFakeLdap( - SystemFailure(), SystemFailure(), SystemFailure(), - new LdapAuthResult(true, "Alice", "alice", new[] { "configeditor" }, Array.Empty(), null)); - var mapper = new FakeMapper(g => g.ToArray()); - var sut = Build(ldap, mapper, - new LdapOptions { OutageFailureThreshold = 3, OutageCooldownSeconds = 1, AuthTimeoutMs = 5000 }); - - for (var i = 0; i < 3; i++) - await sut.AuthenticateUserNameAsync("u", "p", CancellationToken.None) - .WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None); - ldap.Calls.ShouldBe(3); - - // Circuit open: this call is fast-denied without touching the backend. - var blocked = await sut.AuthenticateUserNameAsync("u", "p", CancellationToken.None) - .WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None); - blocked.Error!.ShouldContain("unavailable"); - ldap.Calls.ShouldBe(3); - - // Wait past the cooldown, then a probe reaches the backend and succeeds → circuit closes. - await Task.Delay(TimeSpan.FromMilliseconds(1200), TestContext.Current.CancellationToken); - - var probe = await sut.AuthenticateUserNameAsync("alice", "secret", CancellationToken.None) - .WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None); - probe.Success.ShouldBeTrue(); - ldap.Calls.ShouldBe(4, "the half-open probe reaches the backend"); - } - - /// OutageFailureThreshold = 0 disables the circuit — it never opens, every call reaches - /// the backend (03/S2). - [Fact] - public async Task Circuit_disabled_never_opens() - { - var ldap = new DelayingFakeLdap(TimeSpan.Zero, SystemFailure()); - var mapper = new FakeMapper(g => g.ToArray()); - var sut = Build(ldap, mapper, - new LdapOptions { OutageFailureThreshold = 0, OutageCooldownSeconds = 60, AuthTimeoutMs = 5000 }); - - for (var i = 0; i < 6; i++) - await sut.AuthenticateUserNameAsync("u", "p", CancellationToken.None) - .WaitAsync(TimeSpan.FromSeconds(10), CancellationToken.None); - - 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 }; - - private static LdapAuthResult UserDeny() => - new(false, null, "u", Array.Empty(), Array.Empty(), "Invalid username or password") - { IsSystemFailure = false }; - - private static LdapOpcUaUserAuthenticator Build( - ILdapAuthService ldap, IGroupRoleMapper mapper, LdapOptions options) => - new(ldap, ScopeFactoryWith(mapper), Options.Create(options), - NullLogger.Instance); - - /// Builds an IServiceScopeFactory whose scopes resolve the supplied mapper. - private static IServiceScopeFactory ScopeFactoryWith(IGroupRoleMapper mapper) - { - var services = new ServiceCollection(); - services.AddScoped(_ => mapper); - return services.BuildServiceProvider().GetRequiredService(); - } - - /// - /// A fake that awaits a configurable delay (observing the token) before - /// returning a fixed result — the offline stand-in for a slow / hung directory. Counts invocations so - /// tests can assert the outage circuit denies WITHOUT reaching the backend. - /// - private sealed class DelayingFakeLdap : ILdapAuthService - { - private readonly TimeSpan _delay; - private readonly LdapAuthResult _then; - private int _calls; - - /// Initializes the fake with a per-call delay and the result to return afterwards. - /// How long each authenticate call blocks before returning. - /// The result returned once the delay elapses. - public DelayingFakeLdap(TimeSpan delay, LdapAuthResult then) - { - _delay = delay; - _then = then; - } - - /// The number of times the backend was actually invoked. - public int Calls => Volatile.Read(ref _calls); - - /// Awaits the configured delay, then returns the canned result. - /// The username (ignored). - /// The password (ignored). - /// Cancellation token, observed by the delay. - public async Task AuthenticateAsync(string username, string password, CancellationToken ct = default) - { - Interlocked.Increment(ref _calls); - await Task.Delay(_delay, ct).ConfigureAwait(false); - return _then; - } - } - - /// - /// A fake that parks ASYNCHRONOUSLY (awaits a - /// ) until the test calls — used to hold an - /// in-flight bind so the concurrency cap is deterministically saturated WITHOUT blocking a thread. - /// Records how many calls have entered (past the slot acquire). - /// - private sealed class GatedFakeLdap : ILdapAuthService - { - private readonly TaskCompletionSource _park = new(TaskCreationOptions.RunContinuationsAsynchronously); - private readonly LdapAuthResult _then; - private int _entered; - - /// Initializes the fake with the result to return once released. - /// The result returned once is called. - public GatedFakeLdap(LdapAuthResult then) => _then = then; - - /// Number of authenticate calls that reached the backend body. - public int Entered => Volatile.Read(ref _entered); - - /// Releases the parked bind so its awaiting call can complete. - public void Release() => _park.TrySetResult(); - - /// Marks entry, awaits the parking gate, then returns the canned result. - /// The username (ignored). - /// The password (ignored). - /// Cancellation token, observed while parked. - public async Task AuthenticateAsync(string username, string password, CancellationToken ct = default) - { - Interlocked.Increment(ref _entered); - await _park.Task.WaitAsync(ct).ConfigureAwait(false); - return _then; - } - } - - /// - /// A fake that returns a scripted sequence of results (one per call; - /// the last entry repeats once exhausted). Counts calls so the outage-circuit tests can assert the - /// backend was — or was NOT — reached on a given call. - /// - private sealed class ScriptedFakeLdap : ILdapAuthService - { - private readonly IReadOnlyList _script; - private int _calls; - - /// Initializes the fake with the scripted results, returned in order. - /// The results to return, one per call; the last repeats once exhausted. - public ScriptedFakeLdap(params LdapAuthResult[] script) => _script = script; - - /// The number of times the backend was invoked. - public int Calls => Volatile.Read(ref _calls); - - /// Returns the next scripted result (clamped to the last entry). - /// The username (ignored). - /// The password (ignored). - /// The cancellation token (ignored). - public Task AuthenticateAsync(string username, string password, CancellationToken ct = default) - { - var idx = Interlocked.Increment(ref _calls) - 1; - return Task.FromResult(_script[Math.Min(idx, _script.Count - 1)]); - } - } - - /// Test group→role mapper driven by a delegate over the supplied groups. - private sealed class FakeMapper(Func, IReadOnlyList> map) : IGroupRoleMapper - { - /// Maps groups to roles via the configured delegate; Scope is always null. - /// The LDAP groups to map. - /// The cancellation token. - public Task> MapAsync(IReadOnlyList groups, CancellationToken ct) - => Task.FromResult(new GroupRoleMapping(map(groups), Scope: null)); - } -} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapGlauthOutageLiveTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapGlauthOutageLiveTests.cs deleted file mode 100644 index eeb52391..00000000 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapGlauthOutageLiveTests.cs +++ /dev/null @@ -1,190 +0,0 @@ -using System.Diagnostics; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; -using Shouldly; -using Xunit; -using ZB.MOM.WW.Auth.Abstractions.Roles; -using ZB.MOM.WW.OtOpcUa.Host.OpcUa; -using ZB.MOM.WW.OtOpcUa.Security.Ldap; -using LdapTransport = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapTransport; - -namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; - -/// -/// R2-08 03/S2 LIVE gate — the directory-outage circuit exercised against a REAL GLAuth server -/// (the image + config PR #451 swapped in for the retired bitnami/openldap), driven through the -/// production auth path ( + ). -/// The offline prove the circuit logic with fakes + an -/// unroutable-host blackhole; this proves the same behaviour against an actual directory that is -/// paused mid-run (a real outage) and unpaused (recovery), and doubles as the first end-to-end -/// verification that the GLAuth swap binds correctly. -/// -/// -/// Triple-gated so it never runs by accident and always restores the shared GLAuth: -/// -/// GLAUTH_LIVE_HOST — the GLAuth host (e.g. 10.100.0.35); absent ⇒ skip; -/// GLAUTH_OUTAGE_START_CMD — pauses the GLAuth container (a real outage); and -/// GLAUTH_OUTAGE_STOP_CMD — unpauses it. -/// -/// A try/finally ALWAYS runs the stop command once the pause succeeded. Operator recipe: -/// -/// ssh dohertj2@10.100.0.35 "cd ~/otopcua-harness && docker compose up -d ldap" -/// export GLAUTH_LIVE_HOST=10.100.0.35 -/// export GLAUTH_OUTAGE_START_CMD='ssh dohertj2@10.100.0.35 "docker pause otopcua-harness-ldap-1"' -/// export GLAUTH_OUTAGE_STOP_CMD='ssh dohertj2@10.100.0.35 "docker unpause otopcua-harness-ldap-1"' -/// dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests --filter "FullyQualifiedName~LdapGlauthOutageLive" -/// -/// -[Trait("Category", "Integration")] -[Trait("Category", "LiveLdap")] -public sealed class LdapGlauthOutageLiveTests -{ - private const string HostEnvVar = "GLAUTH_LIVE_HOST"; - private const string PortEnvVar = "GLAUTH_LIVE_PORT"; - private const string StartCmdEnvVar = "GLAUTH_OUTAGE_START_CMD"; - private const string StopCmdEnvVar = "GLAUTH_OUTAGE_STOP_CMD"; - - private const int OutageThreshold = 3; - private const int CooldownSeconds = 2; - private static readonly TimeSpan AuthTimeout = TimeSpan.FromMilliseconds(2_500); - - /// - /// Healthy real binds succeed; pausing GLAuth makes consecutive binds fail bounded (never a - /// multi-second SDK stall) and opens the circuit into a sub-second fast-deny; unpausing + - /// cooldown lets a half-open probe close the circuit and binds succeed again. - /// - [Fact] - public async Task Real_glauth_outage_opens_circuit_then_recovers_on_unpause() - { - var host = Environment.GetEnvironmentVariable(HostEnvVar); - if (string.IsNullOrWhiteSpace(host)) - Assert.Skip($"Set {HostEnvVar} (+ {StartCmdEnvVar}/{StopCmdEnvVar}) to run the live GLAuth outage cycle."); - var port = int.TryParse(Environment.GetEnvironmentVariable(PortEnvVar), out var p) ? p : 3894; - var startCmd = Environment.GetEnvironmentVariable(StartCmdEnvVar); - var stopCmd = Environment.GetEnvironmentVariable(StopCmdEnvVar); - if (string.IsNullOrWhiteSpace(startCmd) || string.IsNullOrWhiteSpace(stopCmd)) - Assert.Skip($"Set both {StartCmdEnvVar} and {StopCmdEnvVar} (docker pause/unpause) to run this destructive live test."); - - var ct = TestContext.Current.CancellationToken; - var options = new LdapOptions - { - Enabled = true, - DevStubMode = false, - Server = host!, - Port = port, - Transport = LdapTransport.None, - AllowInsecure = true, - SearchBase = "dc=zb,dc=local", - ServiceAccountDn = "cn=serviceaccount,dc=zb,dc=local", - ServiceAccountPassword = "serviceaccount123", - ConnectionTimeoutMs = 2_000, - AuthTimeoutMs = (int)AuthTimeout.TotalMilliseconds, - OutageFailureThreshold = OutageThreshold, - OutageCooldownSeconds = CooldownSeconds, - }; - var authenticator = BuildAuthenticator(options); - - // ── 1. Healthy directory: real binds succeed, wrong creds deny (user-side, not an outage). ── - var alice = await authenticator.AuthenticateUserNameAsync("alice", "alice123", ct); - alice.Success.ShouldBeTrue("alice should bind against the live GLAuth"); - alice.Roles.ShouldNotBeEmpty("a healthy bind returns alice's mapped group roles"); - - var bob = await authenticator.AuthenticateUserNameAsync("bob", "bob123", ct); - bob.Success.ShouldBeTrue("bob should bind against the live GLAuth"); - - var wrong = await authenticator.AuthenticateUserNameAsync("alice", "wrong-password", ct); - wrong.Success.ShouldBeFalse("a wrong password is a user-side deny"); - - // ── 2. Outage: pause GLAuth. Consecutive binds fail BOUNDED, then the circuit fast-denies. ── - var paused = false; - try - { - await RunShellCommandAsync(startCmd!, ct); - paused = true; - - for (var i = 0; i < OutageThreshold; i++) - { - var sw = Stopwatch.StartNew(); - var outage = await authenticator.AuthenticateUserNameAsync("alice", "alice123", ct); - sw.Stop(); - - outage.Success.ShouldBeFalse($"bind #{i + 1} during the outage must fail closed"); - // The whole flow is wall-clock bounded — never the SDK's default ~10-20 s stall. - sw.Elapsed.ShouldBeLessThan(AuthTimeout + TimeSpan.FromSeconds(2), - "the authenticator must bound a hung directory call, not stall on it"); - } - - // Circuit is now open — the next call fast-denies WITHOUT a directory round-trip (sub-second). - var blockedSw = Stopwatch.StartNew(); - var blocked = await authenticator.AuthenticateUserNameAsync("alice", "alice123", ct); - blockedSw.Stop(); - - blocked.Success.ShouldBeFalse(); - blocked.Error.ShouldNotBeNull(); - blocked.Error!.ShouldContain("unavailable", Case.Insensitive, - "an open outage circuit fast-denies 'backend unavailable'"); - blockedSw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(1), - "an open circuit fast-denies without a directory call"); - } - finally - { - if (paused) - await RunShellCommandAsync(stopCmd!, CancellationToken.None); // ALWAYS restore GLAuth - } - - // ── 3. Recovery: after the cooldown the half-open probe reaches the (restored) directory. ── - await Task.Delay(TimeSpan.FromSeconds(CooldownSeconds) + TimeSpan.FromSeconds(1), ct); - - var recovered = false; - var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(30); - while (DateTime.UtcNow < deadline) - { - var probe = await authenticator.AuthenticateUserNameAsync("alice", "alice123", ct); - if (probe.Success) { recovered = true; break; } - await Task.Delay(TimeSpan.FromMilliseconds(500), ct); - } - recovered.ShouldBeTrue("the circuit must half-open and close once GLAuth is unpaused"); - } - - private static LdapOpcUaUserAuthenticator BuildAuthenticator(LdapOptions options) - { - var ldap = new OtOpcUaLdapAuthService(Options.Create(options), NullLogger.Instance); - return new LdapOpcUaUserAuthenticator( - ldap, ScopeFactoryWith(new PassthroughMapper()), Options.Create(options), - NullLogger.Instance); - } - - /// Builds an whose scopes resolve the supplied mapper. - private static IServiceScopeFactory ScopeFactoryWith(IGroupRoleMapper mapper) - { - var services = new ServiceCollection(); - services.AddScoped(_ => mapper); - return services.BuildServiceProvider().GetRequiredService(); - } - - /// Runs an SSH/docker one-liner through the login shell (verbatim, as in the S7 outage gate). - private static async Task RunShellCommandAsync(string command, CancellationToken ct) - { - using var proc = new Process - { - StartInfo = new ProcessStartInfo("/bin/sh", $"-c \"{command.Replace("\"", "\\\"")}\"") - { - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - }, - }; - proc.Start(); - await proc.WaitForExitAsync(ct); - proc.ExitCode.ShouldBe(0, $"outage command failed: {await proc.StandardError.ReadToEndAsync(ct)}"); - await Task.Delay(TimeSpan.FromSeconds(1), ct); // let the pause/unpause take effect - } - - /// Passes LDAP groups straight through as roles — the live gate asserts on the bind, not the mapping. - private sealed class PassthroughMapper : IGroupRoleMapper - { - public Task> MapAsync(IReadOnlyList groups, CancellationToken ct) - => Task.FromResult(new GroupRoleMapping(groups, Scope: null)); - } -} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOpcUaUserAuthenticatorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOpcUaUserAuthenticatorTests.cs deleted file mode 100644 index b076de93..00000000 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOpcUaUserAuthenticatorTests.cs +++ /dev/null @@ -1,152 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; -using Shouldly; -using Xunit; -using ZB.MOM.WW.Auth.Abstractions.Roles; -using ZB.MOM.WW.OtOpcUa.Host.OpcUa; -using ZB.MOM.WW.OtOpcUa.Security.Ldap; - -namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; - -/// -/// Verifies translates app -/// outcomes into OpcUaUserAuthResult, resolves roles from the directory's groups -/// through the shared seam (Task 1.2), unions any pre-resolved -/// roles (the DevStub Administrator grant) in, and turns LDAP backend exceptions into a denial rather -/// than letting them escape into the SDK. -/// -public sealed class LdapOpcUaUserAuthenticatorTests -{ - /// On success the data-plane authenticator resolves roles via the mapper from the - /// returned Groups — not from the auth result's Roles field — and grants identity. - [Fact] - public async Task Authenticate_LDAP_success_resolves_roles_via_mapper_from_groups() - { - // Library-style result: groups present, Roles empty (the real path). The mapper maps the - // group "configeditor" -> "Designer" (canonical, Task 1.7). - var ldap = new FakeLdap(new LdapAuthResult(true, "Alice", "alice", new[] { "configeditor" }, Array.Empty(), null)); - var mapper = new FakeMapper(g => g.Select(x => x == "configeditor" ? "Designer" : x).ToArray()); - var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), Options.Create(new LdapOptions()), NullLogger.Instance); - - var result = await sut.AuthenticateUserNameAsync("alice", "secret", CancellationToken.None); - - result.Success.ShouldBeTrue(); - result.DisplayName.ShouldBe("Alice"); - result.Roles.ShouldBe(new[] { "Designer" }); - } - - /// The DevStub pre-resolved roles (Administrator) survive the move to the mapper: they are - /// unioned with the mapper output so the dev grant still reaches the OPC UA session. - [Fact] - public async Task Authenticate_devstub_preresolved_roles_are_unioned_with_mapper() - { - // DevStub-shaped result: group "dev", pre-resolved role "Administrator". Mapper maps "dev" to - // nothing, so the union is exactly {Administrator}. - var ldap = new FakeLdap(new LdapAuthResult(true, "dev", "dev", new[] { "dev" }, new[] { "Administrator" }, null)); - var mapper = new FakeMapper(_ => Array.Empty()); - var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), Options.Create(new LdapOptions()), NullLogger.Instance); - - var result = await sut.AuthenticateUserNameAsync("dev", "anything", CancellationToken.None); - - result.Success.ShouldBeTrue(); - result.Roles.ShouldBe(new[] { "Administrator" }); - } - - /// A mapper fault (e.g. DB outage) must not deny an authenticated session — it falls - /// back to the pre-resolved roles, matching the login endpoint's behaviour. - [Fact] - public async Task Authenticate_mapper_fault_falls_back_to_preresolved_roles() - { - var ldap = new FakeLdap(new LdapAuthResult(true, "dev", "dev", new[] { "dev" }, new[] { "Administrator" }, null)); - var mapper = new FakeMapper(_ => throw new InvalidOperationException("DB down")); - var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), Options.Create(new LdapOptions()), NullLogger.Instance); - - var result = await sut.AuthenticateUserNameAsync("dev", "anything", CancellationToken.None); - - result.Success.ShouldBeTrue(); - result.Roles.ShouldBe(new[] { "Administrator" }); - } - - /// Verifies that LDAP authentication failure returns Deny result with error text. - [Fact] - public async Task Authenticate_LDAP_failure_returns_Deny_with_error_text() - { - var ldap = new FakeLdap(new LdapAuthResult(false, null, "mallory", Array.Empty(), Array.Empty(), "Invalid username or password")); - var mapper = new FakeMapper(g => g.ToArray()); - var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), Options.Create(new LdapOptions()), NullLogger.Instance); - - var result = await sut.AuthenticateUserNameAsync("mallory", "wrong", CancellationToken.None); - - result.Success.ShouldBeFalse(); - result.Error.ShouldBe("Invalid username or password"); - } - - /// Verifies that LDAP exceptions are converted to backend error denial results. - [Fact] - public async Task Authenticate_LDAP_exception_returns_backend_error_denial() - { - var ldap = new FakeLdap(_ => throw new InvalidOperationException("LDAP unreachable")); - var mapper = new FakeMapper(g => g.ToArray()); - var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), Options.Create(new LdapOptions()), NullLogger.Instance); - - var result = await sut.AuthenticateUserNameAsync("anyone", "x", CancellationToken.None); - - result.Success.ShouldBeFalse(); - result.Error.ShouldNotBeNull(); - result.Error.ShouldContain("backend"); - } - - /// Verifies that authentication falls back to username when LDAP omits display name. - [Fact] - public async Task Authenticate_falls_back_to_username_when_LDAP_omits_display_name() - { - var ldap = new FakeLdap(new LdapAuthResult(true, null, "alice", new[] { "ReadOnly" }, Array.Empty(), null)); - var mapper = new FakeMapper(g => g.ToArray()); - var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), Options.Create(new LdapOptions()), NullLogger.Instance); - - var result = await sut.AuthenticateUserNameAsync("alice", "x", CancellationToken.None); - - result.Success.ShouldBeTrue(); - result.DisplayName.ShouldBe("alice"); - } - - /// Builds an IServiceScopeFactory whose scopes resolve the supplied mapper. - private static IServiceScopeFactory ScopeFactoryWith(IGroupRoleMapper mapper) - { - var services = new ServiceCollection(); - services.AddScoped(_ => mapper); - return services.BuildServiceProvider().GetRequiredService(); - } - - /// Test fake implementation of LDAP authentication service. - private sealed class FakeLdap : ILdapAuthService - { - private readonly Func _handler; - - /// Initializes the fake with a fixed result that ignores the username. - /// The result to return for any authentication attempt. - public FakeLdap(LdapAuthResult fixed_) => _handler = _ => fixed_; - - /// Initializes the fake with a handler function for custom results. - /// The handler to invoke with the username to produce a result. - public FakeLdap(Func handler) => _handler = handler; - - /// Authenticates a user asynchronously via the handler function. - /// The username to authenticate. - /// The password (ignored by the fake). - /// Cancellation token for the operation. - public Task AuthenticateAsync(string username, string password, CancellationToken ct = default) - => Task.FromResult(_handler(username)); - } - - /// Test fake group→role mapper driven by a delegate over the supplied groups. - private sealed class FakeMapper(Func, IReadOnlyList> map) : IGroupRoleMapper - { - /// Maps groups to roles via the configured delegate; Scope is always null. - /// The LDAP groups to map. - /// The cancellation token. - public Task> MapAsync(IReadOnlyList groups, CancellationToken ct) - => Task.FromResult(new GroupRoleMapping(map(groups), Scope: null)); - } -} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOptionsBindingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOptionsBindingTests.cs index d5f4d88c..674fc79f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOptionsBindingTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOptionsBindingTests.cs @@ -63,19 +63,17 @@ public sealed class LdapOptionsBindingTests } /// - /// The five S2 resilience keys bind from the real Security:Ldap section (03/S2). + /// ConnectionTimeoutMs binds from the real Security:Ldap section. The other four S2 + /// resilience keys were removed with LdapOpcUaUserAuthenticator in #520 — they bounded an + /// LDAP bind running inline on OPC UA session activation, and no remaining consumer does that. /// [Fact] - public void Binding_reads_resilience_keys() + public void Binding_reads_the_connection_timeout_key() { var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["Security:Ldap:ConnectionTimeoutMs"] = "7000", - ["Security:Ldap:AuthTimeoutMs"] = "12000", - ["Security:Ldap:MaxConcurrentAuthentications"] = "5", - ["Security:Ldap:OutageFailureThreshold"] = "4", - ["Security:Ldap:OutageCooldownSeconds"] = "20", }) .Build(); @@ -83,23 +81,13 @@ public sealed class LdapOptionsBindingTests options.ShouldNotBeNull(); options.ConnectionTimeoutMs.ShouldBe(7000); - options.AuthTimeoutMs.ShouldBe(12000); - options.MaxConcurrentAuthentications.ShouldBe(5); - options.OutageFailureThreshold.ShouldBe(4); - options.OutageCooldownSeconds.ShouldBe(20); } - /// The resilience keys carry the documented defaults when absent from config. + /// The connection timeout carries its documented default when absent from config. [Fact] - public void Resilience_key_defaults_hold_when_absent() + public void Connection_timeout_default_holds_when_absent() { - var options = new LdapOptions(); - - options.ConnectionTimeoutMs.ShouldBe(10000); - options.AuthTimeoutMs.ShouldBe(15000); - options.MaxConcurrentAuthentications.ShouldBe(8); - options.OutageFailureThreshold.ShouldBe(3); - options.OutageCooldownSeconds.ShouldBe(15); + new LdapOptions().ConnectionTimeoutMs.ShouldBe(10000); } /// ConnectionTimeoutMs is projected onto the shared-library options so the diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOptionsValidatorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOptionsValidatorTests.cs index 9c5ff2b9..2f84f5e9 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOptionsValidatorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOptionsValidatorTests.cs @@ -220,79 +220,6 @@ public sealed class LdapOptionsValidatorTests Sut.Validate(null, options).Failed.ShouldBeTrue(); } - /// Enabled with a non-positive AuthTimeoutMs fails validation (03/S2). - [Fact] - public void Enabled_with_nonpositive_AuthTimeoutMs_fails() - { - var options = new LdapOptions - { - Enabled = true, - Server = "ldap", - SearchBase = "dc=x", - Port = 389, - Transport = LdapTransport.Ldaps, - AuthTimeoutMs = 0, - }; - - Sut.Validate(null, options).Failed.ShouldBeTrue(); - } - - /// Enabled with a non-positive MaxConcurrentAuthentications fails validation (03/S2). - [Fact] - public void Enabled_with_nonpositive_MaxConcurrentAuthentications_fails() - { - var options = new LdapOptions - { - Enabled = true, - Server = "ldap", - SearchBase = "dc=x", - Port = 389, - Transport = LdapTransport.Ldaps, - MaxConcurrentAuthentications = 0, - }; - - Sut.Validate(null, options).Failed.ShouldBeTrue(); - } - - /// A positive OutageFailureThreshold with a zero OutageCooldownSeconds fails — - /// an open circuit with no cooldown would probe on every call (03/S2). - [Fact] - public void OutageFailureThreshold_positive_with_zero_cooldown_fails() - { - var options = new LdapOptions - { - Enabled = true, - Server = "ldap", - SearchBase = "dc=x", - Port = 389, - Transport = LdapTransport.Ldaps, - OutageFailureThreshold = 3, - OutageCooldownSeconds = 0, - }; - - Sut.Validate(null, options).Failed.ShouldBeTrue(); - } - - /// A zero OutageFailureThreshold (circuit disabled) passes regardless of the - /// cooldown value (03/S2). - [Fact] - public void OutageFailureThreshold_zero_disables_circuit_and_passes() - { - var options = new LdapOptions - { - Enabled = true, - Server = "ldap", - SearchBase = "dc=x", - Port = 389, - Transport = LdapTransport.Ldaps, - OutageFailureThreshold = 0, - OutageCooldownSeconds = 0, - }; - - Sut.Validate(null, options).Succeeded.ShouldBeTrue(); - } - - /// Enabled with port 0 reports the port-range failure using the shared primitive wording. [Fact] public void Enabled_with_zero_port_fails() {