Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RestoreSessionFunction.cs
T
Oliver Kieselbach 88cd29d3b9 backend: lifecycle completeness nets + session-scope point-read + server-side dashboard search (audit P5/P6)
P5.1 Table lifecycle buckets: TableLifecycleBucketTests classifies every
TableNames constant (offboarding buckets via reflection, per-session set from a
real DeletionManifestBuilder run, reviewed KeptByDesign list). Fixes found by
the audit: UsageMetrics offboarding wipe matched 0 rows (PK=date/RK=tenantId)
-> new SafeWipe Variant D (RowKey-anchored, verify protects the "global" row);
PlatformStats tenant partition + ScriptNameCache never wiped -> tenant-PK bucket.

P5.2 SessionsIndex field manifest: SessionIndexFieldManifest (32 always / 18
conditional / 4 primary-only) pinned bidirectionally against
BuildSessionIndexEntity and MapToSessionSummary; full rebuilds now
TableUpdateMode.Replace (heals stale conditional columns); merge sites warn on
non-manifest keys; force-path now mirrors StalledAt write + clear heals.

P5.3/P6.1 Session scope resolution: ResolveSessionScopeAsync (RequestContext)
replaces the 16-site copy-paste fallback; ResolveSessionTenantIdAsync does a
point-read on the new SessionTenantLookup table (PK=sessionId) with legacy
index-scan fallback + self-heal, replacing the cross-partition scan; the
"Count == 0 -> rescan" heuristics are gone. Write paths gate cross-tenant
resolution on IsGlobalAdmin. RecomputeTriggerGate enforces the decided
read-only semantics API-side: reanalyze/rescan now 403 for Viewer/GlobalReader/
cross-tenant tenant roles. Lookup rows ride the deletion cascade (step 22).

P6.2 Server-side dashboard search: q= on search/sessions (+global) matching
exactly the client-side searchable fields, page-backfill loop (max 10 Azure
pages/request), q bound into the pagination fingerprint; the web dashboard's
searchAll() replaces the loadAll() full-history sweep (dedupe-merge, delegated
callers keep the legacy sweep).

P6.3 Context value memoization: TenantConfigContext + Auth/Notification/
Tenant/Theme providers (cuts the externally-triggered re-render cascade).

Verified: solution build 0W/0E; Functions.Tests 4106 (+37), DecisionCore 519,
V2.Core 2206, SummaryDialog 44; tsc/eslint clean; vitest 722 (+4). Drift
detection of both new manifest tests proven by mutation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 19:29:27 +02:00

219 lines
11 KiB
C#

using System;
using System.Net;
using System.Text.Json;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services.Deletion;
using AutopilotMonitor.Shared.DataAccess;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Sessions
{
/// <summary>
/// <c>POST /api/global/sessions/{sessionId}/restore</c> — GA-only cascade-delete restore
/// endpoint (plan §13, PR4b). Body: <c>{ "manifestId": "...", "dryRun": false }</c>.
/// Dispatches into <see cref="SessionRestoreService"/> which auto-selects full vs
/// partial-poisoned-recovery mode based on the (Sessions row state, progress.CompletedAt)
/// tuple. Status mapping:
/// <list type="bullet">
/// <item>200 OK — restore (or dry-run) succeeded, body carries the row counts.</item>
/// <item>400 Bad Request — missing/invalid body, missing manifestId.</item>
/// <item>404 Not Found — manifest blob missing (GC'd past 33-day window) or session unknown.</item>
/// <item>409 Conflict — every <c>Reject*</c> outcome (state mismatch, manifest corruption, already at None, manifestId mismatch, CAS conflict on clear).</item>
/// </list>
/// Route registration in <c>EndpointAccessPolicyCatalog</c> with
/// <c>EndpointPolicy.GlobalAdminOnly</c> is what makes this GA-only; unregistered routes
/// fail-closed → 403 (per memory <c>feedback_route_policy_catalog</c>).
/// </summary>
public class RestoreSessionFunction
{
private readonly ILogger<RestoreSessionFunction> _logger;
private readonly SessionRestoreService _restoreService;
private readonly ISessionRepository _sessionRepo;
public RestoreSessionFunction(
ILogger<RestoreSessionFunction> logger,
SessionRestoreService restoreService,
ISessionRepository sessionRepo)
{
_logger = logger;
_restoreService = restoreService;
_sessionRepo = sessionRepo;
}
[Function("RestoreSession")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "global/sessions/{sessionId}/restore")] HttpRequestData req,
string sessionId)
{
if (string.IsNullOrWhiteSpace(sessionId))
{
return await WriteJsonAsync(req, HttpStatusCode.BadRequest, new { success = false, message = "sessionId is required" });
}
// Parse body.
RestoreRequestBody? body;
try
{
body = await JsonSerializer.DeserializeAsync<RestoreRequestBody>(
req.Body, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "RestoreSession: malformed body for session {SessionId}", sessionId);
return await WriteJsonAsync(req, HttpStatusCode.BadRequest,
new { success = false, message = "Request body is not valid JSON. Expected: { \"manifestId\": \"...\", \"dryRun\": false }" });
}
if (body == null || string.IsNullOrWhiteSpace(body.ManifestId))
{
return await WriteJsonAsync(req, HttpStatusCode.BadRequest,
new { success = false, message = "manifestId is required in the request body." });
}
// Tenant resolution. Codex F1 review: an explicit body.TenantId from a GA caller is
// the only path that lets a Global Admin restore a session in a tenant other than
// their JWT home tenant. Order:
// 1. Explicit body.TenantId on GA calls (cross-tenant restore — the common case
// when restoring from the Session Cleanup admin page).
// 2. JWT TargetTenantId (single-tenant operators).
// 3. Session-tenant lookup keyed by sessionId (rare but preserved).
// 4. body.TenantId again — last-resort fallback for the "Sessions row gone" path
// where neither the index nor the JWT tenant can resolve the target.
var requestCtx = req.GetRequestContext();
var actorEmail = TenantHelper.GetUserIdentifier(req);
string tenantId;
if (requestCtx.IsGlobalAdmin
&& !string.IsNullOrWhiteSpace(body.TenantId)
&& Guid.TryParse(body.TenantId, out _))
{
tenantId = body.TenantId!;
}
else
{
tenantId = requestCtx.TargetTenantId;
if (string.IsNullOrEmpty(tenantId) || string.Equals(tenantId, "global", StringComparison.OrdinalIgnoreCase))
{
tenantId = await _sessionRepo.ResolveSessionTenantIdAsync(sessionId) ?? string.Empty;
}
if (string.IsNullOrEmpty(tenantId))
{
// Sessions row is gone — but a full restore IS legal in that case. The
// manifest blob path encodes tenantId, but the operator only gives us
// sessionId + manifestId. Require the operator to scope the call by passing
// the tenantId in the request body (or via the X-Target-Tenant header).
if (string.IsNullOrWhiteSpace(body.TenantId))
{
return await WriteJsonAsync(req, HttpStatusCode.NotFound, new
{
success = false,
message = $"Session {sessionId} not found in any active SessionsIndex entry. " +
"If the cascade completed (Sessions row gone), pass the tenantId explicitly in the request body."
});
}
tenantId = body.TenantId!;
}
}
_logger.LogInformation(
"RestoreSession: tenant={TenantId} session={SessionId} manifestId={ManifestId} dryRun={DryRun} actor={Actor}",
tenantId, sessionId, body.ManifestId, body.DryRun, actorEmail);
// Trim + cap operator-supplied reason so it can't bloat the audit-row PartitionKey
// payload or be used to smuggle large blobs through. 1024 chars matches the cap the
// handler uses for LastFailureMessage in DeletionProgress (same compliance bucket).
var reason = body.Reason;
if (!string.IsNullOrWhiteSpace(reason))
{
reason = reason!.Trim();
if (reason.Length > 1024) reason = reason.Substring(0, 1024);
}
else
{
reason = null;
}
SessionRestoreResult result;
try
{
result = await _restoreService.RestoreAsync(
tenantId, sessionId, body.ManifestId,
body.DryRun, actorEmail, reason);
}
catch (Exception ex)
{
_logger.LogError(ex,
"RestoreSession: unhandled exception for tenant={TenantId} session={SessionId} manifestId={ManifestId}",
tenantId, sessionId, body.ManifestId);
return await WriteJsonAsync(req, HttpStatusCode.InternalServerError,
new { success = false, message = "Internal error during restore — see audit log + telemetry for details.", exceptionType = ex.GetType().Name });
}
return await WriteResultAsync(req, result);
}
/// <summary>
/// Pure status-mapping function for the restore outcome → HTTP status code translation.
/// Extracted as static + public so unit tests can verify it without spinning up the full
/// HttpRequestData mocking machinery.
/// </summary>
public static HttpStatusCode MapOutcomeToStatus(SessionRestoreOutcome outcome) => outcome switch
{
SessionRestoreOutcome.Restored => HttpStatusCode.OK,
SessionRestoreOutcome.DryRunOk => HttpStatusCode.OK,
SessionRestoreOutcome.RejectManifestNotFound => HttpStatusCode.NotFound,
_ => HttpStatusCode.Conflict,
};
private static async Task<HttpResponseData> WriteResultAsync(HttpRequestData req, SessionRestoreResult result)
{
var status = MapOutcomeToStatus(result.Outcome);
var body = new
{
success = status == HttpStatusCode.OK,
outcome = result.Outcome.ToString(),
mode = result.Mode,
message = result.Message,
currentState = result.CurrentState,
pendingManifestId = result.PendingManifestId,
rowsRestoredByTable = result.RowsRestoredByTable,
rowsSkippedByTable = result.RowsSkippedByTable,
wouldRestoreByTable = result.WouldRestoreByTable,
inventoryReIncrements = result.InventoryReIncrements,
durationMs = result.DurationMs,
};
return await WriteJsonAsync(req, status, body);
}
private static async Task<HttpResponseData> WriteJsonAsync(HttpRequestData req, HttpStatusCode status, object body)
{
var response = req.CreateResponse(status);
await response.WriteAsJsonAsync(body);
return response;
}
private sealed class RestoreRequestBody
{
public string ManifestId { get; set; } = string.Empty;
public bool DryRun { get; set; }
/// <summary>
/// Optional: when the Sessions row is already gone (full-restore case after a completed
/// cascade), there's no SessionsIndex entry to look up the tenant from. Operators
/// should provide tenantId explicitly in this scenario.
/// </summary>
public string? TenantId { get; set; }
/// <summary>
/// Optional free-text justification. Persisted into the <c>deletion_restored</c> audit
/// row's <c>reason</c> detail so the operator's intent (customer ticket, compliance
/// request, …) is recoverable from the tenant audit trail. Codex follow-up to the
/// PR-B audit consolidation: previously the UI sent this but the backend dropped it.
/// Trimmed + capped at 1024 chars by the function before being passed to the service.
/// </summary>
public string? Reason { get; set; }
}
}
}