Mark subproject as dirty in CMKS - Infra

This commit is contained in:
Nils Blomgren
2026-05-18 08:37:09 +02:00
parent 328d494530
commit 8b94fb8826
635 changed files with 81145 additions and 100551 deletions

View 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();
}
}