feat(secrets-ui): add/rotate/delete modal + audited gated reveal
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
@namespace ZB.MOM.WW.Secrets.Ui.Components
|
||||
@implements IDisposable
|
||||
@inject ISecretStore Store
|
||||
@inject ISecretCipher Cipher
|
||||
@inject IAuditWriter Audit
|
||||
@inject IServiceProvider Services
|
||||
@* Components/RevealButton.razor — a policy-gated, audited reveal. The plaintext is loaded and
|
||||
shown only AFTER the click, straight from the store+cipher (a human reveal shows the CURRENT
|
||||
value, distinct from any cached machine resolve). The value never enters an audit field. *@
|
||||
|
||||
@if (!_revealed)
|
||||
{
|
||||
<button type="button" class="btn btn-outline-secondary reveal-secret"
|
||||
disabled="@_busy" data-secret-name="@Name.Value"
|
||||
data-testid="reveal-button" @onclick="RevealAsync">
|
||||
Reveal
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="reveal-panel" data-testid="reveal-panel">
|
||||
@if (_value is not null)
|
||||
{
|
||||
<code class="revealed-value" data-testid="revealed-value">@_value</code>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="field-error s-bad" data-testid="reveal-error">@(_error ?? "Unavailable.")</span>
|
||||
}
|
||||
<button type="button" class="btn btn-link hide-secret"
|
||||
data-testid="hide-button" @onclick="Hide">
|
||||
Hide
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private bool _revealed;
|
||||
private string? _value;
|
||||
private string? _error;
|
||||
private bool _busy;
|
||||
|
||||
/// <summary>The secret whose plaintext this button reveals.</summary>
|
||||
[Parameter] public SecretName Name { get; set; }
|
||||
|
||||
/// <summary>The cascading authentication state, used as the fallback source of the audit actor.</summary>
|
||||
[CascadingParameter] private Task<AuthenticationState>? AuthState { get; set; }
|
||||
|
||||
private async Task RevealAsync()
|
||||
{
|
||||
_busy = true;
|
||||
AuditOutcome outcome;
|
||||
try
|
||||
{
|
||||
StoredSecret? row = await Store.GetAsync(Name, _cts.Token);
|
||||
if (row is null)
|
||||
{
|
||||
_value = null;
|
||||
_error = "Secret not found.";
|
||||
outcome = AuditOutcome.Failure;
|
||||
}
|
||||
else
|
||||
{
|
||||
_value = Cipher.Decrypt(row);
|
||||
_error = null;
|
||||
outcome = AuditOutcome.Success;
|
||||
}
|
||||
}
|
||||
catch (SecretDecryptionException)
|
||||
{
|
||||
_value = null;
|
||||
_error = "Unable to decrypt secret.";
|
||||
outcome = AuditOutcome.Failure;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
_revealed = true;
|
||||
}
|
||||
|
||||
string actor = await SecretActorResolver.ResolveAsync(Services, AuthState);
|
||||
await Audit.WriteAsync(
|
||||
new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = DateTimeOffset.UtcNow,
|
||||
Actor = actor,
|
||||
Action = "secret.reveal",
|
||||
Outcome = outcome,
|
||||
Category = "Secrets",
|
||||
Target = Name.Value,
|
||||
},
|
||||
_cts.Token);
|
||||
}
|
||||
|
||||
private void Hide()
|
||||
{
|
||||
_revealed = false;
|
||||
_value = null;
|
||||
_error = null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
_cts.Cancel();
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user