diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/AssemblyMarker.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/AssemblyMarker.cs deleted file mode 100644 index 4c5bad5..0000000 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/AssemblyMarker.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace ZB.MOM.WW.Secrets.Ui; - -/// -/// Internal marker giving the assembly a compilable input while the Blazor -/// components are scaffolded. Replace with real components as they are added. -/// -internal static class AssemblyMarker -{ -} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/SecretsList.razor b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/SecretsList.razor new file mode 100644 index 0000000..a7b2511 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Components/SecretsList.razor @@ -0,0 +1,84 @@ +@namespace ZB.MOM.WW.Secrets.Ui +@implements IDisposable +@inject ISecretStore Store +@* Components/SecretsList.razor — metadata-only listing of secrets. Never renders a value or ciphertext. *@ + + + @if (_secrets is null) + { +

Loading…

+ } + else + { +
+ + + + + + + + + + + + + + + @if (_secrets.Count == 0) + { + + + + } + else + { + @foreach (SecretMetadata secret in _secrets) + { + + + + + + + + + + + } + } + +
NameDescriptionContent typeKEKRevisionUpdated (UTC)Updated by
No secrets.
@secret.Name.Value@secret.Description@secret.ContentType@secret.KekId@secret.Revision@secret.UpdatedUtc.UtcDateTime.ToString("u")@secret.UpdatedBy + + @* Placeholder — actual reveal wiring is a later task. Gated so it + renders only for principals holding the reveal policy. *@ + + +
+
+ } +
+ +@code { + private readonly CancellationTokenSource _cts = new(); + private IReadOnlyList? _secrets; + + /// + protected override async Task OnInitializedAsync() + { + _secrets = await Store.ListAsync(includeDeleted: false, _cts.Token); + } + + /// + public void Dispose() + { + _cts.Cancel(); + _cts.Dispose(); + } +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Pages/SecretsPage.razor b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Pages/SecretsPage.razor new file mode 100644 index 0000000..addfb5d --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/Pages/SecretsPage.razor @@ -0,0 +1,9 @@ +@page "/admin/secrets" +@namespace ZB.MOM.WW.Secrets.Ui +@attribute [Authorize(Policy = SecretsAuthorization.ManagePolicy)] +@* Pages/SecretsPage.razor — the host supplies the outer Theme shell/layout. *@ + +

Secrets

+

Manage stored secrets. Values are never shown here — reveal is a separate, more privileged action.

+ + diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/SecretsAuthorization.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/SecretsAuthorization.cs new file mode 100644 index 0000000..719885e --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/SecretsAuthorization.cs @@ -0,0 +1,74 @@ +using Microsoft.AspNetCore.Authorization; +using ZB.MOM.WW.Auth.AspNetCore; + +namespace ZB.MOM.WW.Secrets.Ui; + +/// +/// Authorization policies and the roles that back them for the secrets-management UI. +/// +/// +/// Two policies gate the UI, layered by privilege: +/// +/// +/// +/// — view and administer secret metadata (the list +/// page, create/rename/delete affordances). Satisfied by , +/// , or . +/// +/// +/// +/// +/// — the strictly more privileged right to reveal a secret's +/// plaintext value. Satisfied only by or . +/// Because both reveal-granting roles also satisfy , holding +/// reveal always implies manage. +/// +/// +/// +/// Both policies require an authenticated user. The requirements are expressed as role checks +/// against the canonical claim, so the host maps its LDAP +/// groups to these role names (via its IGroupRoleMapper / Security:Ldap:GroupToRole +/// configuration) exactly as it does for every other ZB.MOM.WW role. The role-name +/// constants are deliberately minimal and can be overridden by the host by registering its own +/// policies under the same policy names instead of calling . +/// +public static class SecretsAuthorization +{ + /// Policy name for administering secret metadata (the list page and management actions). + public const string ManagePolicy = "secrets:manage"; + + /// Policy name for revealing a secret's plaintext value — strictly more privileged than . + public const string RevealPolicy = "secrets:reveal"; + + /// Role that grants secret metadata management (satisfies ). + public const string ManageRole = "secrets-manager"; + + /// Role that grants secret value reveal (satisfies both and ). + public const string RevealRole = "secrets-reveal"; + + /// Administrator role that satisfies every secrets policy. + public const string AdminRole = "administrator"; + + /// + /// Registers the and policies on the + /// supplied . Each policy requires an authenticated user; + /// is a strict superset-privilege of . + /// + /// The authorization options to add the policies to. + /// The same instance, to allow chaining. + /// is null. + public static AuthorizationOptions AddSecretsAuthorization(this AuthorizationOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + options.AddPolicy(ManagePolicy, policy => policy + .RequireAuthenticatedUser() + .RequireRole(ManageRole, RevealRole, AdminRole)); + + options.AddPolicy(RevealPolicy, policy => policy + .RequireAuthenticatedUser() + .RequireRole(RevealRole, AdminRole)); + + return options; + } +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/_Imports.razor b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/_Imports.razor new file mode 100644 index 0000000..39c22eb --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/_Imports.razor @@ -0,0 +1,7 @@ +@using Microsoft.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Authorization +@using ZB.MOM.WW.Theme +@using ZB.MOM.WW.Secrets.Abstractions +@using ZB.MOM.WW.Secrets.Ui diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/PlaceholderTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/PlaceholderTests.cs deleted file mode 100644 index 24ecffb..0000000 --- a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/PlaceholderTests.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace ZB.MOM.WW.Secrets.Ui.Tests; - -public class PlaceholderTests -{ - [Fact] - public void Builds() => Assert.True(true); -} diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/SecretsListTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/SecretsListTests.cs new file mode 100644 index 0000000..4f91b3b --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/SecretsListTests.cs @@ -0,0 +1,120 @@ +using Bunit; +using Bunit.TestDoubles; +using Microsoft.Extensions.DependencyInjection; +using ZB.MOM.WW.Secrets.Abstractions; +using ZB.MOM.WW.Secrets.Ui; + +namespace ZB.MOM.WW.Secrets.Ui.Tests; + +public class SecretsListTests : TestContext +{ + private static readonly SecretMetadata SecretOne = new() + { + Name = new SecretName("db/primary"), + Description = "Primary database connection", + ContentType = SecretContentType.ConnectionString, + KekId = "kek-a", + Revision = 3, + CreatedUtc = new DateTimeOffset(2026, 7, 1, 0, 0, 0, TimeSpan.Zero), + UpdatedUtc = new DateTimeOffset(2026, 7, 10, 12, 0, 0, TimeSpan.Zero), + UpdatedBy = "alice", + }; + + private static readonly SecretMetadata SecretTwo = new() + { + Name = new SecretName("api/token"), + Description = "External API token", + ContentType = SecretContentType.Text, + KekId = "kek-b", + Revision = 1, + CreatedUtc = new DateTimeOffset(2026, 7, 2, 0, 0, 0, TimeSpan.Zero), + UpdatedUtc = new DateTimeOffset(2026, 7, 11, 8, 30, 0, TimeSpan.Zero), + UpdatedBy = "bob", + }; + + private void RegisterStore(params SecretMetadata[] rows) + => Services.AddSingleton(new FakeSecretStore(rows)); + + [Fact] + public void Renders_Metadata_ForEachSecret() + { + RegisterStore(SecretOne, SecretTwo); + var auth = this.AddTestAuthorization(); + auth.SetAuthorized("alice"); + auth.SetPolicies(SecretsAuthorization.ManagePolicy); + + var cut = RenderComponent(); + var markup = cut.Markup; + + Assert.Contains("db/primary", markup); + Assert.Contains("Primary database connection", markup); + Assert.Contains(nameof(SecretContentType.ConnectionString), markup); + + Assert.Contains("api/token", markup); + Assert.Contains("External API token", markup); + Assert.Contains(nameof(SecretContentType.Text), markup); + } + + [Fact] + public void RevealButton_Hidden_WithoutRevealPolicy() + { + RegisterStore(SecretOne, SecretTwo); + var auth = this.AddTestAuthorization(); + auth.SetAuthorized("alice"); + auth.SetPolicies(SecretsAuthorization.ManagePolicy); + + var cut = RenderComponent(); + + Assert.Empty(cut.FindAll("button.reveal-secret")); + Assert.DoesNotContain("Reveal", cut.Markup); + } + + [Fact] + public void RevealButton_Shown_WithRevealPolicy() + { + RegisterStore(SecretOne, SecretTwo); + var auth = this.AddTestAuthorization(); + auth.SetAuthorized("carol"); + auth.SetPolicies(SecretsAuthorization.ManagePolicy, SecretsAuthorization.RevealPolicy); + + var cut = RenderComponent(); + + // One reveal affordance per secret row. + Assert.Equal(2, cut.FindAll("button.reveal-secret").Count); + } + + [Fact] + public void EmptyList_ShowsEmptyState() + { + RegisterStore(); + var auth = this.AddTestAuthorization(); + auth.SetAuthorized("alice"); + auth.SetPolicies(SecretsAuthorization.ManagePolicy); + + var cut = RenderComponent(); + + Assert.Contains("No secrets", cut.Markup); + } + + /// Minimal hand-written whose only real behavior is . + private sealed class FakeSecretStore(IReadOnlyList rows) : ISecretStore + { + public Task> ListAsync(bool includeDeleted, CancellationToken ct) + => Task.FromResult(rows); + + public Task GetAsync(SecretName name, CancellationToken ct) + => throw new NotSupportedException(); + + public Task UpsertAsync(StoredSecret row, CancellationToken ct) + => throw new NotSupportedException(); + + public Task DeleteAsync(SecretName name, string? actor, CancellationToken ct) + => throw new NotSupportedException(); + + public Task> GetManifestAsync(CancellationToken ct) + => throw new NotSupportedException(); + + public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct) + => throw new NotSupportedException(); + } +}