diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/NavMenu.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/NavMenu.razor index 6519fd1b..96b668e4 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/NavMenu.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/NavMenu.razor @@ -18,6 +18,7 @@ + @* Import Bundle requires Administrator only — Designer role is not sufficient. Export Bundle lives in the Design section (RequireDesign). *@ diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/EndpointExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/EndpointExtensions.cs index 90adc1b3..1581f494 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/EndpointExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/EndpointExtensions.cs @@ -25,7 +25,9 @@ public static class EndpointExtensions endpoints.MapRazorComponents() .AddInteractiveServerRenderMode() - .AddAdditionalAssemblies(typeof(MainLayout).Assembly); + .AddAdditionalAssemblies( + typeof(MainLayout).Assembly, + typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly); return endpoints; } diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj index 1a1196bf..b651306f 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj @@ -19,6 +19,7 @@ + diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Components/Routes.razor b/src/ZB.MOM.WW.ScadaBridge.Host/Components/Routes.razor index bb6fe2cb..b293a410 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/Components/Routes.razor +++ b/src/ZB.MOM.WW.ScadaBridge.Host/Components/Routes.razor @@ -1,6 +1,6 @@ + AdditionalAssemblies="new[] { typeof(ZB.MOM.WW.ScadaBridge.CentralUI.Components.Layout.MainLayout).Assembly, typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly }"> diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs index 659dbc4e..fdaf18d0 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; @@ -31,6 +32,7 @@ using ZB.MOM.WW.Secrets.Abstractions; using ZB.MOM.WW.Secrets.Configuration; using ZB.MOM.WW.Secrets.DependencyInjection; using ZB.MOM.WW.Secrets.Sqlite; +using ZB.MOM.WW.Secrets.Ui; using ZB.MOM.WW.Telemetry; using Serilog; @@ -178,6 +180,14 @@ try builder.Services.AddSecurity(disableLogin); builder.Services.AddCentralUI(); builder.Services.AddZbSecrets(builder.Configuration, "Secrets"); + // Secrets UI authorization: adds the named policies secrets:manage + secrets:reveal + // (role-based) consumed by the /admin/secrets page. AddSecretsAuthorization only ADDS + // these two policies via Configure — it composes additively with + // AddScadaBridgeAuthorization (invoked inside AddSecurity above) without clobbering. + // The library's AdminRole ("Administrator") satisfies both policies, and ScadaBridge + // builds identities with RoleClaimType = ZbClaimTypes.Role, so an Administrator is + // authorized for both manage + reveal. Done Host-side to keep Secrets.Ui out of Security. + builder.Services.Configure(o => o.AddSecretsAuthorization()); builder.Services.AddInboundAPI(); // Inbound-API auth re-arch: the shared ZB.MOM.WW.Auth.ApiKeys verifier + diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SecretsAuthorizationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SecretsAuthorizationTests.cs new file mode 100644 index 00000000..ac98b94f --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/SecretsAuthorizationTests.cs @@ -0,0 +1,92 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.DependencyInjection; +using ZB.MOM.WW.ScadaBridge.Security; +using ZB.MOM.WW.Secrets.Ui; + +namespace ZB.MOM.WW.ScadaBridge.Host.Tests; + +/// +/// G-6 (Task 7) offline behavioral proof: composing the Secrets.Ui authorization +/// (Configure<AuthorizationOptions>(o => o.AddSecretsAuthorization())) on top of +/// ScadaBridge's own yields the +/// two named policies secrets:manage + secrets:reveal, and — because ScadaBridge +/// builds identities with RoleClaimType = ZbClaimTypes.Role and the library's admin role is +/// literally "Administrator" — an Administrator principal is authorized for BOTH, +/// while a non-admin / anonymous principal is denied BOTH. This is the offline evidence for the +/// Task-6 "no app-specific policy remap needed" decision, exercised against the real +/// rather than mere registration. +/// +public class SecretsAuthorizationTests +{ + private const string ManagePolicy = "secrets:manage"; + private const string RevealPolicy = "secrets:reveal"; + + private static IServiceProvider BuildProvider() + { + var services = new ServiceCollection(); + services.AddLogging(); + // Mirror the Central composition: ScadaBridge's own authorization first + // (registers the core authorization services + ScadaBridge policies via + // AddAuthorization), then the additive Secrets.Ui policy configuration. + services.AddScadaBridgeAuthorization(); + services.Configure(o => o.AddSecretsAuthorization()); + return services.BuildServiceProvider(); + } + + private static ClaimsPrincipal PrincipalWithRole(string role) + { + // Build the identity EXACTLY as ScadaBridge does: role claims under + // JwtTokenService.RoleClaimType (== ZbClaimTypes.Role == ClaimTypes.Role) and + // roleType set to the same, so IsInRole (used by the library's RequireRole) resolves. + var identity = new ClaimsIdentity( + new[] { new Claim(JwtTokenService.RoleClaimType, role) }, + authenticationType: "Test", + nameType: JwtTokenService.UsernameClaimType, + roleType: JwtTokenService.RoleClaimType); + return new ClaimsPrincipal(identity); + } + + [Fact] + public async Task BothSecretsPolicies_AreRegistered() + { + var provider = BuildProvider(); + var policyProvider = provider.GetRequiredService(); + + Assert.NotNull(await policyProvider.GetPolicyAsync(ManagePolicy)); + Assert.NotNull(await policyProvider.GetPolicyAsync(RevealPolicy)); + } + + [Fact] + public async Task Administrator_IsAuthorized_ForBothManageAndReveal() + { + var provider = BuildProvider(); + var authService = provider.GetRequiredService(); + var admin = PrincipalWithRole(Roles.Administrator); + + Assert.True((await authService.AuthorizeAsync(admin, ManagePolicy)).Succeeded); + Assert.True((await authService.AuthorizeAsync(admin, RevealPolicy)).Succeeded); + } + + [Fact] + public async Task NonAdministratorRole_IsDenied_ForBothManageAndReveal() + { + var provider = BuildProvider(); + var authService = provider.GetRequiredService(); + var viewer = PrincipalWithRole(Roles.Viewer); + + Assert.False((await authService.AuthorizeAsync(viewer, ManagePolicy)).Succeeded); + Assert.False((await authService.AuthorizeAsync(viewer, RevealPolicy)).Succeeded); + } + + [Fact] + public async Task AnonymousPrincipal_IsDenied_ForBothManageAndReveal() + { + var provider = BuildProvider(); + var authService = provider.GetRequiredService(); + var anonymous = new ClaimsPrincipal(new ClaimsIdentity()); + + Assert.False((await authService.AuthorizeAsync(anonymous, ManagePolicy)).Succeeded); + Assert.False((await authService.AuthorizeAsync(anonymous, RevealPolicy)).Succeeded); + } +}