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