diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs index ea9adfe8..906e30bd 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs @@ -1,4 +1,5 @@ using ZB.MOM.WW.Auth.ApiKeys.DependencyInjection; +using ZB.MOM.WW.Auth.AspNetCore; using ZB.MOM.WW.Health; using ZB.MOM.WW.Health.Akka; using ZB.MOM.WW.Health.EntityFrameworkCore; @@ -104,7 +105,16 @@ try builder.Services.AddSiteCallAudit(); builder.Services.AddTemplateEngine(); builder.Services.AddDeploymentManager(); - builder.Services.AddSecurity(builder.Configuration); + // Host is the composition root and owns config-coupled wiring: register the + // shared LDAP auth (binds LdapOptions + IValidateOptions with + // ValidateOnStart + ILdapAuthService singleton) here, then AddSecurity() for the + // config-free remainder. AddSecurity is a component library and takes no + // IConfiguration (Options pattern only). Behaviour-preserving: identical + // registrations to the previous AddSecurity(builder.Configuration) call. + builder.Services.AddZbLdapAuth( + builder.Configuration, + ZB.MOM.WW.ScadaBridge.Security.ServiceCollectionExtensions.LdapSectionPath); + builder.Services.AddSecurity(); builder.Services.AddCentralUI(); builder.Services.AddInboundAPI(); diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs index 5f355251..0214a196 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs @@ -1,5 +1,4 @@ using Microsoft.AspNetCore.Authentication.Cookies; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -15,29 +14,37 @@ public static class ServiceCollectionExtensions /// under the existing ScadaBridge:Security section as a Ldap sub-section /// (Task 1.4 config rename) so the non-LDAP fields stay /// where they are while the LDAP connection settings bind to the shared library. + /// The Host composition root references this constant to register the shared LDAP + /// auth (AddZbLdapAuth) before calling . /// public const string LdapSectionPath = "ScadaBridge:Security:Ldap"; /// - /// Registers LDAP authentication (shared ZB.MOM.WW.Auth.Ldap), JWT token service, - /// role mapper, cookie authentication, and authorization policies. + /// Registers the JWT token service, role mapper, cookie authentication, and + /// authorization policies. This is a component library and therefore takes no + /// IConfiguration (Options pattern only). /// + /// + /// LDAP authentication (shared ZB.MOM.WW.Auth.Ldap via AddZbLdapAuth) is + /// registered by the Host composition root — which calls AddZbLdapAuth with the + /// nested before this method — because that + /// registration is config-coupled (it binds LdapOptions from + /// IConfiguration) and component libraries must not accept IConfiguration. + /// /// The service collection to register into. - /// - /// Application configuration, read for the nested LDAP - /// options bound + validated by AddZbLdapAuth. - /// - public static IServiceCollection AddSecurity(this IServiceCollection services, IConfiguration configuration) + public static IServiceCollection AddSecurity(this IServiceCollection services) { - // Task 1.2 cutover: replace ScadaBridge's bespoke LdapAuthService with the shared - // ZB.MOM.WW.Auth.Ldap implementation (ScadaBridge was the donor for its hardened - // bind-then-search / escaping / fail-closed semantics, so this is a behaviour- - // equivalent re-point). AddZbLdapAuth binds LdapOptions from the nested Ldap - // sub-section, registers IValidateOptions with ValidateOnStart (so a - // misconfigured directory fails fast at boot — superseding the old - // SecurityOptionsValidator LDAP checks), and registers ILdapAuthService as a - // stateless singleton. - services.AddZbLdapAuth(configuration, LdapSectionPath); + // Task 1.2 cutover: ScadaBridge's bespoke LdapAuthService was replaced by the + // shared ZB.MOM.WW.Auth.Ldap implementation (ScadaBridge was the donor for its + // hardened bind-then-search / escaping / fail-closed semantics, so this was a + // behaviour-equivalent re-point). That registration — AddZbLdapAuth, which binds + // LdapOptions from the nested Ldap sub-section, registers IValidateOptions + // with ValidateOnStart (so a misconfigured directory fails fast at boot — + // superseding the old SecurityOptionsValidator LDAP checks), and registers + // ILdapAuthService as a stateless singleton — now lives at the Host composition + // root (which calls AddZbLdapAuth(configuration, LdapSectionPath) immediately + // before AddSecurity()), because it is config-coupled and this is a component + // library. services.AddScoped(); services.AddScoped(); diff --git a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/SecurityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/SecurityTests.cs index 48bf60c6..3eabd496 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/SecurityTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/SecurityTests.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ZB.MOM.WW.Auth.Abstractions.Ldap; +using ZB.MOM.WW.Auth.AspNetCore; using ZB.MOM.WW.Auth.Ldap; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; @@ -441,7 +442,7 @@ public class SecurityReviewRegressionTests var services = new ServiceCollection(); services.AddLogging(); services.AddDataProtection(); - services.AddSecurity(EmptyConfig()); + services.AddSecurity(); using var provider = services.BuildServiceProvider(); var cookieOptions = provider @@ -454,16 +455,6 @@ public class SecurityReviewRegressionTests Assert.True(cookieOptions.Cookie.HttpOnly); } - /// - /// Task 1.2: an empty - /// for AddSecurity(config). The cookie PostConfigure under test reads only the - /// non-LDAP fields (idle timeout / HTTPS-cookie policy), - /// and the library's LdapOptions ValidateOnStart only fires at host start (not on - /// BuildServiceProvider), so no LDAP config is needed to resolve the cookie wiring. - /// - private static Microsoft.Extensions.Configuration.IConfiguration EmptyConfig() => - new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(); - // --- CentralUI-005: cookie auth must use a sliding session window --- // Documented policy (CLAUDE.md Security & Auth): sliding refresh with a // 30-minute idle timeout. The cookie middleware must enable SlidingExpiration @@ -475,7 +466,7 @@ public class SecurityReviewRegressionTests var services = new ServiceCollection(); services.AddLogging(); services.AddDataProtection(); - services.AddSecurity(EmptyConfig()); + services.AddSecurity(); using var provider = services.BuildServiceProvider(); var cookieOptions = provider @@ -491,7 +482,7 @@ public class SecurityReviewRegressionTests var services = new ServiceCollection(); services.AddLogging(); services.AddDataProtection(); - services.AddSecurity(EmptyConfig()); + services.AddSecurity(); // The idle timeout drives the cookie's expiry window. services.Configure(o => o.IdleTimeoutMinutes = 30); @@ -509,7 +500,7 @@ public class SecurityReviewRegressionTests var services = new ServiceCollection(); services.AddLogging(); services.AddDataProtection(); - services.AddSecurity(EmptyConfig()); + services.AddSecurity(); services.Configure(o => o.IdleTimeoutMinutes = 45); using var provider = services.BuildServiceProvider(); @@ -1179,14 +1170,27 @@ public class SecurityOptionsValidatorTests Assert.Contains(nameof(LdapOptions.ServiceAccountDn), result.FailureMessage); } + /// + /// Verifies the security composition the Host performs at its composition root — + /// AddZbLdapAuth(configuration, LdapSectionPath) followed by AddSecurity() — + /// wires the shared as an + /// for LdapOptions (which is what makes + /// ValidateOnStart() fire). The LDAP registration moved to the Host because it is + /// config-coupled; AddSecurity() is a component library and no longer takes + /// IConfiguration. + /// [Fact] public void AddSecurity_RegistersLdapOptionsValidator_WithValidateOnStart() { var services = new ServiceCollection(); services.AddLogging(); services.AddDataProtection(); - services.AddSecurity( - new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build()); + // Mirror the Host composition order: shared LDAP auth (config-coupled) first, + // then the config-free AddSecurity(). + services.AddZbLdapAuth( + new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(), + ZB.MOM.WW.ScadaBridge.Security.ServiceCollectionExtensions.LdapSectionPath); + services.AddSecurity(); using var provider = services.BuildServiceProvider();