118 lines
6.9 KiB
C#
118 lines
6.9 KiB
C#
using System.Globalization;
|
|
using System.Security.Claims;
|
|
using ItWorx.KnowledgeHub.Api.Authorization;
|
|
using ItWorx.KnowledgeHub.Api.Security;
|
|
using ItWorx.KnowledgeHub.Application.Audit;
|
|
using ItWorx.KnowledgeHub.Application.Operations;
|
|
using ItWorx.KnowledgeHub.Application.Operations.UseCases;
|
|
using ItWorx.KnowledgeHub.Application.Platform;
|
|
using ItWorx.KnowledgeHub.Contracts.Operations;
|
|
using Microsoft.AspNetCore.Http.HttpResults;
|
|
|
|
namespace ItWorx.KnowledgeHub.Api.Endpoints;
|
|
|
|
/// <summary>
|
|
/// Administrator-only, cookie-authenticated backup management (docs/18-backup-restore.md).
|
|
/// Deliberately create/list only — restore is never reachable through the live
|
|
/// application's own HTTP surface (see <c>RestoreModeRunner</c> and ADR-016 decision 4).
|
|
/// </summary>
|
|
public static class BackupsEndpoints
|
|
{
|
|
public static IEndpointRouteBuilder MapBackupsEndpoints(this IEndpointRouteBuilder app)
|
|
{
|
|
var group = app.MapGroup("/api/v1/admin/backups")
|
|
.WithTags("Backups")
|
|
.RequireAuthorization(AuthorizationPolicies.RequireAdministrator);
|
|
|
|
group.MapGet("", ListAsync);
|
|
group.MapGet("/status", StatusAsync);
|
|
group.MapPut("/{backupId}/protection", ProtectAsync)
|
|
.AddEndpointFilter<AntiforgeryEndpointFilter>();
|
|
|
|
group.MapPost("", CreateAsync)
|
|
.RequireRateLimiting(RateLimitPolicies.JobCreation)
|
|
.AddEndpointFilter<AntiforgeryEndpointFilter>();
|
|
|
|
return app;
|
|
}
|
|
|
|
private static async Task<Ok<BackupListResponse>> ListAsync(ListBackupsUseCase useCase, IBackupEvidenceStore evidence, CancellationToken cancellationToken)
|
|
{
|
|
var backups = await useCase.ExecuteAsync(cancellationToken);
|
|
var responses = new List<BackupSummaryResponse>(backups.Count);
|
|
foreach (var backup in backups)
|
|
responses.Add(ToResponse(backup, await evidence.GetAsync(backup.BackupId, cancellationToken)));
|
|
return TypedResults.Ok(new BackupListResponse(responses));
|
|
}
|
|
|
|
private static async Task<Ok<BackupSummaryResponse>> CreateAsync(
|
|
CreateBackupUseCase useCase, IBackupEvidenceStore evidence, ClaimsPrincipal principal, HttpContext httpContext, CancellationToken cancellationToken)
|
|
{
|
|
var correlationId = httpContext.Items[CorrelationIdMiddleware.ItemKey] as string;
|
|
var actorId = Guid.Parse(principal.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
|
|
|
var summary = await useCase.ExecuteAsync(new CreateBackupCommand(actorId, correlationId), cancellationToken);
|
|
return TypedResults.Ok(ToResponse(summary, await evidence.GetAsync(summary.BackupId, cancellationToken)));
|
|
}
|
|
|
|
private static BackupSummaryResponse ToResponse(BackupSummary summary, BackupEvidence? evidence) =>
|
|
new(
|
|
summary.BackupId, summary.CreatedAt, summary.TotalSizeBytes, summary.TableCount,
|
|
summary.TotalRowCount, summary.AttachmentCount, summary.SchemaVersion, summary.ProductVersion,
|
|
evidence?.Protected ?? false, (evidence?.ProtectionVersion ?? 0).ToString(CultureInfo.InvariantCulture),
|
|
evidence?.IntegrityVerifiedAt, evidence?.RestoreVerifiedAt, evidence?.LastRestoreSucceeded);
|
|
|
|
private static async Task<Ok<BackupStatusResponse>> StatusAsync(
|
|
ListBackupsUseCase backups, IBackupEvidenceStore evidence, IPlatformSettingsReader settings,
|
|
IConfiguration configuration, TimeProvider clock, CancellationToken cancellationToken)
|
|
{
|
|
var policy = (await settings.GetEffectiveAsync(cancellationToken)).Values.BackupPolicyExpectation;
|
|
var items = await backups.ExecuteAsync(cancellationToken);
|
|
var receipts = new List<BackupEvidence>();
|
|
BackupEvidence? latestReceipt = null;
|
|
foreach (var backup in items)
|
|
if (await evidence.GetAsync(backup.BackupId, cancellationToken) is { } receipt)
|
|
{
|
|
receipts.Add(receipt);
|
|
if (items.Count > 0 && backup.BackupId == items[0].BackupId) latestReceipt = receipt;
|
|
}
|
|
DateTimeOffset? last = items.Count > 0 ? items[0].CreatedAt : null;
|
|
var integrity = receipts.Select(r => r.IntegrityVerifiedAt).Max();
|
|
var restore = receipts.Select(r => r.RestoreVerifiedAt).Max();
|
|
var now = clock.GetUtcNow();
|
|
var state = last is null || latestReceipt?.IntegrityVerifiedAt is null ? "Unknown"
|
|
: now - last > TimeSpan.FromHours(policy.ExpectedIntervalHours) ? "Stale" : "Current";
|
|
var restoreState = restore is null ? "Unknown"
|
|
: now - restore > TimeSpan.FromDays(policy.RestoreVerificationMaxAgeDays) ? "Stale" : "Verified";
|
|
return TypedResults.Ok(new BackupStatusResponse(state, "External", policy.ScheduleDescription, null,
|
|
last?.AddHours(policy.ExpectedIntervalHours), last, integrity, restore, restoreState,
|
|
items.Sum(b => b.TotalSizeBytes), receipts.Count(r => r.Protected), policy.ExpectedIntervalHours,
|
|
configuration.GetValue<int?>("KnowledgeHub:Backup:RetainedCount") ?? 14,
|
|
policy.DailyRetentionCount, policy.WeeklyRetentionCount, policy.MonthlyRetentionCount,
|
|
policy.RestoreVerificationMaxAgeDays));
|
|
}
|
|
|
|
private static async Task<IResult> ProtectAsync(
|
|
string backupId, BackupProtectionRequest request, IBackupEvidenceStore evidence,
|
|
IAuditEventRecorder audit, HttpContext context, TimeProvider clock, CancellationToken cancellationToken)
|
|
{
|
|
var header = context.Request.Headers.IfMatch.ToString();
|
|
if (header.Length < 3 || header[0] != '"' || header[^1] != '"'
|
|
|| !int.TryParse(header[1..^1], NumberStyles.None, CultureInfo.InvariantCulture, out var version))
|
|
return Results.Problem(statusCode: 428, title: "If-Match is verplicht.");
|
|
var actor = Guid.Parse(context.User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
|
BackupProtectionResult result;
|
|
try { result = await evidence.SetProtectionAsync(backupId, request.Protected, version, actor, cancellationToken); }
|
|
catch (IOException) { return Results.Problem(statusCode: 409, title: "Back-upopslag is in gebruik; probeer opnieuw."); }
|
|
if (result == BackupProtectionResult.NotFound) return Results.NotFound();
|
|
if (result == BackupProtectionResult.Conflict) return Results.Problem(statusCode: 412, title: "De bescherming is intussen gewijzigd.");
|
|
await audit.RecordAsync(new AuditEventRecord(AuditActorKind.User, actor, "backup",
|
|
request.Protected ? "BackupProtected" : "BackupUnprotected", "Backup", backupId,
|
|
request.Protected ? "Back-up beschermd tegen automatische retentie." : "Back-upbescherming verwijderd.",
|
|
context.Items[CorrelationIdMiddleware.ItemKey] as string, clock.GetUtcNow()), cancellationToken);
|
|
var receipt = await evidence.GetAsync(backupId, cancellationToken);
|
|
context.Response.Headers.ETag = $"\"{receipt!.ProtectionVersion}\"";
|
|
return Results.Ok(receipt);
|
|
}
|
|
}
|