128 lines
3.9 KiB
Plaintext
128 lines
3.9 KiB
Plaintext
@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;
|
|
}
|
|
catch (Exception)
|
|
{
|
|
// Any other unexpected store/cipher fault (e.g. master key unavailable, timeout) must
|
|
// still be audited as a Failure and surfaced to the user, not left to tear down the
|
|
// circuit. The value is never shown.
|
|
_value = null;
|
|
_error = "Unable to reveal secret.";
|
|
outcome = AuditOutcome.Failure;
|
|
}
|
|
finally
|
|
{
|
|
_busy = false;
|
|
_revealed = true;
|
|
}
|
|
|
|
string actor = await SecretActorResolver.ResolveAsync(Services, AuthState);
|
|
try
|
|
{
|
|
await Audit.WriteAsync(
|
|
new AuditEvent
|
|
{
|
|
EventId = Guid.NewGuid(),
|
|
OccurredAtUtc = DateTimeOffset.UtcNow,
|
|
Actor = actor,
|
|
Action = "secret.reveal",
|
|
Outcome = outcome,
|
|
Category = "Secrets",
|
|
Target = Name.Value,
|
|
DetailsJson = outcome == AuditOutcome.Failure ? "{\"reason\":\"error\"}" : null,
|
|
},
|
|
_cts.Token);
|
|
}
|
|
catch
|
|
{
|
|
// Best-effort audit; never let an audit-write fault surface into the event pipeline.
|
|
}
|
|
}
|
|
|
|
private void Hide()
|
|
{
|
|
_revealed = false;
|
|
_value = null;
|
|
_error = null;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void Dispose()
|
|
{
|
|
_cts.Cancel();
|
|
_cts.Dispose();
|
|
}
|
|
}
|