feat(security): LdapAuthResult.IsSystemFailure distinguishes directory outage from credential deny (03/S2)

This commit is contained in:
Joseph Doherty
2026-07-13 11:39:35 -04:00
parent 09faa05f06
commit 0d5cb89d48
4 changed files with 70 additions and 3 deletions
@@ -121,6 +121,56 @@ public sealed class OtOpcUaLdapAuthServiceTests
inner.Called.ShouldBeFalse();
}
/// <summary>A system-side library failure (<c>ServiceAccountBindFailed</c> — the directory-unreachable
/// bucket) adapts to <c>IsSystemFailure == true</c> so the OPC UA authenticator's outage circuit can
/// distinguish it from a user-credential deny (03/S2).</summary>
[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();
}
/// <summary>User-side library failures and success adapt to <c>IsSystemFailure == false</c> — they must
/// NOT trip the outage circuit (03/S2).</summary>
[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();
}
/// <summary>A successful bind is never a system failure.</summary>
[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();
}
/// <summary>Records whether the library service was invoked and returns a canned result.</summary>
private sealed class RecordingLibService(LibLdapAuthResult result) : LibILdapAuthService
{