feat(secrets-ui): RCL + authz policies + metadata-only secrets list
This commit is contained in:
@@ -1,9 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.Secrets.Ui;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Internal marker giving the assembly a compilable input while the Blazor
|
|
||||||
/// components are scaffolded. Replace with real components as they are added.
|
|
||||||
/// </summary>
|
|
||||||
internal static class AssemblyMarker
|
|
||||||
{
|
|
||||||
}
|
|
||||||
@@ -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. *@
|
||||||
|
|
||||||
|
<TechCard Title="Secrets">
|
||||||
|
@if (_secrets is null)
|
||||||
|
{
|
||||||
|
<p class="mono">Loading…</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Content type</th>
|
||||||
|
<th>KEK</th>
|
||||||
|
<th class="num">Revision</th>
|
||||||
|
<th>Updated (UTC)</th>
|
||||||
|
<th>Updated by</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@if (_secrets.Count == 0)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td class="empty-row" colspan="8">No secrets.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@foreach (SecretMetadata secret in _secrets)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td class="mono">@secret.Name.Value</td>
|
||||||
|
<td>@secret.Description</td>
|
||||||
|
<td>@secret.ContentType</td>
|
||||||
|
<td class="mono">@secret.KekId</td>
|
||||||
|
<td class="num">@secret.Revision</td>
|
||||||
|
<td class="mono">@secret.UpdatedUtc.UtcDateTime.ToString("u")</td>
|
||||||
|
<td>@secret.UpdatedBy</td>
|
||||||
|
<td>
|
||||||
|
<AuthorizeView Policy="@SecretsAuthorization.RevealPolicy">
|
||||||
|
@* Placeholder — actual reveal wiring is a later task. Gated so it
|
||||||
|
renders only for principals holding the reveal policy. *@
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-outline-secondary reveal-secret"
|
||||||
|
disabled
|
||||||
|
title="Reveal (coming soon)"
|
||||||
|
data-secret-name="@secret.Name.Value">
|
||||||
|
Reveal
|
||||||
|
</button>
|
||||||
|
</AuthorizeView>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</TechCard>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private readonly CancellationTokenSource _cts = new();
|
||||||
|
private IReadOnlyList<SecretMetadata>? _secrets;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
_secrets = await Store.ListAsync(includeDeleted: false, _cts.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_cts.Cancel();
|
||||||
|
_cts.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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. *@
|
||||||
|
|
||||||
|
<h1>Secrets</h1>
|
||||||
|
<p>Manage stored secrets. Values are never shown here — reveal is a separate, more privileged action.</p>
|
||||||
|
|
||||||
|
<SecretsList />
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using ZB.MOM.WW.Auth.AspNetCore;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.Secrets.Ui;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Authorization policies and the roles that back them for the secrets-management UI.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Two policies gate the UI, layered by privilege:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>
|
||||||
|
/// <description>
|
||||||
|
/// <see cref="ManagePolicy"/> — view and administer secret <em>metadata</em> (the list
|
||||||
|
/// page, create/rename/delete affordances). Satisfied by <see cref="ManageRole"/>,
|
||||||
|
/// <see cref="RevealRole"/>, or <see cref="AdminRole"/>.
|
||||||
|
/// </description>
|
||||||
|
/// </item>
|
||||||
|
/// <item>
|
||||||
|
/// <description>
|
||||||
|
/// <see cref="RevealPolicy"/> — the strictly more privileged right to reveal a secret's
|
||||||
|
/// plaintext value. Satisfied only by <see cref="RevealRole"/> or <see cref="AdminRole"/>.
|
||||||
|
/// Because both reveal-granting roles also satisfy <see cref="ManagePolicy"/>, holding
|
||||||
|
/// reveal always implies manage.
|
||||||
|
/// </description>
|
||||||
|
/// </item>
|
||||||
|
/// </list>
|
||||||
|
/// Both policies require an authenticated user. The requirements are expressed as role checks
|
||||||
|
/// against the canonical <see cref="ZbClaimTypes.Role"/> claim, so the host maps its LDAP
|
||||||
|
/// groups to these role names (via its <c>IGroupRoleMapper</c> / <c>Security:Ldap:GroupToRole</c>
|
||||||
|
/// configuration) exactly as it does for every other <c>ZB.MOM.WW</c> 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 <see cref="AddSecretsAuthorization"/>.
|
||||||
|
/// </remarks>
|
||||||
|
public static class SecretsAuthorization
|
||||||
|
{
|
||||||
|
/// <summary>Policy name for administering secret metadata (the list page and management actions).</summary>
|
||||||
|
public const string ManagePolicy = "secrets:manage";
|
||||||
|
|
||||||
|
/// <summary>Policy name for revealing a secret's plaintext value — strictly more privileged than <see cref="ManagePolicy"/>.</summary>
|
||||||
|
public const string RevealPolicy = "secrets:reveal";
|
||||||
|
|
||||||
|
/// <summary>Role that grants secret metadata management (satisfies <see cref="ManagePolicy"/>).</summary>
|
||||||
|
public const string ManageRole = "secrets-manager";
|
||||||
|
|
||||||
|
/// <summary>Role that grants secret value reveal (satisfies both <see cref="RevealPolicy"/> and <see cref="ManagePolicy"/>).</summary>
|
||||||
|
public const string RevealRole = "secrets-reveal";
|
||||||
|
|
||||||
|
/// <summary>Administrator role that satisfies every secrets policy.</summary>
|
||||||
|
public const string AdminRole = "administrator";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers the <see cref="ManagePolicy"/> and <see cref="RevealPolicy"/> policies on the
|
||||||
|
/// supplied <see cref="AuthorizationOptions"/>. Each policy requires an authenticated user;
|
||||||
|
/// <see cref="RevealPolicy"/> is a strict superset-privilege of <see cref="ManagePolicy"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options">The authorization options to add the policies to.</param>
|
||||||
|
/// <returns>The same <paramref name="options"/> instance, to allow chaining.</returns>
|
||||||
|
/// <exception cref="ArgumentNullException"><paramref name="options"/> is <c>null</c>.</exception>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.Secrets.Ui.Tests;
|
|
||||||
|
|
||||||
public class PlaceholderTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void Builds() => Assert.True(true);
|
|
||||||
}
|
|
||||||
@@ -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<ISecretStore>(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<SecretsList>();
|
||||||
|
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<SecretsList>();
|
||||||
|
|
||||||
|
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<SecretsList>();
|
||||||
|
|
||||||
|
// 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<SecretsList>();
|
||||||
|
|
||||||
|
Assert.Contains("No secrets", cut.Markup);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Minimal hand-written <see cref="ISecretStore"/> whose only real behavior is <see cref="ListAsync"/>.</summary>
|
||||||
|
private sealed class FakeSecretStore(IReadOnlyList<SecretMetadata> rows) : ISecretStore
|
||||||
|
{
|
||||||
|
public Task<IReadOnlyList<SecretMetadata>> ListAsync(bool includeDeleted, CancellationToken ct)
|
||||||
|
=> Task.FromResult(rows);
|
||||||
|
|
||||||
|
public Task<StoredSecret?> GetAsync(SecretName name, CancellationToken ct)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task UpsertAsync(StoredSecret row, CancellationToken ct)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<bool> DeleteAsync(SecretName name, string? actor, CancellationToken ct)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user