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;
///
/// 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 RestoreModeRunner and ADR-016 decision 4).
///
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();
group.MapPost("", CreateAsync)
.RequireRateLimiting(RateLimitPolicies.JobCreation)
.AddEndpointFilter();
return app;
}
private static async Task> ListAsync(ListBackupsUseCase useCase, IBackupEvidenceStore evidence, CancellationToken cancellationToken)
{
var backups = await useCase.ExecuteAsync(cancellationToken);
var responses = new List(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> 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> 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? 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("KnowledgeHub:Backup:RetainedCount") ?? 14,
policy.DailyRetentionCount, policy.WeeklyRetentionCount, policy.MonthlyRetentionCount,
policy.RestoreVerificationMaxAgeDays));
}
private static async Task 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);
}
}