feat(security): LdapAuthResult.IsSystemFailure distinguishes directory outage from credential deny (03/S2)
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
"tasks": [
|
"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-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-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-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-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"] },
|
{ "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"] },
|
||||||
|
|||||||
@@ -7,4 +7,15 @@ public sealed record LdapAuthResult(
|
|||||||
string? Username,
|
string? Username,
|
||||||
IReadOnlyList<string> Groups,
|
IReadOnlyList<string> Groups,
|
||||||
IReadOnlyList<string> Roles,
|
IReadOnlyList<string> Roles,
|
||||||
string? Error);
|
string? Error)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// <c>true</c> 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 <c>false</c>
|
||||||
|
/// on success and on user-side denies; set only on the delegated real path for the library's
|
||||||
|
/// directory-unreachable bucket.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsSystemFailure { get; init; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -113,7 +113,13 @@ public sealed class OtOpcUaLdapAuthService : ILdapAuthService
|
|||||||
if (result.Succeeded)
|
if (result.Succeeded)
|
||||||
return new(true, result.DisplayName, result.Username, result.Groups, [], null);
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Folds a structured library failure code into the app's user-facing error text.</summary>
|
/// <summary>Folds a structured library failure code into the app's user-facing error text.</summary>
|
||||||
|
|||||||
@@ -121,6 +121,56 @@ public sealed class OtOpcUaLdapAuthServiceTests
|
|||||||
inner.Called.ShouldBeFalse();
|
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>
|
/// <summary>Records whether the library service was invoked and returns a canned result.</summary>
|
||||||
private sealed class RecordingLibService(LibLdapAuthResult result) : LibILdapAuthService
|
private sealed class RecordingLibService(LibLdapAuthResult result) : LibILdapAuthService
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user