mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
88cd29d3b9
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>
219 lines
11 KiB
C#
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; }
|
|
}
|
|
}
|
|
}
|