using System.Collections.Generic; using System.Linq; using System.Net; using AutopilotMonitor.Functions.DataAccess.TableStorage; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; using AutopilotMonitor.Shared.Models.Offboarding; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; namespace AutopilotMonitor.Functions.Functions.Admin; /// /// GlobalAdmin-only read + delete endpoints over . /// Backs the /global/customs-archive Web UI: list runs, inspect entries, delete per /// entry / per run. All writes audit-logged under . /// /// Authorization is enforced by PolicyEnforcementMiddleware via /// EndpointAccessPolicyCatalog; the five routes registered here all carry /// EndpointPolicy.GlobalAdminOnly. /// /// public class CustomsArchiveQueryFunction { private readonly ILogger _logger; private readonly ITenantCustomsArchiveRepository _archive; private readonly IMaintenanceRepository _maintenance; public CustomsArchiveQueryFunction( ILogger logger, ITenantCustomsArchiveRepository archive, IMaintenanceRepository maintenance) { _logger = logger; _archive = archive; _maintenance = maintenance; } /// /// GET /api/global/customs-archive — list every archive partition (one entry per /// (tenantId, historyRowKey) run) with aggregated row-counts per source table. /// Optional ?tenantId= filter restricts to one tenant. /// [Function("CustomsArchiveListRuns")] public async Task ListRuns( [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/customs-archive")] HttpRequestData req) { var query = req.Query; var filterTenantId = query["tenantId"]; var summaries = new Dictionary(System.StringComparer.Ordinal); var source = string.IsNullOrEmpty(filterTenantId) ? _archive.QueryAllAsync() : _archive.QueryByTenantAsync(filterTenantId.ToLowerInvariant()); await foreach (var entry in source) { var key = entry.PartitionKey; if (!summaries.TryGetValue(key, out var summary)) { summary = new CustomsArchiveRunSummary { PartitionKey = entry.PartitionKey, TenantId = entry.TenantId, HistoryRowKey = entry.HistoryRowKey, ArchivedAt = entry.ArchivedAt, }; summaries[key] = summary; } switch (entry.OriginalTable) { case Constants.TableNames.GatherRules: summary.GatherRulesCount++; break; case Constants.TableNames.AnalyzeRules: summary.AnalyzeRulesCount++; break; case Constants.TableNames.ImeLogPatterns: summary.ImeLogPatternsCount++; break; } // Track the earliest ArchivedAt for the run (in case rows have slightly drifting // timestamps from multiple iterations of archive-then-wipe). if (entry.ArchivedAt < summary.ArchivedAt) { summary.ArchivedAt = entry.ArchivedAt; } } var runs = summaries.Values .OrderByDescending(s => s.ArchivedAt) .ToList(); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new CustomsArchiveRunListResponse { Success = true, Count = runs.Count, Runs = runs }); return response; } /// /// GET /api/global/customs-archive/{tenantId}/{historyRowKey} — list every archived /// entry in one offboarding run, with a short preview of . /// Full body is available via the per-entry route below. /// [Function("CustomsArchiveListEntries")] public async Task ListEntries( [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/customs-archive/{tenantId}/{historyRowKey}")] HttpRequestData req, string tenantId, string historyRowKey) { var normalizedTenantId = tenantId.ToLowerInvariant(); var items = new List(); await foreach (var entry in _archive.QueryByRunAsync(normalizedTenantId, historyRowKey)) { items.Add(new CustomsArchiveEntrySummary { PartitionKey = entry.PartitionKey, RowKey = entry.RowKey, OriginalTable = entry.OriginalTable, OriginalRowKey = entry.OriginalRowKey, ArchivedAt = entry.ArchivedAt, EntityJsonPreview = TruncatePreview(entry.EntityJson, maxLength: 200), }); } var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new CustomsArchiveEntryListResponse { Success = true, Count = items.Count, Entries = items }); return response; } /// /// GET /api/global/customs-archive/{tenantId}/{historyRowKey}/{archiveRowKey} — full /// archive entry including . /// [Function("CustomsArchiveGetEntry")] public async Task GetEntry( [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/customs-archive/{tenantId}/{historyRowKey}/{archiveRowKey}")] HttpRequestData req, string tenantId, string historyRowKey, string archiveRowKey) { var partitionKey = TableTenantCustomsArchiveRepository.BuildPartitionKey( tenantId.ToLowerInvariant(), historyRowKey); var entry = await _archive.TryGetEntryAsync(partitionKey, archiveRowKey); if (entry == null) { return req.CreateResponse(HttpStatusCode.NotFound); } var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new CustomsArchiveEntryResponse { Success = true, Entry = entry }); return response; } /// /// DELETE /api/global/customs-archive/{tenantId}/{historyRowKey}/{archiveRowKey} — /// remove a single archived entry. Audit-logged under . /// [Function("CustomsArchiveDeleteEntry")] public async Task DeleteEntry( [HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "global/customs-archive/{tenantId}/{historyRowKey}/{archiveRowKey}")] HttpRequestData req, string tenantId, string historyRowKey, string archiveRowKey, FunctionContext context) { var requestCtx = context.GetRequestContext(); var normalizedTenantId = tenantId.ToLowerInvariant(); var partitionKey = TableTenantCustomsArchiveRepository.BuildPartitionKey(normalizedTenantId, historyRowKey); await _archive.DeleteEntryAsync(partitionKey, archiveRowKey); await _maintenance.LogAuditEntryAsync( Constants.AuditGlobalTenantId, "DELETE", "CustomsArchiveEntry", $"{partitionKey}/{archiveRowKey}", requestCtx.UserPrincipalName, new Dictionary { { "TenantId", normalizedTenantId }, { "HistoryRowKey", historyRowKey }, { "ArchiveRowKey", archiveRowKey }, }); _logger.LogWarning( "CustomsArchive entry deleted by {Upn}: tenant={Tenant} history={History} archive={Archive}", requestCtx.UserPrincipalName, normalizedTenantId, historyRowKey, archiveRowKey); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new SuccessOnlyResponse { Success = true }); return response; } /// /// DELETE /api/global/customs-archive/{tenantId}/{historyRowKey} — bulk-delete every /// entry of one offboarding run (after the operator has reviewed them). Audit-logged /// under with the deleted count. /// [Function("CustomsArchiveDeleteRun")] public async Task DeleteRun( [HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "global/customs-archive/{tenantId}/{historyRowKey}")] HttpRequestData req, string tenantId, string historyRowKey, FunctionContext context) { var requestCtx = context.GetRequestContext(); var normalizedTenantId = tenantId.ToLowerInvariant(); var deleted = await _archive.DeleteRunAsync(normalizedTenantId, historyRowKey); await _maintenance.LogAuditEntryAsync( Constants.AuditGlobalTenantId, "DELETE", "CustomsArchiveRun", $"{normalizedTenantId}/{historyRowKey}", requestCtx.UserPrincipalName, new Dictionary { { "TenantId", normalizedTenantId }, { "HistoryRowKey", historyRowKey }, { "DeletedCount", deleted.ToString() }, }); _logger.LogWarning( "CustomsArchive run deleted by {Upn}: tenant={Tenant} history={History} deleted={Count}", requestCtx.UserPrincipalName, normalizedTenantId, historyRowKey, deleted); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new CustomsArchiveDeleteRunResponse { Success = true, Deleted = deleted }); return response; } private static string TruncatePreview(string s, int maxLength) { if (string.IsNullOrEmpty(s)) return string.Empty; if (s.Length <= maxLength) return s; return s.Substring(0, maxLength) + "…"; } }