feat(security): LDAP auth timeout/concurrency/outage-circuit options; project ConnectionTimeoutMs into the shared library (03/S2)
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
"planPath": "archreview/plans/R2-08-ldap-historyread-async-plan.md",
|
||||
"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": "pending", "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-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"] },
|
||||
|
||||
@@ -47,5 +47,18 @@ public sealed class LdapOptionsValidator : OptionsValidatorBase<LdapOptions>
|
||||
builder.RequireThat(
|
||||
!(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.
|
||||
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).");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,49 @@ public sealed class LdapOptions
|
||||
/// </summary>
|
||||
public Dictionary<string, string> GroupToRole { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Bounds (in milliseconds) the shared-library directory client's socket connect AND each
|
||||
/// per-operation search inside a single bind flow. Projected into the library's
|
||||
/// <see cref="LibLdapOptions.ConnectionTimeoutMs"/> via <see cref="ToLibraryOptions"/>. 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
|
||||
/// <see cref="AuthTimeoutMs"/> is the backstop (03/S2).
|
||||
/// </summary>
|
||||
public int ConnectionTimeoutMs { get; set; } = 10_000;
|
||||
|
||||
/// <summary>
|
||||
/// Hard whole-flow wall-clock bound (in milliseconds) the OPC UA data-plane authenticator
|
||||
/// (<c>LdapOpcUaUserAuthenticator</c>) applies across the ENTIRE authenticate flow — bind plus
|
||||
/// role-map — so the OPC UA SDK's synchronous impersonation callback (raised under a
|
||||
/// <c>SessionManager</c>-wide event lock) can never be wedged by a slow / hung directory.
|
||||
/// Deliberately > <see cref="ConnectionTimeoutMs"/> (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).
|
||||
/// </summary>
|
||||
public int AuthTimeoutMs { get; set; } = 15_000;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public int MaxConcurrentAuthentications { get; set; } = 8;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="OutageCooldownSeconds"/> without touching LDAP. Any success
|
||||
/// or user-side (credential) deny resets the counter. <c>0</c> disables the circuit (03/S2).
|
||||
/// Default 3.
|
||||
/// </summary>
|
||||
public int OutageFailureThreshold { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// How long (in seconds) the outage circuit stays open before a half-open probe is allowed
|
||||
/// through. Only meaningful when <see cref="OutageFailureThreshold"/> > 0. Default 15 (03/S2).
|
||||
/// </summary>
|
||||
public int OutageCooldownSeconds { get; set; } = 15;
|
||||
|
||||
/// <summary>
|
||||
/// Projects the wire fields onto the shared <c>ZB.MOM.WW.Auth.Ldap</c>
|
||||
/// <see cref="LibLdapOptions"/> the directory client consumes. App-only concerns
|
||||
@@ -104,5 +147,6 @@ public sealed class LdapOptions
|
||||
UserNameAttribute = UserNameAttribute,
|
||||
DisplayNameAttribute = DisplayNameAttribute,
|
||||
GroupAttribute = GroupAttribute,
|
||||
ConnectionTimeoutMs = ConnectionTimeoutMs,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -61,6 +61,56 @@ public sealed class LdapOptionsBindingTests
|
||||
|
||||
options.DevStubMode.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The five S2 resilience keys bind from the real <c>Security:Ldap</c> section (03/S2).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Binding_reads_resilience_keys()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Security:Ldap:ConnectionTimeoutMs"] = "7000",
|
||||
["Security:Ldap:AuthTimeoutMs"] = "12000",
|
||||
["Security:Ldap:MaxConcurrentAuthentications"] = "5",
|
||||
["Security:Ldap:OutageFailureThreshold"] = "4",
|
||||
["Security:Ldap:OutageCooldownSeconds"] = "20",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var options = configuration.GetSection(LdapOptions.SectionName).Get<LdapOptions>();
|
||||
|
||||
options.ShouldNotBeNull();
|
||||
options.ConnectionTimeoutMs.ShouldBe(7000);
|
||||
options.AuthTimeoutMs.ShouldBe(12000);
|
||||
options.MaxConcurrentAuthentications.ShouldBe(5);
|
||||
options.OutageFailureThreshold.ShouldBe(4);
|
||||
options.OutageCooldownSeconds.ShouldBe(20);
|
||||
}
|
||||
|
||||
/// <summary>The resilience keys carry the documented defaults when absent from config.</summary>
|
||||
[Fact]
|
||||
public void Resilience_key_defaults_hold_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);
|
||||
}
|
||||
|
||||
/// <summary><c>ConnectionTimeoutMs</c> is projected onto the shared-library options so the
|
||||
/// socket-connect + per-search timeout is bounded inside the directory client.</summary>
|
||||
[Fact]
|
||||
public void ToLibraryOptions_projects_ConnectionTimeoutMs()
|
||||
{
|
||||
var options = new LdapOptions { ConnectionTimeoutMs = 4321 };
|
||||
|
||||
options.ToLibraryOptions().ConnectionTimeoutMs.ShouldBe(4321);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -203,6 +203,95 @@ public sealed class LdapOptionsValidatorTests
|
||||
result.Failures.ShouldContain("Ldap:SearchBase is required when LDAP login is enabled.");
|
||||
}
|
||||
|
||||
/// <summary>Enabled with a non-positive <c>ConnectionTimeoutMs</c> fails validation (03/S2).</summary>
|
||||
[Fact]
|
||||
public void Enabled_with_nonpositive_ConnectionTimeoutMs_fails()
|
||||
{
|
||||
var options = new LdapOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Server = "ldap",
|
||||
SearchBase = "dc=x",
|
||||
Port = 389,
|
||||
Transport = LdapTransport.Ldaps,
|
||||
ConnectionTimeoutMs = 0,
|
||||
};
|
||||
|
||||
Sut.Validate(null, options).Failed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Enabled with a non-positive <c>AuthTimeoutMs</c> fails validation (03/S2).</summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>Enabled with a non-positive <c>MaxConcurrentAuthentications</c> fails validation (03/S2).</summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>A positive <c>OutageFailureThreshold</c> with a zero <c>OutageCooldownSeconds</c> fails —
|
||||
/// an open circuit with no cooldown would probe on every call (03/S2).</summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>A zero <c>OutageFailureThreshold</c> (circuit disabled) passes regardless of the
|
||||
/// cooldown value (03/S2).</summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>Enabled with port 0 reports the port-range failure using the shared primitive wording.</summary>
|
||||
[Fact]
|
||||
public void Enabled_with_zero_port_fails()
|
||||
|
||||
Reference in New Issue
Block a user