mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
5163d7d6a0
73 sites in 68 files built the query collection with HttpUtility.ParseQueryString(req.Url.Query), 15 files used req.Query and five mixed both. HttpRequestData.Query is that same call, cached per request, and the ASP.NET Core request type does not override it, so the change is behaviour-neutral. - QueryParamGuardTests: ParseQueryString in the Functions project is limited to a named baseline of three sites that parse something other than the request query. - A fact pins req.Query against HttpUtility.ParseQueryString (repeated keys, encoded values, caching) so a worker update that changes the implementation fails a test. - Test mocks that set Url answer Query through CallBase; a loose mock returns null. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
244 lines
10 KiB
C#
244 lines
10 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// GlobalAdmin-only read + delete endpoints over <see cref="ITenantCustomsArchiveRepository"/>.
|
|
/// Backs the <c>/global/customs-archive</c> Web UI: list runs, inspect entries, delete per
|
|
/// entry / per run. All writes audit-logged under <see cref="Constants.AuditGlobalTenantId"/>.
|
|
/// <para>
|
|
/// Authorization is enforced by <c>PolicyEnforcementMiddleware</c> via
|
|
/// <c>EndpointAccessPolicyCatalog</c>; the five routes registered here all carry
|
|
/// <c>EndpointPolicy.GlobalAdminOnly</c>.
|
|
/// </para>
|
|
/// </summary>
|
|
public class CustomsArchiveQueryFunction
|
|
{
|
|
private readonly ILogger<CustomsArchiveQueryFunction> _logger;
|
|
private readonly ITenantCustomsArchiveRepository _archive;
|
|
private readonly IMaintenanceRepository _maintenance;
|
|
|
|
public CustomsArchiveQueryFunction(
|
|
ILogger<CustomsArchiveQueryFunction> logger,
|
|
ITenantCustomsArchiveRepository archive,
|
|
IMaintenanceRepository maintenance)
|
|
{
|
|
_logger = logger;
|
|
_archive = archive;
|
|
_maintenance = maintenance;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[Function("CustomsArchiveListRuns")]
|
|
public async Task<HttpResponseData> ListRuns(
|
|
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/customs-archive")] HttpRequestData req)
|
|
{
|
|
var query = req.Query;
|
|
var filterTenantId = query["tenantId"];
|
|
|
|
var summaries = new Dictionary<string, CustomsArchiveRunSummary>(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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// GET /api/global/customs-archive/{tenantId}/{historyRowKey} — list every archived
|
|
/// entry in one offboarding run, with a short preview of <see cref="TenantOffboardingCustomsArchiveEntry.EntityJson"/>.
|
|
/// Full body is available via the per-entry route below.
|
|
/// </summary>
|
|
[Function("CustomsArchiveListEntries")]
|
|
public async Task<HttpResponseData> 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<CustomsArchiveEntrySummary>();
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// GET /api/global/customs-archive/{tenantId}/{historyRowKey}/{archiveRowKey} — full
|
|
/// archive entry including <see cref="TenantOffboardingCustomsArchiveEntry.EntityJson"/>.
|
|
/// </summary>
|
|
[Function("CustomsArchiveGetEntry")]
|
|
public async Task<HttpResponseData> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// DELETE /api/global/customs-archive/{tenantId}/{historyRowKey}/{archiveRowKey} —
|
|
/// remove a single archived entry. Audit-logged under <see cref="Constants.AuditGlobalTenantId"/>.
|
|
/// </summary>
|
|
[Function("CustomsArchiveDeleteEntry")]
|
|
public async Task<HttpResponseData> 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<string, string>
|
|
{
|
|
{ "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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// DELETE /api/global/customs-archive/{tenantId}/{historyRowKey} — bulk-delete every
|
|
/// entry of one offboarding run (after the operator has reviewed them). Audit-logged
|
|
/// under <see cref="Constants.AuditGlobalTenantId"/> with the deleted count.
|
|
/// </summary>
|
|
[Function("CustomsArchiveDeleteRun")]
|
|
public async Task<HttpResponseData> 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<string, string>
|
|
{
|
|
{ "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) + "…";
|
|
}
|
|
}
|