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 d5df7283..08933cfd 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 @@ -3,7 +3,7 @@ "tasks": [ { "id": "R2-08-T1", "subject": "S2 stall-repro test (RED): slow LDAP backend must deny within AuthTimeout — fails on current code", "status": "completed", "blockedBy": [] }, { "id": "R2-08-T2", "subject": "LdapOptions: ConnectionTimeoutMs/AuthTimeoutMs/MaxConcurrentAuthentications/OutageFailureThreshold/OutageCooldownSeconds + ToLibraryOptions projection + validator rules", "status": "completed", "blockedBy": [] }, - { "id": "R2-08-T3", "subject": "LdapAuthResult.IsSystemFailure seam + OtOpcUaLdapAuthService.Adapt mapping (ServiceAccountBindFailed => system-side)", "status": "pending", "blockedBy": [] }, + { "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": "pending", "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": "pending", "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"] }, diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapAuthResult.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapAuthResult.cs index 4ca801e6..a91cae04 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapAuthResult.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapAuthResult.cs @@ -7,4 +7,15 @@ public sealed record LdapAuthResult( string? Username, IReadOnlyList Groups, IReadOnlyList Roles, - string? Error); + string? Error) +{ + /// + /// true when this failure is a SYSTEM-side directory fault (the directory is unreachable or + /// the service account is misconfigured) rather than a user-credential deny. Feeds the OPC UA + /// data-plane authenticator's outage circuit (03/S2): a run of consecutive system-side failures + /// opens the circuit and fails fast, whereas user-side denies never trip it. Always false + /// on success and on user-side denies; set only on the delegated real path for the library's + /// directory-unreachable bucket. + /// + public bool IsSystemFailure { get; init; } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaLdapAuthService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaLdapAuthService.cs index 373ee4f4..6ddf8bb5 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaLdapAuthService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaLdapAuthService.cs @@ -113,7 +113,13 @@ public sealed class OtOpcUaLdapAuthService : ILdapAuthService if (result.Succeeded) return new(true, result.DisplayName, result.Username, result.Groups, [], null); - return new(false, null, username, [], [], FailureToError(result.Failure)); + // ServiceAccountBindFailed is the library's directory-unreachable / service-account-misconfigured + // bucket (its enum has no dedicated DirectoryUnavailable) — flag it as system-side so the OPC UA + // authenticator's outage circuit can distinguish an outage from a user-credential deny (03/S2). + return new(false, null, username, [], [], FailureToError(result.Failure)) + { + IsSystemFailure = result.Failure is LdapAuthFailure.ServiceAccountBindFailed, + }; } /// Folds a structured library failure code into the app's user-facing error text. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/OtOpcUaLdapAuthServiceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/OtOpcUaLdapAuthServiceTests.cs index d0b9354b..3146ba8b 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/OtOpcUaLdapAuthServiceTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/OtOpcUaLdapAuthServiceTests.cs @@ -121,6 +121,56 @@ public sealed class OtOpcUaLdapAuthServiceTests inner.Called.ShouldBeFalse(); } + /// A system-side library failure (ServiceAccountBindFailed — the directory-unreachable + /// bucket) adapts to IsSystemFailure == true so the OPC UA authenticator's outage circuit can + /// distinguish it from a user-credential deny (03/S2). + [Fact] + public async Task ServiceAccountBindFailed_adapts_to_IsSystemFailure_true() + { + var inner = new RecordingLibService(LibLdapAuthResult.Fail(LdapAuthFailure.ServiceAccountBindFailed)); + var sut = Build( + new LdapOptions { Enabled = true, DevStubMode = false, Transport = LdapTransport.Ldaps }, + inner); + + var result = await sut.AuthenticateAsync("alice", "secret", CancellationToken.None); + + result.Success.ShouldBeFalse(); + result.IsSystemFailure.ShouldBeTrue(); + } + + /// User-side library failures and success adapt to IsSystemFailure == false — they must + /// NOT trip the outage circuit (03/S2). + [Theory] + [InlineData(LdapAuthFailure.BadCredentials)] + [InlineData(LdapAuthFailure.UserNotFound)] + public async Task User_side_failures_adapt_to_IsSystemFailure_false(LdapAuthFailure failure) + { + var inner = new RecordingLibService(LibLdapAuthResult.Fail(failure)); + var sut = Build( + new LdapOptions { Enabled = true, DevStubMode = false, Transport = LdapTransport.Ldaps }, + inner); + + var result = await sut.AuthenticateAsync("alice", "wrong", CancellationToken.None); + + result.Success.ShouldBeFalse(); + result.IsSystemFailure.ShouldBeFalse(); + } + + /// A successful bind is never a system failure. + [Fact] + public async Task Success_is_not_a_system_failure() + { + var inner = new RecordingLibService(LibLdapAuthResult.Success("alice", "Alice", new[] { "ReadOnly" })); + var sut = Build( + new LdapOptions { Enabled = true, DevStubMode = false, Transport = LdapTransport.Ldaps }, + inner); + + var result = await sut.AuthenticateAsync("alice", "secret", CancellationToken.None); + + result.Success.ShouldBeTrue(); + result.IsSystemFailure.ShouldBeFalse(); + } + /// Records whether the library service was invoked and returns a canned result. private sealed class RecordingLibService(LibLdapAuthResult result) : LibILdapAuthService {