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);
+ }
+}