Mark subproject as dirty in CMKS - Infra
This commit is contained in:
583
src/EntKube.Web/Components/Pages/Tenants/VaultTab.razor
Normal file
583
src/EntKube.Web/Components/Pages/Tenants/VaultTab.razor
Normal file
@@ -0,0 +1,583 @@
|
||||
@using EntKube.Web.Data
|
||||
@using EntKube.Web.Services
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@inject VaultService VaultService
|
||||
@inject TenantService TenantService
|
||||
@inject StorageService StorageService
|
||||
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<i class="bi bi-shield-lock fs-4 me-2 text-primary"></i>
|
||||
<h4 class="mb-0">Secrets Vault</h4>
|
||||
</div>
|
||||
<p class="text-muted small mb-3">Encrypted secrets for apps and cluster components. Per-tenant AES-256-GCM envelope encryption with auto-unseal.</p>
|
||||
|
||||
@if (!vaultInitialized)
|
||||
{
|
||||
<div class="card border-warning shadow-sm">
|
||||
<div class="card-body text-center py-5">
|
||||
<i class="bi bi-shield-exclamation text-warning" style="font-size: 3rem;"></i>
|
||||
<h5 class="mt-3">Vault Not Initialized</h5>
|
||||
<p class="text-muted mb-3">A per-tenant encryption key will be generated and sealed with the platform root key.</p>
|
||||
<button class="btn btn-primary" @onclick="InitializeVault">
|
||||
<i class="bi bi-key me-1"></i>Initialize Vault
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* --- Scope Toggle --- *@
|
||||
<div class="d-flex align-items-center gap-3 mb-3">
|
||||
<span class="text-muted small fw-medium">Scope:</span>
|
||||
<div class="btn-group" role="group">
|
||||
<button class="btn btn-sm @(scope == "app" ? "btn-primary" : "btn-outline-primary")" @onclick='() => SwitchScope("app")'>
|
||||
<i class="bi bi-app-indicator me-1"></i>App Secrets
|
||||
</button>
|
||||
<button class="btn btn-sm @(scope == "component" ? "btn-primary" : "btn-outline-primary")" @onclick='() => SwitchScope("component")'>
|
||||
<i class="bi bi-puzzle me-1"></i>Component Secrets
|
||||
</button>
|
||||
<button class="btn btn-sm @(scope == "storage" ? "btn-primary" : "btn-outline-primary")" @onclick='() => SwitchScope("storage")'>
|
||||
<i class="bi bi-hdd me-1"></i>Storage Secrets
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* --- Selector Dropdown --- *@
|
||||
@if (scope == "app")
|
||||
{
|
||||
@if (appOptions is not null && appOptions.Count > 0)
|
||||
{
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
<div class="card-body py-2">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-transparent"><i class="bi bi-app"></i></span>
|
||||
<select class="form-select" @bind="selectedAppId" @bind:after="LoadSecrets">
|
||||
<option value="@Guid.Empty">Select an app...</option>
|
||||
@foreach ((string label, Guid id) in appOptions)
|
||||
{
|
||||
<option value="@id">@label</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-app text-muted" style="font-size: 2rem;"></i>
|
||||
<p class="text-muted small mt-2 mb-0">Create a customer and app first to store app secrets.</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else if (scope == "component")
|
||||
{
|
||||
@if (componentOptions is not null && componentOptions.Count > 0)
|
||||
{
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
<div class="card-body py-2">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-transparent"><i class="bi bi-puzzle"></i></span>
|
||||
<select class="form-select" @bind="selectedComponentId" @bind:after="LoadSecrets">
|
||||
<option value="@Guid.Empty">Select a component...</option>
|
||||
@foreach ((string label, Guid id) in componentOptions)
|
||||
{
|
||||
<option value="@id">@label</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-puzzle text-muted" style="font-size: 2rem;"></i>
|
||||
<p class="text-muted small mt-2 mb-0">Create an environment, cluster, and component first to store component secrets.</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else if (scope == "storage")
|
||||
{
|
||||
@if (storageLinkOptions is not null && storageLinkOptions.Count > 0)
|
||||
{
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
<div class="card-body py-2">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-transparent"><i class="bi bi-hdd"></i></span>
|
||||
<select class="form-select" @bind="selectedStorageLinkId" @bind:after="LoadSecrets">
|
||||
<option value="@Guid.Empty">Select a storage link...</option>
|
||||
@foreach ((string label, Guid id) in storageLinkOptions)
|
||||
{
|
||||
<option value="@id">@label</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-hdd text-muted" style="font-size: 2rem;"></i>
|
||||
<p class="text-muted small mt-2 mb-0">Create a storage link first to view its secrets.</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@* --- Secrets Panel --- *@
|
||||
@if (HasSelection())
|
||||
{
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-white d-flex align-items-center py-2">
|
||||
<i class="bi bi-lock me-2 text-primary"></i>
|
||||
<strong>Secrets</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@* Add secret form *@
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-md-4">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text"><i class="bi bi-tag"></i></span>
|
||||
<input type="text" class="form-control" placeholder="SECRET_NAME"
|
||||
@bind="newSecretName" @bind:event="oninput" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text"><i class="bi bi-key"></i></span>
|
||||
<input type="password" class="form-control" placeholder="Secret value"
|
||||
@bind="newSecretValue" @bind:event="oninput" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-auto">
|
||||
<button class="btn btn-sm btn-primary" @onclick="AddSecret"
|
||||
disabled="@(string.IsNullOrWhiteSpace(newSecretName) || string.IsNullOrWhiteSpace(newSecretValue))">
|
||||
<i class="bi bi-plus-lg me-1"></i>Set Secret
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="alert alert-danger py-1 small mb-2">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>@errorMessage
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(successMessage))
|
||||
{
|
||||
<div class="alert alert-success py-1 small mb-2">
|
||||
<i class="bi bi-check-circle me-1"></i>@successMessage
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Secrets table *@
|
||||
@if (secrets is null)
|
||||
{
|
||||
<div class="text-center py-3">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||
</div>
|
||||
}
|
||||
else if (secrets.Count == 0)
|
||||
{
|
||||
<div class="text-center py-3">
|
||||
<i class="bi bi-lock text-muted" style="font-size: 2rem;"></i>
|
||||
<p class="text-muted small mt-1 mb-0">No secrets stored yet. Add one above.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>K8s Sync</th>
|
||||
<th>K8s Secret</th>
|
||||
<th>Namespace</th>
|
||||
<th>Updated</th>
|
||||
<th class="text-end" style="width: 150px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (VaultSecret secret in secrets)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@secret.Name</code></td>
|
||||
<td>
|
||||
@if (secret.SyncToKubernetes)
|
||||
{
|
||||
<span class="badge bg-success"><i class="bi bi-cloud-check me-1"></i>Synced</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary">Off</span>
|
||||
}
|
||||
</td>
|
||||
<td><small class="text-muted">@(secret.KubernetesSecretName ?? "—")</small></td>
|
||||
<td><small class="text-muted">@(secret.KubernetesNamespace ?? "—")</small></td>
|
||||
<td><small class="text-muted">@secret.UpdatedAt.ToString("MMM d, HH:mm")</small></td>
|
||||
<td class="text-end">
|
||||
<button class="btn btn-sm btn-outline-secondary me-1" @onclick="() => RevealSecret(secret)"
|
||||
title="@(revealedSecretId == secret.Id ? "Hide value" : "Reveal value")">
|
||||
<i class="bi @(revealedSecretId == secret.Id ? "bi-eye-slash" : "bi-eye")"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-warning me-1" @onclick="() => StartEdit(secret)" title="Edit value">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-info me-1" @onclick="() => ToggleSync(secret)"
|
||||
title="@(secret.SyncToKubernetes ? "Disable K8s sync" : "Enable K8s sync")">
|
||||
<i class="bi @(secret.SyncToKubernetes ? "bi-cloud-slash" : "bi-cloud-arrow-up")"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteSecret(secret.Id)" title="Delete secret">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@* Reveal value row *@
|
||||
@if (revealedSecretId == secret.Id)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="6" class="bg-light">
|
||||
<div class="p-2">
|
||||
<span class="text-muted small me-2">Value:</span>
|
||||
<code class="user-select-all">@revealedValue</code>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
@* Edit value row *@
|
||||
@if (editSecretId == secret.Id)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="6" class="bg-light">
|
||||
<div class="row g-2 p-2 align-items-center">
|
||||
<div class="col-md-6">
|
||||
<input type="password" class="form-control form-control-sm"
|
||||
placeholder="New secret value" @bind="editSecretValue" />
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-sm btn-success" @onclick="SaveEdit"
|
||||
disabled="@string.IsNullOrWhiteSpace(editSecretValue)">
|
||||
<i class="bi bi-check-lg me-1"></i>Update
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary ms-1" @onclick="CancelEdit">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
@if (syncConfigSecretId == secret.Id)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="6" class="bg-light">
|
||||
<div class="row g-2 p-2 align-items-center">
|
||||
<div class="col-md-4">
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
placeholder="K8s Secret name (e.g. my-app-secrets)" @bind="syncSecretName" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
placeholder="Namespace (e.g. default)" @bind="syncNamespace" />
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-sm btn-success" @onclick="SaveSync">
|
||||
<i class="bi bi-check-lg me-1"></i>Save
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary ms-1" @onclick="() => syncConfigSecretId = null">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public Guid TenantId { get; set; }
|
||||
|
||||
private bool vaultInitialized;
|
||||
private string scope = "app";
|
||||
|
||||
// App scope
|
||||
private List<(string Label, Guid Id)>? appOptions;
|
||||
private Guid selectedAppId;
|
||||
|
||||
// Component scope
|
||||
private List<(string Label, Guid Id)>? componentOptions;
|
||||
private Guid selectedComponentId;
|
||||
|
||||
// Storage scope
|
||||
private List<(string Label, Guid Id)>? storageLinkOptions;
|
||||
private Guid selectedStorageLinkId;
|
||||
|
||||
// Secrets
|
||||
private List<VaultSecret>? secrets;
|
||||
private string newSecretName = "";
|
||||
private string newSecretValue = "";
|
||||
private string? errorMessage;
|
||||
private string? successMessage;
|
||||
private Guid? syncConfigSecretId;
|
||||
private string syncSecretName = "";
|
||||
private string syncNamespace = "";
|
||||
|
||||
// Reveal/Edit state
|
||||
private Guid? revealedSecretId;
|
||||
private string? revealedValue;
|
||||
private Guid? editSecretId;
|
||||
private string editSecretValue = "";
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
SecretVault? vault = await VaultService.GetVaultAsync(TenantId);
|
||||
vaultInitialized = vault is not null;
|
||||
|
||||
if (vaultInitialized)
|
||||
{
|
||||
await LoadOptions();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task InitializeVault()
|
||||
{
|
||||
await VaultService.InitializeVaultAsync(TenantId);
|
||||
vaultInitialized = true;
|
||||
await LoadOptions();
|
||||
}
|
||||
|
||||
private async Task LoadOptions()
|
||||
{
|
||||
List<Customer> customers = await TenantService.GetCustomersAsync(TenantId);
|
||||
appOptions = [];
|
||||
|
||||
foreach (Customer customer in customers)
|
||||
{
|
||||
List<Data.App> apps = await TenantService.GetAppsAsync(customer.Id);
|
||||
|
||||
foreach (Data.App app in apps)
|
||||
{
|
||||
appOptions.Add(($"{customer.Name} / {app.Name}", app.Id));
|
||||
}
|
||||
}
|
||||
|
||||
List<KubernetesCluster> clusters = await TenantService.GetClustersAsync(TenantId);
|
||||
componentOptions = [];
|
||||
|
||||
foreach (KubernetesCluster cluster in clusters)
|
||||
{
|
||||
List<ClusterComponent> components = await VaultService.GetComponentsAsync(cluster.Id);
|
||||
|
||||
foreach (ClusterComponent comp in components)
|
||||
{
|
||||
componentOptions.Add(($"{cluster.Name} / {comp.Name}", comp.Id));
|
||||
}
|
||||
}
|
||||
|
||||
// Load storage links for the storage secrets scope.
|
||||
|
||||
List<StorageLink> links = await StorageService.GetStorageLinksAsync(TenantId);
|
||||
storageLinkOptions = links
|
||||
.Select(l => ($"{l.Environment.Name} / {l.Name} ({l.Provider})", l.Id))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private void SwitchScope(string newScope)
|
||||
{
|
||||
scope = newScope;
|
||||
secrets = null;
|
||||
selectedAppId = Guid.Empty;
|
||||
selectedComponentId = Guid.Empty;
|
||||
selectedStorageLinkId = Guid.Empty;
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
}
|
||||
|
||||
private bool HasSelection()
|
||||
{
|
||||
return (scope == "app" && selectedAppId != Guid.Empty)
|
||||
|| (scope == "component" && selectedComponentId != Guid.Empty)
|
||||
|| (scope == "storage" && selectedStorageLinkId != Guid.Empty);
|
||||
}
|
||||
|
||||
private async Task LoadSecrets()
|
||||
{
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
|
||||
if (scope == "app" && selectedAppId != Guid.Empty)
|
||||
{
|
||||
secrets = await VaultService.GetAppSecretsAsync(TenantId, selectedAppId);
|
||||
}
|
||||
else if (scope == "component" && selectedComponentId != Guid.Empty)
|
||||
{
|
||||
secrets = await VaultService.GetComponentSecretsAsync(TenantId, selectedComponentId);
|
||||
}
|
||||
else if (scope == "storage" && selectedStorageLinkId != Guid.Empty)
|
||||
{
|
||||
secrets = await VaultService.GetStorageLinkSecretsAsync(TenantId, selectedStorageLinkId);
|
||||
}
|
||||
else
|
||||
{
|
||||
secrets = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddSecret()
|
||||
{
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
if (scope == "app")
|
||||
{
|
||||
await VaultService.SetAppSecretAsync(TenantId, selectedAppId, newSecretName.Trim(), newSecretValue.Trim());
|
||||
}
|
||||
else if (scope == "component")
|
||||
{
|
||||
await VaultService.SetComponentSecretAsync(TenantId, selectedComponentId, newSecretName.Trim(), newSecretValue.Trim());
|
||||
}
|
||||
else if (scope == "storage")
|
||||
{
|
||||
await VaultService.SetStorageLinkSecretAsync(TenantId, selectedStorageLinkId, newSecretName.Trim(), newSecretValue.Trim());
|
||||
}
|
||||
|
||||
successMessage = $"Secret '{newSecretName.Trim()}' saved.";
|
||||
newSecretName = "";
|
||||
newSecretValue = "";
|
||||
await LoadSecrets();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteSecret(Guid secretId)
|
||||
{
|
||||
errorMessage = null;
|
||||
|
||||
// Check if deletion is blocked by active storage bindings.
|
||||
|
||||
(bool canDelete, string? reason) = await VaultService.CanDeleteSecretAsync(secretId);
|
||||
|
||||
if (!canDelete)
|
||||
{
|
||||
errorMessage = reason;
|
||||
return;
|
||||
}
|
||||
|
||||
await VaultService.DeleteSecretAsync(secretId);
|
||||
revealedSecretId = null;
|
||||
editSecretId = null;
|
||||
await LoadSecrets();
|
||||
}
|
||||
|
||||
private async Task RevealSecret(VaultSecret secret)
|
||||
{
|
||||
// Toggle off if already revealed.
|
||||
|
||||
if (revealedSecretId == secret.Id)
|
||||
{
|
||||
revealedSecretId = null;
|
||||
revealedValue = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Decrypt and reveal the value.
|
||||
|
||||
revealedValue = await VaultService.GetSecretValueByIdAsync(secret.Id);
|
||||
revealedSecretId = secret.Id;
|
||||
}
|
||||
|
||||
private void StartEdit(VaultSecret secret)
|
||||
{
|
||||
editSecretId = secret.Id;
|
||||
editSecretValue = "";
|
||||
revealedSecretId = null;
|
||||
revealedValue = null;
|
||||
}
|
||||
|
||||
private void CancelEdit()
|
||||
{
|
||||
editSecretId = null;
|
||||
editSecretValue = "";
|
||||
}
|
||||
|
||||
private async Task SaveEdit()
|
||||
{
|
||||
if (editSecretId is null || string.IsNullOrWhiteSpace(editSecretValue))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
errorMessage = null;
|
||||
|
||||
bool updated = await VaultService.UpdateSecretValueAsync(editSecretId.Value, editSecretValue.Trim());
|
||||
|
||||
if (updated)
|
||||
{
|
||||
successMessage = "Secret value updated.";
|
||||
}
|
||||
else
|
||||
{
|
||||
errorMessage = "Failed to update secret.";
|
||||
}
|
||||
|
||||
editSecretId = null;
|
||||
editSecretValue = "";
|
||||
await LoadSecrets();
|
||||
}
|
||||
|
||||
private void ToggleSync(VaultSecret secret)
|
||||
{
|
||||
if (secret.SyncToKubernetes)
|
||||
{
|
||||
_ = DisableSync(secret.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
syncConfigSecretId = secret.Id;
|
||||
syncSecretName = secret.KubernetesSecretName ?? "";
|
||||
syncNamespace = secret.KubernetesNamespace ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DisableSync(Guid secretId)
|
||||
{
|
||||
await VaultService.ConfigureKubernetesSyncAsync(secretId, syncEnabled: false, secretName: null, ns: null);
|
||||
await LoadSecrets();
|
||||
}
|
||||
|
||||
private async Task SaveSync()
|
||||
{
|
||||
if (syncConfigSecretId is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await VaultService.ConfigureKubernetesSyncAsync(
|
||||
syncConfigSecretId.Value,
|
||||
syncEnabled: true,
|
||||
secretName: syncSecretName.Trim(),
|
||||
ns: syncNamespace.Trim());
|
||||
|
||||
syncConfigSecretId = null;
|
||||
await LoadSecrets();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user