diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/ConfirmDeleteModal.razor b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/ConfirmDeleteModal.razor
index ebb507f..c4f2631 100644
--- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/ConfirmDeleteModal.razor
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/ConfirmDeleteModal.razor
@@ -81,6 +81,12 @@
private async Task ConfirmAsync()
{
_error = null;
+
+ if (!await SecretsComponentAuthorization.IsAuthorizedAsync(Services, AuthState, SecretsAuthorization.ManagePolicy))
+ {
+ _error = "You are not authorized to delete secrets.";
+ return;
+ }
_busy = true;
bool deleted = false;
try
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/RevealButton.razor b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/RevealButton.razor
index e28541d..0a26908 100644
--- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/RevealButton.razor
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/RevealButton.razor
@@ -56,6 +56,14 @@ else
// value costs only a retry.
private async Task RevealAsync()
{
+ if (!await SecretsComponentAuthorization.IsAuthorizedAsync(Services, AuthState, SecretsAuthorization.RevealPolicy))
+ {
+ _value = null;
+ _error = "You are not authorized to reveal secrets.";
+ _revealed = true;
+ return;
+ }
+
_busy = true;
// Captured now: if the component is disposed mid-reveal, reading _cts.Token later would throw
// ObjectDisposedException; a captured token just reports cancellation, which fails closed.
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/SecretEditor.razor b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/SecretEditor.razor
index 9a9e497..541dad6 100644
--- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/SecretEditor.razor
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/SecretEditor.razor
@@ -110,6 +110,12 @@
{
_error = null;
+ if (!await SecretsComponentAuthorization.IsAuthorizedAsync(Services, AuthState, SecretsAuthorization.ManagePolicy))
+ {
+ _error = "You are not authorized to manage secrets.";
+ return;
+ }
+
SecretName name;
try
{
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/SecretsComponentAuthorization.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/SecretsComponentAuthorization.cs
new file mode 100644
index 0000000..f5f6fac
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/SecretsComponentAuthorization.cs
@@ -0,0 +1,38 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Components.Authorization;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace ZB.MOM.WW.Secrets.Ui;
+
+///
+/// The in-component policy check every revealing or mutating handler runs before it touches the
+/// store (Secrets-016). The public components used to rely entirely on the host rendering them
+/// inside an AuthorizeView; a host that composed <RevealButton/> into a
+/// lower-privilege page handed plaintext to anyone who could see that page. Now each component
+/// re-checks its own policy and fails closed — no authentication state, no
+/// , or a failed policy all refuse.
+///
+internal static class SecretsComponentAuthorization
+{
+ public static async Task IsAuthorizedAsync(
+ IServiceProvider services, Task? cascadedState, string policy)
+ {
+ IAuthorizationService? authorization = services.GetService();
+ if (authorization is null)
+ {
+ return false;
+ }
+
+ Task? stateTask = cascadedState
+ ?? services.GetService()?.GetAuthenticationStateAsync();
+ if (stateTask is null)
+ {
+ return false;
+ }
+
+ AuthenticationState state = await stateTask.ConfigureAwait(false);
+ AuthorizationResult result = await authorization.AuthorizeAsync(state.User, resource: null, policy)
+ .ConfigureAwait(false);
+ return result.Succeeded;
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/ComponentAuthorizationTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/ComponentAuthorizationTests.cs
new file mode 100644
index 0000000..c2904de
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/ComponentAuthorizationTests.cs
@@ -0,0 +1,160 @@
+using System.Security.Claims;
+using Bunit;
+using Bunit.TestDoubles;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.Extensions.DependencyInjection;
+using ZB.MOM.WW.Audit;
+using ZB.MOM.WW.Auth.AspNetCore;
+using ZB.MOM.WW.Secrets.Abstractions;
+using ZB.MOM.WW.Secrets.Ui.Components;
+using ZB.MOM.WW.Secrets.Ui.Tests.Fakes;
+
+namespace ZB.MOM.WW.Secrets.Ui.Tests;
+
+///
+/// Secrets-016: each public component re-checks its own policy, so a host that renders one outside
+/// an AuthorizeView cannot hand its action to a user without the right; and the REAL policy
+/// definitions in are pinned (the bUnit
+/// tests elsewhere use SetPolicies, which bypasses them).
+///
+public class ComponentAuthorizationTests : TestContext
+{
+ private const string PlainValue = "the-plaintext-value-42";
+
+ private (RecordingSecretStore Store, CapturingAuditWriter Audit, FakeCipher Cipher) Setup(params string[] grantedPolicies)
+ {
+ var store = new RecordingSecretStore();
+ var audit = new CapturingAuditWriter();
+ var cipher = new FakeCipher();
+ Services.AddSingleton(store);
+ Services.AddSingleton(audit);
+ Services.AddSingleton(cipher);
+ var auth = this.AddTestAuthorization();
+ auth.SetAuthorized("eve");
+ auth.SetPolicies(grantedPolicies);
+ return (store, audit, cipher);
+ }
+
+ [Fact]
+ public void RevealButton_outside_an_AuthorizeView_refuses_a_manager_without_reveal()
+ {
+ // A host composed the button into a page gated only by ManagePolicy.
+ var (store, audit, cipher) = Setup(SecretsAuthorization.ManagePolicy);
+ var name = new SecretName("prod/db");
+ store.Seed(cipher.Encrypt(name, PlainValue, SecretContentType.Text) with
+ {
+ Revision = 1, CreatedUtc = DateTimeOffset.UnixEpoch, UpdatedUtc = DateTimeOffset.UnixEpoch,
+ });
+
+ var cut = RenderComponent(p => p.Add(c => c.Name, name));
+ cut.Find("button.reveal-secret").Click();
+
+ Assert.DoesNotContain(PlainValue, cut.Markup);
+ Assert.Contains("not authorized", cut.Find("[data-testid=reveal-error]").TextContent);
+ Assert.DoesNotContain(audit.Events, e => e.Outcome == AuditOutcome.Success);
+ }
+
+ [Fact]
+ public void RevealButton_still_reveals_for_a_holder_of_the_reveal_policy()
+ {
+ var (store, _, cipher) = Setup(SecretsAuthorization.RevealPolicy);
+ var name = new SecretName("prod/db");
+ store.Seed(cipher.Encrypt(name, PlainValue, SecretContentType.Text) with
+ {
+ Revision = 1, CreatedUtc = DateTimeOffset.UnixEpoch, UpdatedUtc = DateTimeOffset.UnixEpoch,
+ });
+
+ var cut = RenderComponent(p => p.Add(c => c.Name, name));
+ cut.Find("button.reveal-secret").Click();
+
+ Assert.Contains(PlainValue, cut.Markup);
+ }
+
+ [Fact]
+ public void SecretEditor_refuses_a_user_without_the_manage_policy()
+ {
+ var (store, audit, _) = Setup(/* no policies */);
+
+ var cut = RenderComponent(p => p.Add(c => c.OnSaved, () => { }));
+ cut.Find("[data-testid=secret-name]").Change("app/thing");
+ cut.Find("[data-testid=secret-value]").Change("v");
+ cut.Find("[data-testid=editor-submit]").Click();
+
+ Assert.Empty(store.Upserts);
+ Assert.Empty(audit.Events);
+ Assert.Contains("not authorized", cut.Find("[data-testid=editor-error]").TextContent);
+ }
+
+ [Fact]
+ public void ConfirmDeleteModal_refuses_a_user_without_the_manage_policy()
+ {
+ var (store, _, cipher) = Setup(/* no policies */);
+ var name = new SecretName("app/thing");
+ store.Seed(cipher.Encrypt(name, "v", SecretContentType.Text) with
+ {
+ Revision = 1, CreatedUtc = DateTimeOffset.UnixEpoch, UpdatedUtc = DateTimeOffset.UnixEpoch,
+ });
+ bool deleted = false;
+
+ var cut = RenderComponent(p => p
+ .Add(c => c.Visible, true)
+ .Add(c => c.Name, name)
+ .Add(c => c.OnDeleted, () => deleted = true));
+ cut.Find("[data-testid=confirm-delete]").Click();
+
+ Assert.Empty(store.Deletes);
+ Assert.False(deleted);
+ }
+
+ // ---------- the real policy definitions ----------
+
+ public static TheoryData RoleMatrix => new()
+ {
+ // role, satisfies ManagePolicy, satisfies RevealPolicy
+ { SecretsAuthorization.ManageRole, true, false },
+ { SecretsAuthorization.RevealRole, true, true },
+ { SecretsAuthorization.AdminRole, true, true },
+ { "Viewer", false, false },
+ { null, false, false },
+ };
+
+ [Theory]
+ [MemberData(nameof(RoleMatrix))]
+ public async Task The_policy_definitions_grant_exactly_the_documented_roles(string? role, bool manage, bool reveal)
+ {
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.AddAuthorization(o => o.AddSecretsAuthorization());
+ await using ServiceProvider sp = services.BuildServiceProvider();
+ var authorization = sp.GetRequiredService();
+
+ var claims = new List { new(ClaimTypes.Name, "u") };
+ if (role is not null)
+ {
+ claims.Add(new Claim(ZbClaimTypes.Role, role));
+ }
+
+ var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "test", ClaimTypes.Name, ZbClaimTypes.Role));
+
+ Assert.Equal(manage, (await authorization.AuthorizeAsync(user, SecretsAuthorization.ManagePolicy)).Succeeded);
+ Assert.Equal(reveal, (await authorization.AuthorizeAsync(user, SecretsAuthorization.RevealPolicy)).Succeeded);
+ }
+
+ [Fact]
+ public async Task An_unauthenticated_principal_satisfies_neither_policy_even_with_the_role_claim()
+ {
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.AddAuthorization(o => o.AddSecretsAuthorization());
+ await using ServiceProvider sp = services.BuildServiceProvider();
+ var authorization = sp.GetRequiredService();
+
+ // No authentication type -> IsAuthenticated == false.
+ var user = new ClaimsPrincipal(new ClaimsIdentity(
+ [new Claim(ZbClaimTypes.Role, SecretsAuthorization.AdminRole)], authenticationType: null,
+ ClaimTypes.Name, ZbClaimTypes.Role));
+
+ Assert.False((await authorization.AuthorizeAsync(user, SecretsAuthorization.ManagePolicy)).Succeeded);
+ Assert.False((await authorization.AuthorizeAsync(user, SecretsAuthorization.RevealPolicy)).Succeeded);
+ }
+}