diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/BlobStorageServiceDeletionManifestTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/BlobStorageServiceDeletionManifestTests.cs new file mode 100644 index 00000000..5dad4406 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/BlobStorageServiceDeletionManifestTests.cs @@ -0,0 +1,207 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Azure; +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.Models.Deletion; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Verifies the gzip + SHA-256 + IfNoneMatch behaviour of the new manifest upload/download +/// helpers on . Uses a small subclass that overrides the two +/// virtual blob-IO seams — much simpler than wrestling with BlobsModelFactory's +/// shifting parameter list, and matches repo convention (no Azurite). +/// +public class BlobStorageServiceDeletionManifestTests +{ + private const string TenantId = "11111111-1111-1111-1111-111111111111"; + private const string SessionId = "22222222-2222-2222-2222-222222222222"; + + [Fact] + public async Task Upload_then_download_round_trips_manifest() + { + var manifest = SampleManifest(); + var sut = new FakeBlobStorageService(); + + var pointer = await sut.UploadDeletionManifestAsync(manifest); + + Assert.Equal($"{TenantId}/{SessionId}/{manifest.ManifestId}.snapshot.json.gz", pointer.BlobName); + Assert.Equal("deletion-manifests", pointer.ContainerName); + Assert.True(pointer.SizeBytes > 0); + Assert.Matches("^[0-9a-f]{64}$", pointer.SnapshotSha256); + Assert.NotNull(sut.LastWrittenBytes); + Assert.Equal(sut.LastWrittenBytes!.Length, pointer.SizeBytes); + + // Replay back through Download — uses the bytes the Upload captured. + var roundTripped = await sut.DownloadDeletionManifestAsync(TenantId, SessionId, manifest.ManifestId); + + Assert.Equal(manifest.ManifestId, roundTripped.ManifestId); + Assert.Equal(manifest.TenantId, roundTripped.TenantId); + Assert.Equal(manifest.SessionId, roundTripped.SessionId); + Assert.Equal(manifest.Reason, roundTripped.Reason); + Assert.Equal(manifest.Steps.Count, roundTripped.Steps.Count); + Assert.Equal(manifest.SchemaHash, roundTripped.SchemaHash); + } + + [Fact] + public async Task Upload_sets_content_encoding_gzip_and_sha256_metadata() + { + var manifest = SampleManifest(); + var sut = new FakeBlobStorageService(); + + await sut.UploadDeletionManifestAsync(manifest); + + Assert.NotNull(sut.LastWrittenOptions); + Assert.Equal("application/json", sut.LastWrittenOptions!.HttpHeaders!.ContentType); + Assert.Equal("gzip", sut.LastWrittenOptions.HttpHeaders.ContentEncoding); + Assert.NotNull(sut.LastWrittenOptions.Metadata); + Assert.True(sut.LastWrittenOptions.Metadata!.ContainsKey("sha256")); + Assert.Matches("^[0-9a-f]{64}$", sut.LastWrittenOptions.Metadata["sha256"]); + // Plan §1 P1: snapshot is written exactly once → IfNoneMatch=* makes overwrites fail loud. + Assert.NotNull(sut.LastWrittenOptions.Conditions); + Assert.Equal(ETag.All, sut.LastWrittenOptions.Conditions!.IfNoneMatch); + } + + [Fact] + public async Task Upload_payload_is_gzipped_and_decompresses_to_canonical_manifest_json() + { + var manifest = SampleManifest(); + var sut = new FakeBlobStorageService(); + + await sut.UploadDeletionManifestAsync(manifest); + + var captured = sut.LastWrittenBytes; + Assert.NotNull(captured); + // Magic bytes 0x1F 0x8B identify the payload as GZip. + Assert.Equal(0x1F, captured![0]); + Assert.Equal(0x8B, captured[1]); + + // Gunzip and confirm the bytes are valid manifest JSON. + using var input = new MemoryStream(captured); + using var gunzip = new GZipStream(input, CompressionMode.Decompress); + using var output = new MemoryStream(); + gunzip.CopyTo(output); + var decoded = JsonSerializer.Deserialize(output.ToArray(), DeletionManifestJson.SerializerOptions); + Assert.NotNull(decoded); + Assert.Equal(manifest.ManifestId, decoded!.ManifestId); + } + + [Fact] + public async Task Download_fails_loud_on_sha256_mismatch() + { + var manifest = SampleManifest(); + var sut = new FakeBlobStorageService(); + await sut.UploadDeletionManifestAsync(manifest); + + // Tamper with the captured metadata so the recorded sha differs from the actual hash. + sut.LastWrittenOptions!.Metadata!["sha256"] = new string('0', 64); + + var ex = await Assert.ThrowsAsync( + () => sut.DownloadDeletionManifestAsync(TenantId, SessionId, manifest.ManifestId)); + Assert.Contains("SHA-256 mismatch", ex.Message); + } + + [Fact] + public async Task Download_fails_loud_when_sha256_metadata_missing() + { + var manifest = SampleManifest(); + var sut = new FakeBlobStorageService(); + await sut.UploadDeletionManifestAsync(manifest); + + sut.LastWrittenOptions!.Metadata!.Remove("sha256"); + + var ex = await Assert.ThrowsAsync( + () => sut.DownloadDeletionManifestAsync(TenantId, SessionId, manifest.ManifestId)); + Assert.Contains("missing the required 'sha256' metadata", ex.Message); + } + + [Fact] + public async Task Upload_fails_loud_on_overwrite_attempt() + { + var manifest = SampleManifest(); + var sut = new FakeBlobStorageService + { + // Simulate the IfNoneMatch=* race — second producer for the same manifestId hits 409. + UploadException = new RequestFailedException(409, "BlobAlreadyExists", "BlobAlreadyExists", innerException: null), + }; + + var ex = await Assert.ThrowsAsync( + () => sut.UploadDeletionManifestAsync(manifest)); + Assert.Equal(409, ex.Status); + } + + [Fact] + public async Task Upload_throws_on_missing_required_fields() + { + var sut = new FakeBlobStorageService(); + + await Assert.ThrowsAsync( + () => sut.UploadDeletionManifestAsync(null!)); + await Assert.ThrowsAsync( + () => sut.UploadDeletionManifestAsync(new DeletionManifest())); + } + + // ---------------------------------------------------------------- Test fixture ---- + + private static DeletionManifest SampleManifest() => new DeletionManifest + { + ManifestId = "0123456789ABCDEF_FEDCBA9876543210", + TenantId = TenantId, + SessionId = SessionId, + CreatedAt = new DateTime(2026, 5, 12, 10, 0, 0, DateTimeKind.Utc), + CreatedBy = new DeletionActor { Type = "admin", Actor = "alice@example.com" }, + Reason = "admin_delete", + RetentionContext = new DeletionRetentionContext { TenantRetentionDays = 90 }, + Steps = new List + { + new DeletionStep { Order = 1, Table = "Events", Class = DeletionStepClass.PkBySession, RowCount = 0 }, + }, + SchemaHash = "sha256:placeholder", + }; + + /// + /// Captures the bytes + options written by Upload and replays them on Download. The + /// production overrides hit Azure Blob Storage; this fake bypasses the SDK entirely so + /// tests assert behaviour without sweating the BlobsModelFactory shape. + /// + private sealed class FakeBlobStorageService : BlobStorageService + { + public byte[]? LastWrittenBytes { get; private set; } + public BlobUploadOptions? LastWrittenOptions { get; private set; } + public Exception? UploadException { get; set; } + + public FakeBlobStorageService() + : base(new BlobServiceClient("UseDevelopmentStorage=true"), NullLogger.Instance, usesManagedIdentity: false) + { + } + + protected internal override Task WriteDeletionManifestBlobAsync( + string blobName, byte[] gzipped, BlobUploadOptions options, CancellationToken cancellationToken) + { + if (UploadException != null) throw UploadException; + LastWrittenBytes = gzipped; + LastWrittenOptions = options; + return Task.CompletedTask; + } + + protected internal override Task<(byte[] Gzipped, IDictionary? Metadata)> + ReadDeletionManifestBlobAsync(string blobName, CancellationToken cancellationToken) + { + if (LastWrittenBytes == null) + { + throw new InvalidOperationException("Test must call UploadDeletionManifestAsync before DownloadDeletionManifestAsync."); + } + return Task.FromResult<(byte[], IDictionary?)>( + (LastWrittenBytes, LastWrittenOptions?.Metadata)); + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/DeletionManifestBuilderTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/DeletionManifestBuilderTests.cs new file mode 100644 index 00000000..0c86f446 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/DeletionManifestBuilderTests.cs @@ -0,0 +1,848 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Azure; +using Azure.Data.Tables; +using AutopilotMonitor.Functions.Services.Deletion; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models.Deletion; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Behaviour tests for . PR1 is read-only — the builder +/// must (a) enumerate every cascade table per the §3 taxonomy, (b) only emit the +/// SoftwareInventoryDecrement / SessionInventoryContributions pair when the side-row exists, +/// and (c) order the FINAL tombstone step SessionsIndex-then-Sessions. All tests are +/// Moq-based against the new seam — no Azurite, +/// matching repo convention. +/// +public class DeletionManifestBuilderTests +{ + private const string TenantId = "11111111-1111-1111-1111-111111111111"; + private const string SessionId = "22222222-2222-2222-2222-222222222222"; + + [Fact] + public async Task Build_returns_correct_counts_for_full_session() + { + var reader = NewReader(seed => seed + .WithSessionsRow() + .WithSessionsIndexRow() + .WithEvents(7) + .WithRuleResults(2) + .WithAppInstallSummaries(3) + .WithVulnerabilityReport() + .WithDeviceSnapshot() + .WithEventSessionIndex() + .WithSignals(15) + .WithDecisionTransitions(4) + .WithEventTypeIndex(5) + .WithCveIndex(2) + .WithSessionsByTerminal(1) + .WithSessionsByStage(3) + .WithDeadEndsByReason(0) + .WithClassifierVerdictsByIdLevel(2) + .WithSignalsByKind(11)); + + var manifest = await NewBuilder(reader).BuildAsync( + TenantId, SessionId, "admin_delete", + new DeletionActor { Type = "admin", Actor = "alice@example.com" }, + new DeletionRetentionContext { TenantRetentionDays = 90 }); + + var c = manifest.PreflightCounts; + Assert.Equal(7, c["events"]); + Assert.Equal(2, c["ruleResults"]); + Assert.Equal(3, c["appInstallSummaries"]); + Assert.Equal(1, c["vulnerabilityReports"]); + Assert.Equal(1, c["deviceSnapshot"]); + Assert.Equal(1, c["eventSessionIndex"]); + Assert.Equal(15, c["signals"]); + Assert.Equal(4, c["decisionTransitions"]); + Assert.Equal(5, c["eventTypeIndex"]); + Assert.Equal(2, c["cveIndex"]); + Assert.Equal(1, c["sessionsByTerminal"]); + Assert.Equal(3, c["sessionsByStage"]); + Assert.Equal(0, c["deadEndsByReason"]); + Assert.Equal(2, c["classifierVerdictsByIdLevel"]); + Assert.Equal(11, c["signalsByKind"]); + + // 15 cascade tables + tombstone = 16 steps; no inventory steps when no contributions row. + Assert.Equal(16, manifest.Steps.Count); + Assert.Equal(DeletionStepClass.Final, manifest.Steps.Last().Class); + } + + [Fact] + public async Task Build_returns_full_row_dumps_for_all_classes() + { + // Seed one row in each non-inventory table with a recognisable property so we can + // assert that the dump path captures its value. + var reader = NewReader(seed => seed + .WithSessionsRow() + .WithSessionsIndexRow() + .WithEvents(1, propValue: "events_marker") + .WithRuleResults(1, propValue: "rules_marker") + .WithAppInstallSummaries(1, propValue: "appinstalls_marker") + .WithVulnerabilityReport(propValue: "vuln_marker") + .WithDeviceSnapshot(propValue: "device_marker") + .WithEventSessionIndex(propValue: "esi_marker") + .WithSignals(1, propValue: "signals_marker") + .WithDecisionTransitions(1, propValue: "transitions_marker") + .WithEventTypeIndex(1, propValue: "eti_marker") + .WithCveIndex(1, propValue: "cve_marker") + .WithSessionsByTerminal(1, propValue: "terminal_marker") + .WithSessionsByStage(1, propValue: "stage_marker") + .WithDeadEndsByReason(1, propValue: "deadend_marker") + .WithClassifierVerdictsByIdLevel(1, propValue: "classifier_marker") + .WithSignalsByKind(1, propValue: "signalkind_marker")); + + var manifest = await NewBuilder(reader).BuildAsync( + TenantId, SessionId, "admin_delete", + new DeletionActor { Type = "admin", Actor = "alice@example.com" }, + new DeletionRetentionContext { TenantRetentionDays = 90 }); + + foreach (var step in manifest.Steps) + { + // For the FINAL tombstone we expect 2 rows (SessionsIndex + Sessions); other steps each have 1. + if (step.Class == DeletionStepClass.Final) + { + Assert.Equal(2, step.Rows.Count); + continue; + } + Assert.Equal(step.RowCount, step.Rows.Count); + Assert.Single(step.Rows); + + var dump = step.Rows[0]; + Assert.False(string.IsNullOrEmpty(dump.Pk)); + Assert.False(string.IsNullOrEmpty(dump.Rk)); + Assert.False(string.IsNullOrEmpty(dump.Etag)); + Assert.True(dump.Props.ContainsKey("Marker"), + $"Step {step.Order} ({step.Table ?? step.Step}) row dump is missing the Marker prop."); + Assert.Equal(DeletionPropEdmType.String, dump.Props["Marker"].EdmType); + Assert.Equal(JsonValueKind.String, dump.Props["Marker"].Value.ValueKind); + // The seed marker round-trips faithfully through the property → DeletionPropValue convertor. + Assert.False(string.IsNullOrEmpty(dump.Props["Marker"].Value.GetString())); + } + } + + [Fact] + public async Task Build_returns_empty_rows_for_tables_with_no_session_data() + { + // No data anywhere — the builder still emits one step per cascade table, all with RowCount=0. + var reader = NewReader(seed => seed.WithSessionsRow().WithSessionsIndexRow()); + + var manifest = await NewBuilder(reader).BuildAsync( + TenantId, SessionId, "admin_delete", + new DeletionActor { Type = "admin", Actor = "alice@example.com" }, + new DeletionRetentionContext { TenantRetentionDays = 90 }); + + var nonTombstoneSteps = manifest.Steps.Where(s => s.Class != DeletionStepClass.Final).ToList(); + // Without a contributions row, neither inventory step is emitted → exactly 15 cascade-table steps. + Assert.Equal(15, nonTombstoneSteps.Count); + foreach (var step in nonTombstoneSteps) + { + Assert.Equal(0, step.RowCount); + Assert.Empty(step.Rows); + } + } + + [Fact] + public async Task Build_emits_inventory_steps_when_side_row_present() + { + var sideRowKeys = new List + { + new DeletionDecrementKey { Vendor = "Contoso", Name = "Widget", Version = "1.0" }, + new DeletionDecrementKey { Vendor = "Fabrikam", Name = "Gadget", Version = "2.0" }, + new DeletionDecrementKey { Vendor = "Tailspin", Name = "Service", Version = "3.0" }, + }; + var sideRow = MakeContributionsRow(sideRowKeys, compressed: false); + + var reader = NewReader(seed => seed + .WithSessionsRow() + .WithSessionsIndexRow() + .WithSessionInventoryContributions(sideRow)); + + var manifest = await NewBuilder(reader).BuildAsync( + TenantId, SessionId, "admin_delete", + new DeletionActor { Type = "admin", Actor = "alice@example.com" }, + new DeletionRetentionContext { TenantRetentionDays = 90 }); + + // Expect: 15 cascade + 2 inventory + 1 tombstone = 18 steps. + Assert.Equal(18, manifest.Steps.Count); + + var aggregate = manifest.Steps.Single(s => s.Class == DeletionStepClass.Aggregate); + Assert.Equal(DeletionStepNames.SoftwareInventoryDecrement, aggregate.Step); + Assert.NotNull(aggregate.Decrements); + Assert.Equal(3, aggregate.Decrements!.Count); + // Builder sorts deterministically by (Vendor, Name, Version). + Assert.Equal("Contoso", aggregate.Decrements[0].Vendor); + Assert.Equal("Fabrikam", aggregate.Decrements[1].Vendor); + Assert.Equal("Tailspin", aggregate.Decrements[2].Vendor); + Assert.Empty(aggregate.Rows); + + var contribStep = manifest.Steps.Single(s => s.Table == Constants.TableNames.SessionInventoryContributions); + Assert.Equal(DeletionStepClass.PkRkExact, contribStep.Class); + Assert.Single(contribStep.Rows); + Assert.Equal(TenantId, contribStep.Rows[0].Pk); + Assert.Equal(SessionId, contribStep.Rows[0].Rk); + + Assert.Equal(3, manifest.PreflightCounts["softwareInventoryDecrement"]); + Assert.Equal(1, manifest.PreflightCounts["sessionInventoryContributions"]); + } + + [Fact] + public async Task Build_emits_inventory_steps_when_side_row_is_gzip_compressed() + { + var sideRowKeys = new List + { + new DeletionDecrementKey { Vendor = "Contoso", Name = "WidgetSuite", Version = "10.5" }, + new DeletionDecrementKey { Vendor = "Tailspin", Name = "ToolKit", Version = "0.9" }, + }; + var sideRow = MakeContributionsRow(sideRowKeys, compressed: true); + + var reader = NewReader(seed => seed + .WithSessionsRow() + .WithSessionsIndexRow() + .WithSessionInventoryContributions(sideRow)); + + var manifest = await NewBuilder(reader).BuildAsync( + TenantId, SessionId, "admin_delete", + new DeletionActor { Type = "admin", Actor = "alice@example.com" }, + new DeletionRetentionContext { TenantRetentionDays = 90 }); + + var aggregate = manifest.Steps.Single(s => s.Class == DeletionStepClass.Aggregate); + Assert.Equal(2, aggregate.Decrements!.Count); + Assert.Equal("Contoso", aggregate.Decrements[0].Vendor); + Assert.Equal("Tailspin", aggregate.Decrements[1].Vendor); + } + + [Fact] + public async Task Build_omits_inventory_steps_when_no_side_row() + { + // The default reader returns no inventory row → 15 cascade + 1 tombstone, no inventory steps. + var reader = NewReader(seed => seed.WithSessionsRow().WithSessionsIndexRow()); + + var manifest = await NewBuilder(reader).BuildAsync( + TenantId, SessionId, "retention_cutoff", + new DeletionActor { Type = "maintenance", Actor = "system" }, + new DeletionRetentionContext { TenantRetentionDays = 90 }); + + Assert.DoesNotContain(manifest.Steps, s => s.Class == DeletionStepClass.Aggregate); + Assert.DoesNotContain(manifest.Steps, s => s.Table == Constants.TableNames.SessionInventoryContributions); + Assert.False(manifest.PreflightCounts.ContainsKey("softwareInventoryDecrement")); + Assert.False(manifest.PreflightCounts.ContainsKey("sessionInventoryContributions")); + } + + [Fact] + public async Task Build_records_diagnosticsBlobName_when_present_but_no_delete_step_emitted() + { + const string blobName = "diagnostics-uploads/tenant/session.zip"; + var reader = NewReader(seed => seed + .WithSessionsRow(diagnosticsBlobName: blobName) + .WithSessionsIndexRow()); + + var manifest = await NewBuilder(reader).BuildAsync( + TenantId, SessionId, "admin_delete", + new DeletionActor { Type = "admin", Actor = "alice@example.com" }, + new DeletionRetentionContext { TenantRetentionDays = 90 }); + + Assert.Equal(blobName, manifest.DiagnosticsBlobName); + + // No cascade step targets the diagnostics blob — customer storage, customer cleans up + // (plan §1 P10 / §3 / §11). The blob name is captured for forensic traceability only. + // It IS expected to appear as a property value on the Sessions row dump in the Tombstone + // step (the row is being deleted with all its columns intact); that's a row-content + // property, not a "the cascade will delete this blob" signal. + foreach (var step in manifest.Steps) + { + Assert.NotEqual("diagnostics", step.Table); + Assert.NotEqual("DiagnosticsUploads", step.Table); + } + } + + [Fact] + public async Task Build_tombstone_step_orders_sessionsIndex_before_sessions() + { + var reader = NewReader(seed => seed.WithSessionsRow().WithSessionsIndexRow()); + + var manifest = await NewBuilder(reader).BuildAsync( + TenantId, SessionId, "admin_delete", + new DeletionActor { Type = "admin", Actor = "alice@example.com" }, + new DeletionRetentionContext { TenantRetentionDays = 90 }); + + var tombstone = manifest.Steps.Last(); + Assert.Equal(DeletionStepClass.Final, tombstone.Class); + Assert.Equal(DeletionStepNames.Tombstone, tombstone.Step); + Assert.Equal(2, tombstone.Rows.Count); + + // Plan §5 PR4: SessionsIndex is row[0], Sessions is row[1] — UI listings drop the session before the canonical row goes. + // We seeded the Sessions row at (TenantId, SessionId) and the SessionsIndex row at (TenantId, "{SessionId}"). + Assert.NotEqual(SessionId, tombstone.Rows[0].Rk); // SessionsIndex first + Assert.Contains(SessionId, tombstone.Rows[0].Rk, StringComparison.Ordinal); // RK still references the session + Assert.Equal(SessionId, tombstone.Rows[1].Rk); // Sessions row second + } + + [Fact] + public async Task Build_produces_identical_steps_and_counts_across_runs_with_identical_data() + { + // Determinism guarantee per plan §18.6: the builder is read-only and produces the same + // manifest given the same input. Volatile fields (ManifestId, CreatedAt, SchemaHash — + // which is computed over those volatile fields) intentionally differ. + var reader = NewReader(seed => seed + .WithSessionsRow() + .WithSessionsIndexRow() + .WithEvents(3) + .WithSignals(5) + .WithSessionsByTerminal(1)); + + var first = await NewBuilder(reader).BuildAsync(TenantId, SessionId, "admin_delete", + new DeletionActor { Type = "admin", Actor = "alice@example.com" }, + new DeletionRetentionContext { TenantRetentionDays = 90 }); + var second = await NewBuilder(reader).BuildAsync(TenantId, SessionId, "admin_delete", + new DeletionActor { Type = "admin", Actor = "alice@example.com" }, + new DeletionRetentionContext { TenantRetentionDays = 90 }); + + Assert.NotEqual(first.ManifestId, second.ManifestId); + Assert.Equal(first.PreflightCounts, second.PreflightCounts); + Assert.Equal(first.Steps.Count, second.Steps.Count); + for (int i = 0; i < first.Steps.Count; i++) + { + Assert.Equal(first.Steps[i].Order, second.Steps[i].Order); + Assert.Equal(first.Steps[i].Class, second.Steps[i].Class); + Assert.Equal(first.Steps[i].Table, second.Steps[i].Table); + Assert.Equal(first.Steps[i].Step, second.Steps[i].Step); + Assert.Equal(first.Steps[i].RowCount, second.Steps[i].RowCount); + Assert.Equal(first.Steps[i].Rows.Count, second.Steps[i].Rows.Count); + } + } + + [Fact] + public async Task Restore_from_manifest_round_trips_session() + { + // PR1 ships the manifest as a backup format. The Azure-side re-insertion is exercised by + // PR4's worker tests; here we verify the manifest is sufficient AS the backup — + // serialization round-trip preserves every (PK, RK, props, ETag) needed to replay. + var reader = NewReader(seed => seed + .WithSessionsRow() + .WithSessionsIndexRow() + .WithEvents(2, propValue: "events_check") + .WithRuleResults(1, propValue: "rules_check") + .WithSessionsByTerminal(1, propValue: "terminal_check") + .WithSessionInventoryContributions(MakeContributionsRow(new List + { + new DeletionDecrementKey { Vendor = "Contoso", Name = "App", Version = "1.0" }, + }, compressed: false))); + + var manifest = await NewBuilder(reader).BuildAsync( + TenantId, SessionId, "admin_delete", + new DeletionActor { Type = "admin", Actor = "alice@example.com" }, + new DeletionRetentionContext { TenantRetentionDays = 90 }); + + var json = JsonSerializer.SerializeToUtf8Bytes(manifest, DeletionManifestJson.SerializerOptions); + var roundTripped = JsonSerializer.Deserialize(json, DeletionManifestJson.SerializerOptions); + + Assert.NotNull(roundTripped); + Assert.Equal(manifest.ManifestId, roundTripped!.ManifestId); + Assert.Equal(manifest.SchemaHash, roundTripped.SchemaHash); + Assert.Equal(manifest.Steps.Count, roundTripped.Steps.Count); + + // For each step's row dump, confirm we have everything a restore needs to replay. + foreach (var step in roundTripped.Steps) + { + foreach (var row in step.Rows) + { + Assert.False(string.IsNullOrEmpty(row.Pk)); + Assert.False(string.IsNullOrEmpty(row.Rk)); + Assert.NotNull(row.Props); + } + if (step.Class == DeletionStepClass.Aggregate) + { + Assert.NotNull(step.Decrements); + foreach (var k in step.Decrements!) + { + Assert.False(string.IsNullOrEmpty(k.Vendor)); + Assert.False(string.IsNullOrEmpty(k.Name)); + } + } + } + + // Spot-check: the Marker we seeded into Events survives the round-trip end-to-end. + var eventsStep = roundTripped.Steps.Single(s => s.Table == Constants.TableNames.Events); + Assert.Equal(2, eventsStep.Rows.Count); + Assert.Equal(DeletionPropEdmType.String, eventsStep.Rows[0].Props["Marker"].EdmType); + Assert.Equal("events_check", eventsStep.Rows[0].Props["Marker"].Value.GetString()); + } + + [Fact] + public async Task Build_preserves_per_column_edm_types_for_restore() + { + // Restore-faithfulness: every Azure Tables type Azure SDK exposes must surface in the dump + // with the correct EdmType tag so a future restore can re-Insert with the same column type + // instead of collapsing everything to plain strings. + var richRow = new TableEntity(TenantId, "rich") + { + ["StringProp"] = "hello", + ["IntProp"] = 42, + ["LongProp"] = 123456789012L, + ["DoubleProp"] = 3.14, + ["BoolProp"] = true, + ["DateProp"] = new DateTime(2026, 5, 12, 10, 0, 0, DateTimeKind.Utc), + ["GuidProp"] = new Guid("33333333-3333-3333-3333-333333333333"), + ["BinaryProp"] = new byte[] { 0x01, 0x02, 0x03 }, + }; + richRow.ETag = new ETag("0xRICH"); + + var reader = new Mock(); + reader.Setup(r => r.GetSessionRowAsync(TenantId, SessionId, It.IsAny())) + .ReturnsAsync(richRow); + reader.Setup(r => r.GetSessionsIndexRowAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((TableEntity?)null); + reader.Setup(r => r.GetEntityOrNullAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((TableEntity?)null); + reader.Setup(r => r.QueryAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(EmptyAsyncEnumerable()); + + var manifest = await NewBuilder(reader.Object).BuildAsync( + TenantId, SessionId, "admin_delete", + new DeletionActor { Type = "admin", Actor = "alice@example.com" }, + new DeletionRetentionContext { TenantRetentionDays = 90 }); + + // The rich row was placed on the Sessions row → it ends up in the FINAL tombstone step. + var tombstone = manifest.Steps.Last(); + Assert.Equal(DeletionStepClass.Final, tombstone.Class); + var dump = tombstone.Rows[0]; + + Assert.Equal(DeletionPropEdmType.String, dump.Props["StringProp"].EdmType); + Assert.Equal(DeletionPropEdmType.Int32, dump.Props["IntProp"].EdmType); + Assert.Equal(DeletionPropEdmType.Int64, dump.Props["LongProp"].EdmType); + Assert.Equal(DeletionPropEdmType.Double, dump.Props["DoubleProp"].EdmType); + Assert.Equal(DeletionPropEdmType.Boolean, dump.Props["BoolProp"].EdmType); + Assert.Equal(DeletionPropEdmType.DateTime, dump.Props["DateProp"].EdmType); + Assert.Equal(DeletionPropEdmType.Guid, dump.Props["GuidProp"].EdmType); + Assert.Equal(DeletionPropEdmType.Binary, dump.Props["BinaryProp"].EdmType); + + // Values round-trip through System.Text.Json with the encoding the restore expects. + Assert.Equal("hello", dump.Props["StringProp"].Value.GetString()); + Assert.Equal(42, dump.Props["IntProp"].Value.GetInt32()); + Assert.Equal(123456789012L, dump.Props["LongProp"].Value.GetInt64()); + Assert.Equal(3.14, dump.Props["DoubleProp"].Value.GetDouble(), 5); + Assert.True(dump.Props["BoolProp"].Value.GetBoolean()); + + // DateTime → ISO-8601 string; restore parses with DateTime.Parse(round-trip kind). + var dateStr = dump.Props["DateProp"].Value.GetString(); + Assert.NotNull(dateStr); + var parsed = DateTime.Parse(dateStr!, System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.RoundtripKind); + Assert.Equal(new DateTime(2026, 5, 12, 10, 0, 0, DateTimeKind.Utc), parsed.ToUniversalTime()); + + // Guid → string "33333333-3333-3333-3333-333333333333". + Assert.Equal("33333333-3333-3333-3333-333333333333", dump.Props["GuidProp"].Value.GetString()); + + // Binary → Base64 of the original bytes. + var b64 = dump.Props["BinaryProp"].Value.GetString(); + Assert.Equal(Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 }), b64); + } + + [Fact] + public async Task Build_falls_back_to_sessionsIndex_partition_scan_when_indexrowkey_missing() + { + // Edge: Sessions row exists but its IndexRowKey property is empty / corrupted. Without a + // fallback the cascade would leave a SessionsIndex orphan after tombstone. The builder + // must scan SessionsIndex by (PartitionKey, SessionId) to find the row anyway. + var sessionRow = new TableEntity(TenantId, SessionId) + { + ["IndexRowKey"] = string.Empty, + ["DiagnosticsBlobName"] = string.Empty, + ["Status"] = "Succeeded", + }; + sessionRow.ETag = new ETag("0xSESS"); + + var orphanedIndexRow = new TableEntity(TenantId, "FALLBACK_" + SessionId) + { + ["SessionId"] = SessionId, + ["Status"] = "Succeeded", + }; + orphanedIndexRow.ETag = new ETag("0xIDX"); + + var reader = new Mock(); + reader.Setup(r => r.GetSessionRowAsync(TenantId, SessionId, It.IsAny())) + .ReturnsAsync(sessionRow); + // Direct lookup with empty key intentionally NOT set up — builder must skip it and scan instead. + reader.Setup(r => r.GetSessionsIndexRowAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((TableEntity?)null); + reader.Setup(r => r.GetEntityOrNullAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((TableEntity?)null); + reader.Setup(r => r.QueryAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((tableName, filter, _) => + tableName == Constants.TableNames.SessionsIndex + && filter.Contains($"SessionId eq '{SessionId}'", StringComparison.Ordinal) + ? AsyncFrom(new[] { orphanedIndexRow }) + : EmptyAsyncEnumerable()); + + var manifest = await NewBuilder(reader.Object).BuildAsync( + TenantId, SessionId, "admin_delete", + new DeletionActor { Type = "admin", Actor = "alice@example.com" }, + new DeletionRetentionContext { TenantRetentionDays = 90 }); + + var tombstone = manifest.Steps.Last(); + Assert.Equal(DeletionStepClass.Final, tombstone.Class); + Assert.Equal(2, tombstone.Rows.Count); + // SessionsIndex first (recovered via fallback scan), then Sessions. + Assert.Equal("FALLBACK_" + SessionId, tombstone.Rows[0].Rk); + Assert.Equal(SessionId, tombstone.Rows[1].Rk); + } + + [Fact] + public async Task Build_falls_back_to_sessionsIndex_partition_scan_when_direct_lookup_returns_null() + { + // Edge: Sessions.IndexRowKey points at a SessionsIndex row that has already been deleted + // (or never existed). The direct lookup returns 404 → builder must still scan to catch any + // OTHER index row that happens to reference this SessionId. Defensive: handles the case + // where IndexRowKey drifted between Sessions write and cascade build. + const string staleIndexRowKey = "STALE_KEY"; + var sessionRow = new TableEntity(TenantId, SessionId) + { + ["IndexRowKey"] = staleIndexRowKey, + ["Status"] = "Succeeded", + }; + sessionRow.ETag = new ETag("0xSESS"); + + var recoveredIndexRow = new TableEntity(TenantId, "RECOVERED_" + SessionId) + { + ["SessionId"] = SessionId, + ["Status"] = "Succeeded", + }; + recoveredIndexRow.ETag = new ETag("0xIDX"); + + var reader = new Mock(); + reader.Setup(r => r.GetSessionRowAsync(TenantId, SessionId, It.IsAny())) + .ReturnsAsync(sessionRow); + reader.Setup(r => r.GetSessionsIndexRowAsync(TenantId, staleIndexRowKey, It.IsAny())) + .ReturnsAsync((TableEntity?)null); // stale key → 404 + reader.Setup(r => r.GetEntityOrNullAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((TableEntity?)null); + reader.Setup(r => r.QueryAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((tableName, filter, _) => + tableName == Constants.TableNames.SessionsIndex + && filter.Contains($"SessionId eq '{SessionId}'", StringComparison.Ordinal) + ? AsyncFrom(new[] { recoveredIndexRow }) + : EmptyAsyncEnumerable()); + + var manifest = await NewBuilder(reader.Object).BuildAsync( + TenantId, SessionId, "admin_delete", + new DeletionActor { Type = "admin", Actor = "alice@example.com" }, + new DeletionRetentionContext { TenantRetentionDays = 90 }); + + var tombstone = manifest.Steps.Last(); + Assert.Equal(2, tombstone.Rows.Count); + Assert.Equal("RECOVERED_" + SessionId, tombstone.Rows[0].Rk); + } + + [Fact] + public async Task Build_handles_missing_sessions_row_without_throwing() + { + // Edge: cascade preflight fired against an already-deleted session. Builder must not throw; + // the tombstone step just carries no rows. + var reader = new Mock(); + reader.Setup(r => r.GetSessionRowAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((TableEntity?)null); + reader.Setup(r => r.GetSessionsIndexRowAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((TableEntity?)null); + reader.Setup(r => r.GetEntityOrNullAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((TableEntity?)null); + reader.Setup(r => r.QueryAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(EmptyAsyncEnumerable()); + + var manifest = await NewBuilder(reader.Object).BuildAsync( + TenantId, SessionId, "admin_delete", + new DeletionActor { Type = "admin", Actor = "alice@example.com" }, + new DeletionRetentionContext { TenantRetentionDays = 90 }); + + var tombstone = manifest.Steps.Last(); + Assert.Equal(DeletionStepClass.Final, tombstone.Class); + Assert.Equal(0, tombstone.RowCount); + Assert.Empty(tombstone.Rows); + } + + // ---------------------------------------------------------------- Test-fixture plumbing ---- + + private static DeletionManifestBuilder NewBuilder(ISessionDeletionInventoryReader reader) + => new DeletionManifestBuilder(reader, NullLogger.Instance); + + private static ISessionDeletionInventoryReader NewReader(Action seedAction) + { + var seed = new ReaderSeed(); + seedAction(seed); + return seed.Build(); + } + + private static TableEntity MakeContributionsRow(List keys, bool compressed) + { + var json = JsonSerializer.Serialize(keys, DeletionManifestJson.SerializerOptions); + string stored; + if (compressed) + { + using var output = new MemoryStream(); + using (var gzip = new GZipStream(output, CompressionLevel.Optimal, leaveOpen: true)) + { + var bytes = Encoding.UTF8.GetBytes(json); + gzip.Write(bytes, 0, bytes.Length); + } + stored = Convert.ToBase64String(output.ToArray()); + } + else + { + stored = json; + } + + return new TableEntity(TenantId, SessionId) + { + ["SoftwareKeysJson"] = stored, + ["IsCompressed"] = compressed, + ["CountedAt"] = new DateTime(2026, 5, 1, 12, 0, 0, DateTimeKind.Utc), + ["LastUpdatedAt"] = new DateTime(2026, 5, 12, 9, 0, 0, DateTimeKind.Utc), + ["KeyCount"] = keys.Count, + }; + } + + private static async IAsyncEnumerable EmptyAsyncEnumerable() + { + await Task.CompletedTask; + yield break; + } + + private static async IAsyncEnumerable AsyncFrom(IEnumerable source) + { + foreach (var entity in source) + { + yield return entity; + } + await Task.CompletedTask; + } + + /// + /// Fluent test-data seed. Every WithXxx registers Moq setups for the corresponding + /// table; tables not seeded return empty / null on the reader. + /// + private class ReaderSeed + { + private readonly Dictionary> _query = new(StringComparer.Ordinal); + private TableEntity? _sessionsRow; + private TableEntity? _sessionsIndexRow; + private TableEntity? _vulnerabilityReport; + private TableEntity? _deviceSnapshot; + private TableEntity? _eventSessionIndex; + private TableEntity? _sessionInventoryContributions; + private string _sessionsIndexRowKey = "INDEX_" + SessionId; + + public ReaderSeed WithSessionsRow(string? diagnosticsBlobName = null) + { + _sessionsRow = new TableEntity(TenantId, SessionId) + { + ["IndexRowKey"] = _sessionsIndexRowKey, + ["DiagnosticsBlobName"] = diagnosticsBlobName ?? string.Empty, + ["Status"] = "Succeeded", + }; + // Mark with a stable ETag so dumps look real. + _sessionsRow.ETag = new ETag("0xSESS"); + return this; + } + + public ReaderSeed WithSessionsIndexRow() + { + _sessionsIndexRow = new TableEntity(TenantId, _sessionsIndexRowKey) + { + ["SessionId"] = SessionId, + ["Status"] = "Succeeded", + }; + _sessionsIndexRow.ETag = new ETag("0xIDX"); + return this; + } + + public ReaderSeed WithEvents(int n, string propValue = "evt") => SeedSessionPartition(Constants.TableNames.Events, n, propValue); + public ReaderSeed WithRuleResults(int n, string propValue = "rule") => SeedSessionPartition(Constants.TableNames.RuleResults, n, propValue); + public ReaderSeed WithSignals(int n, string propValue = "sig") => SeedSessionPartition(Constants.TableNames.Signals, n, propValue); + public ReaderSeed WithDecisionTransitions(int n, string propValue = "tx") => SeedSessionPartition(Constants.TableNames.DecisionTransitions, n, propValue); + + public ReaderSeed WithAppInstallSummaries(int n, string propValue = "app") + { + var rows = new List(n); + for (int i = 0; i < n; i++) + { + var e = new TableEntity(TenantId, $"app{i}") + { + ["SessionId"] = SessionId, + ["Marker"] = propValue, + }; + e.ETag = new ETag("0xAPP" + i); + rows.Add(e); + } + _query[Constants.TableNames.AppInstallSummaries] = rows; + return this; + } + + public ReaderSeed WithVulnerabilityReport(string propValue = "vuln") + { + _vulnerabilityReport = new TableEntity($"{TenantId}_{SessionId}", "report") + { + ["Marker"] = propValue, + }; + _vulnerabilityReport.ETag = new ETag("0xVULN"); + return this; + } + + public ReaderSeed WithDeviceSnapshot(string propValue = "dev") + { + _deviceSnapshot = new TableEntity(TenantId, SessionId) + { + ["Marker"] = propValue, + }; + _deviceSnapshot.ETag = new ETag("0xDEV"); + return this; + } + + public ReaderSeed WithEventSessionIndex(string propValue = "esi") + { + _eventSessionIndex = new TableEntity(TenantId, SessionId) + { + ["Marker"] = propValue, + }; + _eventSessionIndex.ETag = new ETag("0xESI"); + return this; + } + + public ReaderSeed WithEventTypeIndex(int n, string propValue = "eti") + { + // PK = {tenantId}_{eventType}, RK ends with _{sessionId}. + var rows = new List(n); + for (int i = 0; i < n; i++) + { + var e = new TableEntity($"{TenantId}_evt{i}", $"00000000000000{i:D5}_{SessionId}") + { + ["Marker"] = propValue, + ["SessionId"] = SessionId, + }; + e.ETag = new ETag("0xETI" + i); + rows.Add(e); + } + _query[Constants.TableNames.EventTypeIndex] = rows; + return this; + } + + public ReaderSeed WithCveIndex(int n, string propValue = "cve") + { + // PK = {tenantId}_{cveId}, RK = sessionId. + var rows = new List(n); + for (int i = 0; i < n; i++) + { + var e = new TableEntity($"{TenantId}_CVE-2026-{i:D4}", SessionId) + { + ["Marker"] = propValue, + ["SessionId"] = SessionId, + }; + e.ETag = new ETag("0xCVE" + i); + rows.Add(e); + } + _query[Constants.TableNames.CveIndex] = rows; + return this; + } + + public ReaderSeed WithSessionsByTerminal(int n, string propValue = "term") => SeedDiscriminatorPkProp(Constants.TableNames.SessionsByTerminal, n, propValue, "Failed"); + public ReaderSeed WithSessionsByStage(int n, string propValue = "stg") => SeedDiscriminatorPkProp(Constants.TableNames.SessionsByStage, n, propValue, "AccountSetup"); + public ReaderSeed WithDeadEndsByReason(int n, string propValue = "de") => SeedDiscriminatorPkProp(Constants.TableNames.DeadEndsByReason, n, propValue, "Timeout"); + public ReaderSeed WithClassifierVerdictsByIdLevel(int n, string propValue = "cls") => SeedDiscriminatorPkProp(Constants.TableNames.ClassifierVerdictsByIdLevel, n, propValue, "EspApps_High"); + public ReaderSeed WithSignalsByKind(int n, string propValue = "sk") => SeedDiscriminatorPkProp(Constants.TableNames.SignalsByKind, n, propValue, "EspPhaseChanged"); + + public ReaderSeed WithSessionInventoryContributions(TableEntity row) + { + _sessionInventoryContributions = row; + _sessionInventoryContributions.ETag = new ETag("0xCONTRIB"); + return this; + } + + public ISessionDeletionInventoryReader Build() + { + var reader = new Mock(); + + reader.Setup(r => r.GetSessionRowAsync(TenantId, SessionId, It.IsAny())) + .ReturnsAsync(_sessionsRow); + reader.Setup(r => r.GetSessionsIndexRowAsync(TenantId, _sessionsIndexRowKey, It.IsAny())) + .ReturnsAsync(_sessionsIndexRow); + + // PK_RK_EXACT lookups + reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.VulnerabilityReports, It.IsAny(), "report", It.IsAny())) + .ReturnsAsync(_vulnerabilityReport); + reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.DeviceSnapshot, TenantId, SessionId, It.IsAny())) + .ReturnsAsync(_deviceSnapshot); + reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.EventSessionIndex, TenantId, SessionId, It.IsAny())) + .ReturnsAsync(_eventSessionIndex); + reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.SessionInventoryContributions, TenantId, SessionId, It.IsAny())) + .ReturnsAsync(_sessionInventoryContributions); + // Default null for any other GetEntityOrNullAsync. + reader.Setup(r => r.GetEntityOrNullAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((TableEntity?)null); + // Re-register the specific ones AFTER the default catch-all (Moq honors the most recent setup). + reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.VulnerabilityReports, It.IsAny(), "report", It.IsAny())) + .ReturnsAsync(_vulnerabilityReport); + reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.DeviceSnapshot, TenantId, SessionId, It.IsAny())) + .ReturnsAsync(_deviceSnapshot); + reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.EventSessionIndex, TenantId, SessionId, It.IsAny())) + .ReturnsAsync(_eventSessionIndex); + reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.SessionInventoryContributions, TenantId, SessionId, It.IsAny())) + .ReturnsAsync(_sessionInventoryContributions); + + // QueryAsync: per-table seeded list, otherwise empty. + reader.Setup(r => r.QueryAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((tableName, _, _) => + _query.TryGetValue(tableName, out var rows) + ? AsyncFrom(rows) + : EmptyAsyncEnumerable()); + + return reader.Object; + } + + private ReaderSeed SeedSessionPartition(string tableName, int n, string propValue) + { + var partitionKey = $"{TenantId}_{SessionId}"; + var rows = new List(n); + for (int i = 0; i < n; i++) + { + var e = new TableEntity(partitionKey, $"{tableName}_{i:D5}") + { + ["Marker"] = propValue, + ["SessionId"] = SessionId, + }; + e.ETag = new ETag("0x" + tableName + i); + rows.Add(e); + } + _query[tableName] = rows; + return this; + } + + private ReaderSeed SeedDiscriminatorPkProp(string tableName, int n, string propValue, string discriminator) + { + // PK = {tenantId}_{discriminator}, server-side filter on PK prefix + SessionId == sessionId. + var rows = new List(n); + for (int i = 0; i < n; i++) + { + var e = new TableEntity($"{TenantId}_{discriminator}", $"{tableName}_{i:D5}") + { + ["Marker"] = propValue, + ["SessionId"] = SessionId, + }; + e.ETag = new ETag("0x" + tableName + i); + rows.Add(e); + } + _query[tableName] = rows; + return this; + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/DeletionManifestSerializationTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/DeletionManifestSerializationTests.cs new file mode 100644 index 00000000..cb96f1dd --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/DeletionManifestSerializationTests.cs @@ -0,0 +1,187 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using AutopilotMonitor.Shared.Models.Deletion; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Pure JSON serialization round-trip tests. The cascade plan §3 schema is the wire contract; +/// both producer + worker depend on byte-faithful round-trip of the manifest types. +/// +public class DeletionManifestSerializationTests +{ + [Fact] + public void Manifest_round_trips_through_system_text_json_with_camel_case() + { + var original = BuildSampleManifest(); + + var json = JsonSerializer.Serialize(original, DeletionManifestJson.SerializerOptions); + + // Camel-case casing per plan §3 (top-level fields). + Assert.Contains("\"manifestId\":", json); + Assert.Contains("\"tenantId\":", json); + Assert.Contains("\"preflightCounts\":", json); + Assert.Contains("\"steps\":", json); + Assert.Contains("\"schemaHash\":", json); + // PascalCase shouldn't leak. + Assert.DoesNotContain("\"ManifestId\":", json); + Assert.DoesNotContain("\"Steps\":", json); + + var decoded = JsonSerializer.Deserialize(json, DeletionManifestJson.SerializerOptions); + + Assert.NotNull(decoded); + Assert.Equal(original.ManifestId, decoded!.ManifestId); + Assert.Equal(original.TenantId, decoded.TenantId); + Assert.Equal(original.SessionId, decoded.SessionId); + Assert.Equal(original.Reason, decoded.Reason); + Assert.Equal(original.SchemaHash, decoded.SchemaHash); + Assert.Equal(original.PreflightCounts.Count, decoded.PreflightCounts.Count); + Assert.Equal(original.Steps.Count, decoded.Steps.Count); + } + + [Fact] + public void DeletionStep_AGGREGATE_carries_decrements_but_no_rows() + { + var step = new DeletionStep + { + Order = 16, + Step = DeletionStepNames.SoftwareInventoryDecrement, + Class = DeletionStepClass.Aggregate, + RowCount = 2, + Rows = new List(), + Decrements = new List + { + new DeletionDecrementKey { Vendor = "Contoso", Name = "Widget", Version = "1.0" }, + new DeletionDecrementKey { Vendor = "Fabrikam", Name = "Gadget", Version = "2.0" }, + }, + }; + + var json = JsonSerializer.Serialize(step, DeletionManifestJson.SerializerOptions); + var decoded = JsonSerializer.Deserialize(json, DeletionManifestJson.SerializerOptions); + + Assert.NotNull(decoded); + Assert.Equal(DeletionStepClass.Aggregate, decoded!.Class); + Assert.Empty(decoded.Rows); + Assert.NotNull(decoded.Decrements); + Assert.Equal(2, decoded.Decrements!.Count); + Assert.Equal("Contoso", decoded.Decrements[0].Vendor); + Assert.Equal("Widget", decoded.Decrements[0].Name); + // Plan §3: AGGREGATE steps have decrements but no Rows entries. + Assert.True(decoded.Rows.Count == 0); + } + + [Fact] + public void DeletionStep_FINAL_carries_two_rows_in_order() + { + // Plan §5 PR4: tombstone deletes SessionsIndex FIRST, then Sessions. + var step = new DeletionStep + { + Order = 18, + Step = DeletionStepNames.Tombstone, + Class = DeletionStepClass.Final, + RowCount = 2, + Rows = new List + { + new DeletionRowDump { Pk = "tenant", Rk = "indexRowKey", Etag = "0x1" }, + new DeletionRowDump { Pk = "tenant", Rk = "sessionId", Etag = "0x2" }, + }, + }; + + var json = JsonSerializer.Serialize(step, DeletionManifestJson.SerializerOptions); + var decoded = JsonSerializer.Deserialize(json, DeletionManifestJson.SerializerOptions); + + Assert.NotNull(decoded); + Assert.Equal(DeletionStepClass.Final, decoded!.Class); + Assert.Equal(2, decoded.Rows.Count); + Assert.Equal("indexRowKey", decoded.Rows[0].Rk); + Assert.Equal("sessionId", decoded.Rows[1].Rk); + } + + [Fact] + public void DeletionRowDump_typed_props_round_trip() + { + var props = new Dictionary(StringComparer.Ordinal) + { + ["StringProp"] = new DeletionPropValue { EdmType = DeletionPropEdmType.String, Value = ToElement("\"hello\"") }, + ["IntProp"] = new DeletionPropValue { EdmType = DeletionPropEdmType.Int32, Value = ToElement("42") }, + ["BoolProp"] = new DeletionPropValue { EdmType = DeletionPropEdmType.Boolean, Value = ToElement("true") }, + ["NumberProp"] = new DeletionPropValue { EdmType = DeletionPropEdmType.Double, Value = ToElement("3.14") }, + }; + var dump = new DeletionRowDump { Pk = "pk", Rk = "rk", Etag = "0x1", Props = props }; + + var json = JsonSerializer.Serialize(dump, DeletionManifestJson.SerializerOptions); + var decoded = JsonSerializer.Deserialize(json, DeletionManifestJson.SerializerOptions); + + Assert.NotNull(decoded); + Assert.Equal(DeletionPropEdmType.String, decoded!.Props["StringProp"].EdmType); + Assert.Equal(DeletionPropEdmType.Int32, decoded.Props["IntProp"].EdmType); + Assert.Equal(DeletionPropEdmType.Boolean, decoded.Props["BoolProp"].EdmType); + Assert.Equal(DeletionPropEdmType.Double, decoded.Props["NumberProp"].EdmType); + + Assert.Equal("hello", decoded.Props["StringProp"].Value.GetString()); + Assert.Equal(42, decoded.Props["IntProp"].Value.GetInt32()); + Assert.True(decoded.Props["BoolProp"].Value.GetBoolean()); + Assert.Equal(3.14, decoded.Props["NumberProp"].Value.GetDouble(), 5); + } + + [Fact] + public void DeletionProgress_round_trips_under_500_bytes() + { + // Plan §3 R9: progress blob is intentionally minimal so an operator can open it during incident response. + var progress = new DeletionProgress + { + SnapshotSha256 = new string('a', 64), + CompletedSteps = new HashSet { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17 }, + VerificationDone = true, + CompletedAt = new DateTime(2026, 5, 12, 10, 0, 0, DateTimeKind.Utc), + }; + + var json = JsonSerializer.SerializeToUtf8Bytes(progress, DeletionManifestJson.SerializerOptions); + + Assert.True(json.Length < 500, $"Progress blob serialized to {json.Length} bytes — expected <500 per §3 R9."); + + var decoded = JsonSerializer.Deserialize(json, DeletionManifestJson.SerializerOptions); + Assert.NotNull(decoded); + Assert.Equal(progress.SnapshotSha256, decoded!.SnapshotSha256); + Assert.Equal(progress.CompletedSteps, decoded.CompletedSteps); + Assert.True(decoded.VerificationDone); + Assert.Equal(progress.CompletedAt, decoded.CompletedAt); + } + + private static DeletionManifest BuildSampleManifest() => new DeletionManifest + { + ManifestId = "0123456789ABCDEF_FEDCBA9876543210", + ManifestVersion = 1, + TenantId = "11111111-1111-1111-1111-111111111111", + SessionId = "22222222-2222-2222-2222-222222222222", + CreatedAt = new DateTime(2026, 5, 12, 10, 0, 0, DateTimeKind.Utc), + CreatedBy = new DeletionActor { Type = "admin", Actor = "alice@example.com" }, + Reason = "admin_delete", + RetentionContext = new DeletionRetentionContext { TenantRetentionDays = 90 }, + PreflightCounts = new Dictionary { ["events"] = 5, ["ruleResults"] = 1 }, + DiagnosticsBlobName = "diag.zip", + Steps = new List + { + new DeletionStep + { + Order = 1, + Table = "Events", + Class = DeletionStepClass.PkBySession, + RowCount = 1, + Rows = new List + { + new DeletionRowDump { Pk = "t_s", Rk = "evt", Etag = "0x1" }, + }, + }, + }, + SchemaHash = "sha256:abc", + }; + + private static JsonElement ToElement(string json) + { + using var doc = JsonDocument.Parse(json); + return doc.RootElement.Clone(); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Program.cs b/src/Backend/AutopilotMonitor.Functions/Program.cs index 32b6224f..f294ee7b 100644 --- a/src/Backend/AutopilotMonitor.Functions/Program.cs +++ b/src/Backend/AutopilotMonitor.Functions/Program.cs @@ -126,6 +126,11 @@ builder.Services.AddMemoryCache(); builder.Services.AddHttpClient(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +// Cascade-deletion read surface implemented by the TableStorageService partial (PR1). +// No production caller in PR1; producer (PR3) + worker (PR4) consume it later. +builder.Services.AddSingleton( + sp => sp.GetRequiredService()); +builder.Services.AddSingleton(); builder.Services.AddHostedService(); // Initialize all tables at startup // Data Access Layer — repository interfaces backed by Table Storage. diff --git a/src/Backend/AutopilotMonitor.Functions/Services/BlobStorageService.cs b/src/Backend/AutopilotMonitor.Functions/Services/BlobStorageService.cs index f0f5fd1b..baa0232d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/BlobStorageService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/BlobStorageService.cs @@ -1,8 +1,13 @@ +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text.Json; +using Azure; using Azure.Identity; using Azure.Storage.Blobs; using Azure.Storage.Blobs.Models; using Azure.Storage.Blobs.Specialized; using Azure.Storage.Sas; +using AutopilotMonitor.Shared.Models.Deletion; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; @@ -15,6 +20,10 @@ namespace AutopilotMonitor.Functions.Services /// public class BlobStorageService { + // Container that holds cascade-deletion manifests (snapshot + progress blobs). + // Plan §3 + §10: 30d Lifecycle delete + 3d soft-delete = effective max 33-day retention. + private const string DeletionManifestsContainer = "deletion-manifests"; + private readonly BlobServiceClient _blobServiceClient; private readonly ILogger _logger; private readonly bool _usesManagedIdentity; @@ -46,6 +55,17 @@ namespace AutopilotMonitor.Functions.Services } } + /// + /// Test seam: construct directly from a (possibly Moq'd) . + /// Used by xUnit so manifest upload/download paths can be exercised without hitting Azure. + /// + internal BlobStorageService(BlobServiceClient blobServiceClient, ILogger logger, bool usesManagedIdentity = false) + { + _blobServiceClient = blobServiceClient; + _logger = logger; + _usesManagedIdentity = usesManagedIdentity; + } + /// /// Gets a BlobContainerClient for the specified container. /// @@ -93,5 +113,176 @@ namespace AutopilotMonitor.Functions.Services return blobClient.Uri.ToString(); } } + + // ============================================================ Cascade-deletion manifests ==== + + /// + /// Uploads the immutable snapshot blob for a cascade-deletion manifest. Path: + /// {tenantId}/{sessionId}/{manifestId}.snapshot.json.gz. Gzipped on the wire, + /// SHA-256 of the uncompressed payload pinned to the blob's sha256 metadata. + /// Uses If-None-Match=* so a duplicate-id upload fails loud — the snapshot is + /// supposed to be written exactly once per producer attempt (plan §1 P1). + /// + public async Task UploadDeletionManifestAsync( + DeletionManifest manifest, CancellationToken cancellationToken = default) + { + if (manifest == null) throw new ArgumentNullException(nameof(manifest)); + if (string.IsNullOrEmpty(manifest.ManifestId)) throw new ArgumentException("Manifest.ManifestId is required", nameof(manifest)); + if (string.IsNullOrEmpty(manifest.TenantId)) throw new ArgumentException("Manifest.TenantId is required", nameof(manifest)); + if (string.IsNullOrEmpty(manifest.SessionId)) throw new ArgumentException("Manifest.SessionId is required", nameof(manifest)); + + var uncompressed = JsonSerializer.SerializeToUtf8Bytes(manifest, DeletionManifestJson.SerializerOptions); + + string sha256Hex; + using (var sha = SHA256.Create()) + { + sha256Hex = Convert.ToHexString(sha.ComputeHash(uncompressed)).ToLowerInvariant(); + } + + byte[] gzipped; + using (var output = new MemoryStream()) + { + using (var gzip = new GZipStream(output, CompressionLevel.Optimal, leaveOpen: true)) + { + await gzip.WriteAsync(uncompressed, 0, uncompressed.Length, cancellationToken); + } + gzipped = output.ToArray(); + } + + var blobName = BuildSnapshotBlobName(manifest.TenantId, manifest.SessionId, manifest.ManifestId); + var options = new BlobUploadOptions + { + HttpHeaders = new BlobHttpHeaders + { + ContentType = "application/json", + ContentEncoding = "gzip", + }, + Metadata = new Dictionary(StringComparer.Ordinal) + { + ["sha256"] = sha256Hex, + }, + Conditions = new BlobRequestConditions + { + IfNoneMatch = ETag.All, + }, + }; + + await WriteDeletionManifestBlobAsync(blobName, gzipped, options, cancellationToken); + + _logger.LogInformation( + "Uploaded deletion manifest snapshot blob={Blob} sha256={Sha} sizeBytes={Size}", + blobName, sha256Hex, gzipped.Length); + + return new DeletionManifestBlobPointer + { + ContainerName = DeletionManifestsContainer, + BlobName = blobName, + SnapshotSha256 = sha256Hex, + SizeBytes = gzipped.Length, + }; + } + + /// + /// Downloads the immutable snapshot blob, gunzips it, deserializes the manifest, and + /// verifies the uncompressed-payload SHA-256 against the blob's sha256 metadata. + /// Throws on hash mismatch (corruption signal — plan §13 + /// restore semantics rely on this); rethrows the underlying + /// on 404 or other storage error. + /// + public async Task DownloadDeletionManifestAsync( + string tenantId, string sessionId, string manifestId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(tenantId)) throw new ArgumentException("tenantId is required", nameof(tenantId)); + if (string.IsNullOrEmpty(sessionId)) throw new ArgumentException("sessionId is required", nameof(sessionId)); + if (string.IsNullOrEmpty(manifestId)) throw new ArgumentException("manifestId is required", nameof(manifestId)); + + var blobName = BuildSnapshotBlobName(tenantId, sessionId, manifestId); + var (gzipped, metadata) = await ReadDeletionManifestBlobAsync(blobName, cancellationToken); + + byte[] uncompressed; + using (var input = new MemoryStream(gzipped, writable: false)) + using (var gunzip = new GZipStream(input, CompressionMode.Decompress)) + using (var output = new MemoryStream()) + { + await gunzip.CopyToAsync(output, cancellationToken); + uncompressed = output.ToArray(); + } + + string actualSha256; + using (var sha = SHA256.Create()) + { + actualSha256 = Convert.ToHexString(sha.ComputeHash(uncompressed)).ToLowerInvariant(); + } + + string? expectedSha256 = null; + if (metadata != null && metadata.TryGetValue("sha256", out var meta)) + { + expectedSha256 = meta; + } + + if (string.IsNullOrEmpty(expectedSha256)) + { + throw new InvalidDataException( + $"Deletion manifest blob {blobName} is missing the required 'sha256' metadata; refusing to use it (corruption signal)."); + } + if (!string.Equals(expectedSha256, actualSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException( + $"Deletion manifest blob {blobName} SHA-256 mismatch: expected {expectedSha256}, got {actualSha256} (corruption signal)."); + } + + var manifest = JsonSerializer.Deserialize(uncompressed, DeletionManifestJson.SerializerOptions); + if (manifest == null) + { + throw new InvalidDataException($"Deletion manifest blob {blobName} deserialized to null."); + } + return manifest; + } + + /// + /// Test seam: writes the gzipped manifest blob with the supplied upload options. The + /// production override calls Azure Blob Storage; tests subclass this method to capture + /// the bytes + options without spinning up Azurite or wrestling with BlobsModelFactory. + /// + protected internal virtual async Task WriteDeletionManifestBlobAsync( + string blobName, byte[] gzipped, BlobUploadOptions options, CancellationToken cancellationToken) + { + var containerClient = _blobServiceClient.GetBlobContainerClient(DeletionManifestsContainer); + await containerClient.CreateIfNotExistsAsync(cancellationToken: cancellationToken); + var blobClient = containerClient.GetBlobClient(blobName); + using var ms = new MemoryStream(gzipped, writable: false); + await blobClient.UploadAsync(ms, options, cancellationToken); + } + + /// + /// Test seam: reads the gzipped manifest blob and its metadata. Production override + /// calls Azure Blob Storage; tests subclass this method to return canned bytes. + /// + protected internal virtual async Task<(byte[] Gzipped, IDictionary? Metadata)> + ReadDeletionManifestBlobAsync(string blobName, CancellationToken cancellationToken) + { + var containerClient = _blobServiceClient.GetBlobContainerClient(DeletionManifestsContainer); + var blobClient = containerClient.GetBlobClient(blobName); + using var downloaded = new MemoryStream(); + await blobClient.DownloadToAsync(downloaded, cancellationToken); + var properties = await blobClient.GetPropertiesAsync(cancellationToken: cancellationToken); + return (downloaded.ToArray(), properties.Value?.Metadata); + } + + private static string BuildSnapshotBlobName(string tenantId, string sessionId, string manifestId) + => $"{tenantId}/{sessionId}/{manifestId}.snapshot.json.gz"; + } + + /// + /// Pointer record returned by . + /// Producer (PR3) persists this in the audit log + uses the SHA to bind the snapshot to its + /// progress blob. + /// + public class DeletionManifestBlobPointer + { + public string ContainerName { get; set; } = string.Empty; + public string BlobName { get; set; } = string.Empty; + public string SnapshotSha256 { get; set; } = string.Empty; + public long SizeBytes { get; set; } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Deletion/DeletionManifestBuilder.cs b/src/Backend/AutopilotMonitor.Functions/Services/Deletion/DeletionManifestBuilder.cs new file mode 100644 index 00000000..91cf8aa5 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/Deletion/DeletionManifestBuilder.cs @@ -0,0 +1,536 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Azure.Data.Tables; +using AutopilotMonitor.Functions.Security; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models.Deletion; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Services.Deletion +{ + /// + /// Read-only enumerator that produces a snapshot of every + /// row a cascade-delete would target for a given (tenant, session). PR1 ships only the + /// builder; the producer + worker that actually use the manifest land in PR3 / PR4. + /// + /// Per plan §1 P1 / §3: the manifest IS the backup. A row not captured here will not be + /// deleted; a row captured here can be restored byte-faithful from the JSON dump. The + /// builder never deletes, never CAS-locks, never uploads — it only reads. + /// + /// + public class DeletionManifestBuilder + { + private readonly ISessionDeletionInventoryReader _reader; + private readonly ILogger _logger; + + // Azure Tables system properties that should not appear in the per-row Props bag. + private static readonly HashSet SystemPropertyNames = new HashSet(StringComparer.Ordinal) + { + "PartitionKey", "RowKey", "Timestamp", "odata.etag", + }; + + // Per-row dump for the rare 0-row table is allowed; per-class step is always emitted. + private static readonly JsonSerializerOptions HashSerializerOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false, + }; + + public DeletionManifestBuilder(ISessionDeletionInventoryReader reader, ILogger logger) + { + _reader = reader; + _logger = logger; + } + + /// + /// Builds a complete cascade snapshot for the given session. Read-only; safe to call + /// from a preview endpoint. The returned manifest carries full row dumps suitable for + /// both forward execution (cascade) and reverse execution (restore). + /// + public async Task BuildAsync( + string tenantId, + string sessionId, + string reason, + DeletionActor actor, + DeletionRetentionContext retentionContext, + CancellationToken cancellationToken = default) + { + var manifest = new DeletionManifest + { + ManifestId = NewManifestId(), + ManifestVersion = 1, + TenantId = tenantId, + SessionId = sessionId, + CreatedAt = DateTime.UtcNow, + CreatedBy = actor, + Reason = reason, + RetentionContext = retentionContext, + }; + + // Step A — load the Sessions + SessionsIndex rows up front. Both are needed for the + // FINAL tombstone step; the Sessions row also gives us IndexRowKey + DiagnosticsBlobName. + var sessionRow = await _reader.GetSessionRowAsync(tenantId, sessionId, cancellationToken); + TableEntity? sessionsIndexRow = null; + if (sessionRow != null) + { + manifest.DiagnosticsBlobName = sessionRow.GetString("DiagnosticsBlobName"); + sessionsIndexRow = await ResolveSessionsIndexRowAsync(tenantId, sessionId, sessionRow, cancellationToken); + } + + var safeTenantId = ODataSanitizer.EscapeValue(tenantId); + var safeSessionId = ODataSanitizer.EscapeValue(sessionId); + var compositeSessionPk = $"{tenantId}_{sessionId}"; + + // ---- Steps 1-15: cascade tables, in the §3 order. ---- + await AddPkBySessionStepAsync(manifest, order: 1, table: Constants.TableNames.Events, compositeSessionPk, safeTenantId, safeSessionId, cancellationToken); + await AddPkBySessionStepAsync(manifest, order: 2, table: Constants.TableNames.RuleResults, compositeSessionPk, safeTenantId, safeSessionId, cancellationToken); + await AddPropTenantPkStepAsync(manifest, order: 3, table: Constants.TableNames.AppInstallSummaries, tenantId, sessionId, safeTenantId, safeSessionId, cancellationToken); + await AddPkRkExactStepAsync(manifest, order: 4, table: Constants.TableNames.VulnerabilityReports, partitionKey: compositeSessionPk, rowKey: "report", cancellationToken); + await AddPkRkExactStepAsync(manifest, order: 5, table: Constants.TableNames.DeviceSnapshot, partitionKey: tenantId, rowKey: sessionId, cancellationToken); + await AddPkRkExactStepAsync(manifest, order: 6, table: Constants.TableNames.EventSessionIndex, partitionKey: tenantId, rowKey: sessionId, cancellationToken); + await AddPkBySessionStepAsync(manifest, order: 7, table: Constants.TableNames.Signals, compositeSessionPk, safeTenantId, safeSessionId, cancellationToken); + await AddPkBySessionStepAsync(manifest, order: 8, table: Constants.TableNames.DecisionTransitions, compositeSessionPk, safeTenantId, safeSessionId, cancellationToken); + await AddDiscriminatorPkRkSuffixStepAsync(manifest, order: 9, table: Constants.TableNames.EventTypeIndex, tenantId, sessionId, safeTenantId, cancellationToken); + await AddDiscriminatorPkRkExactStepAsync(manifest, order: 10, table: Constants.TableNames.CveIndex, tenantId, sessionId, safeTenantId, cancellationToken); + await AddDiscriminatorPkPropStepAsync(manifest, order: 11, table: Constants.TableNames.SessionsByTerminal, tenantId, sessionId, safeTenantId, safeSessionId, cancellationToken); + await AddDiscriminatorPkPropStepAsync(manifest, order: 12, table: Constants.TableNames.SessionsByStage, tenantId, sessionId, safeTenantId, safeSessionId, cancellationToken); + await AddDiscriminatorPkPropStepAsync(manifest, order: 13, table: Constants.TableNames.DeadEndsByReason, tenantId, sessionId, safeTenantId, safeSessionId, cancellationToken); + await AddDiscriminatorPkPropStepAsync(manifest, order: 14, table: Constants.TableNames.ClassifierVerdictsByIdLevel, tenantId, sessionId, safeTenantId, safeSessionId, cancellationToken); + await AddDiscriminatorPkPropStepAsync(manifest, order: 15, table: Constants.TableNames.SignalsByKind, tenantId, sessionId, safeTenantId, safeSessionId, cancellationToken); + + // ---- Steps 16 + 17: SoftwareInventory side-row (omit both for pre-side-row sessions). ---- + var contributionsRow = await _reader.GetEntityOrNullAsync( + Constants.TableNames.SessionInventoryContributions, tenantId, sessionId, cancellationToken); + if (contributionsRow != null) + { + AddSoftwareInventoryDecrementStep(manifest, order: 16, contributionsRow); + AddContributionsRowStep(manifest, order: 17, contributionsRow); + } + + // ---- Step 18: Tombstone (SessionsIndex first, then Sessions). ---- + AddTombstoneStep(manifest, order: 18, sessionsIndexRow, sessionRow); + + // PreflightCounts derive from each step's RowCount, plus the AGGREGATE decrements length. + manifest.PreflightCounts = ComputePreflightCounts(manifest); + + // SchemaHash is over the entire manifest content (with SchemaHash field blanked) so the + // worker can detect tampering between snapshot upload and pickup. + manifest.SchemaHash = ComputeSchemaHash(manifest); + + return manifest; + } + + // ============================================================ Step builders ============ + + private async Task AddPkBySessionStepAsync( + DeletionManifest manifest, int order, string table, + string compositeSessionPk, string safeTenantId, string safeSessionId, + CancellationToken cancellationToken) + { + var filter = $"PartitionKey eq '{safeTenantId}_{safeSessionId}'"; + var rows = new List(); + await foreach (var entity in _reader.QueryAsync(table, filter, cancellationToken)) + { + rows.Add(MapEntityToDump(entity)); + } + manifest.Steps.Add(new DeletionStep + { + Order = order, + Table = table, + Class = DeletionStepClass.PkBySession, + RowCount = rows.Count, + Rows = rows, + }); + } + + private async Task AddPropTenantPkStepAsync( + DeletionManifest manifest, int order, string table, + string tenantId, string sessionId, string safeTenantId, string safeSessionId, + CancellationToken cancellationToken) + { + var filter = $"PartitionKey eq '{safeTenantId}' and SessionId eq '{safeSessionId}'"; + var rows = new List(); + await foreach (var entity in _reader.QueryAsync(table, filter, cancellationToken)) + { + rows.Add(MapEntityToDump(entity)); + } + manifest.Steps.Add(new DeletionStep + { + Order = order, + Table = table, + Class = DeletionStepClass.PropTenantPk, + RowCount = rows.Count, + Rows = rows, + }); + } + + private async Task AddPkRkExactStepAsync( + DeletionManifest manifest, int order, string table, + string partitionKey, string rowKey, + CancellationToken cancellationToken) + { + var entity = await _reader.GetEntityOrNullAsync(table, partitionKey, rowKey, cancellationToken); + var rows = new List(); + if (entity != null) + { + rows.Add(MapEntityToDump(entity)); + } + manifest.Steps.Add(new DeletionStep + { + Order = order, + Table = table, + Class = DeletionStepClass.PkRkExact, + RowCount = rows.Count, + Rows = rows, + }); + } + + private async Task AddDiscriminatorPkRkSuffixStepAsync( + DeletionManifest manifest, int order, string table, + string tenantId, string sessionId, string safeTenantId, + CancellationToken cancellationToken) + { + // Server-side: PK prefix scan. Client-side: RK ends with "_{sessionId}". + var filter = $"PartitionKey ge '{safeTenantId}_' and PartitionKey lt '{safeTenantId}_~'"; + var suffix = $"_{sessionId}"; + var rows = new List(); + await foreach (var entity in _reader.QueryAsync(table, filter, cancellationToken)) + { + if (entity.RowKey != null && entity.RowKey.EndsWith(suffix, StringComparison.Ordinal)) + { + rows.Add(MapEntityToDump(entity)); + } + } + manifest.Steps.Add(new DeletionStep + { + Order = order, + Table = table, + Class = DeletionStepClass.DiscriminatorPkRkSuffix, + RowCount = rows.Count, + Rows = rows, + }); + } + + private async Task AddDiscriminatorPkRkExactStepAsync( + DeletionManifest manifest, int order, string table, + string tenantId, string sessionId, string safeTenantId, + CancellationToken cancellationToken) + { + // Server-side: PK prefix scan. Client-side: RK == sessionId. + var filter = $"PartitionKey ge '{safeTenantId}_' and PartitionKey lt '{safeTenantId}_~'"; + var rows = new List(); + await foreach (var entity in _reader.QueryAsync(table, filter, cancellationToken)) + { + if (string.Equals(entity.RowKey, sessionId, StringComparison.Ordinal)) + { + rows.Add(MapEntityToDump(entity)); + } + } + manifest.Steps.Add(new DeletionStep + { + Order = order, + Table = table, + Class = DeletionStepClass.DiscriminatorPkRkExact, + RowCount = rows.Count, + Rows = rows, + }); + } + + private async Task AddDiscriminatorPkPropStepAsync( + DeletionManifest manifest, int order, string table, + string tenantId, string sessionId, string safeTenantId, string safeSessionId, + CancellationToken cancellationToken) + { + // Server-side: PK prefix scan AND SessionId eq '{sessionId}'. Eligible because every + // DISCRIMINATOR_PK_PROP table writes the SessionId property explicitly (see IndexRowKeys + // call sites; the writers project SessionId so the server-side filter is reliable). + var filter = $"PartitionKey ge '{safeTenantId}_' and PartitionKey lt '{safeTenantId}_~' and SessionId eq '{safeSessionId}'"; + var rows = new List(); + await foreach (var entity in _reader.QueryAsync(table, filter, cancellationToken)) + { + rows.Add(MapEntityToDump(entity)); + } + manifest.Steps.Add(new DeletionStep + { + Order = order, + Table = table, + Class = DeletionStepClass.DiscriminatorPkProp, + RowCount = rows.Count, + Rows = rows, + }); + } + + private void AddSoftwareInventoryDecrementStep(DeletionManifest manifest, int order, TableEntity contributionsRow) + { + var decrements = DecodeDecrementKeys(contributionsRow); + manifest.Steps.Add(new DeletionStep + { + Order = order, + Step = DeletionStepNames.SoftwareInventoryDecrement, + Class = DeletionStepClass.Aggregate, + RowCount = decrements.Count, + Rows = new List(), + Decrements = decrements, + }); + } + + private void AddContributionsRowStep(DeletionManifest manifest, int order, TableEntity contributionsRow) + { + manifest.Steps.Add(new DeletionStep + { + Order = order, + Table = Constants.TableNames.SessionInventoryContributions, + Class = DeletionStepClass.PkRkExact, + RowCount = 1, + Rows = new List { MapEntityToDump(contributionsRow) }, + }); + } + + private void AddTombstoneStep(DeletionManifest manifest, int order, TableEntity? sessionsIndexRow, TableEntity? sessionRow) + { + var rows = new List(2); + // §5 PR4 mandates SessionsIndex deleted FIRST so UI listings drop the session before the canonical row goes. + if (sessionsIndexRow != null) + { + rows.Add(MapEntityToDump(sessionsIndexRow)); + } + if (sessionRow != null) + { + rows.Add(MapEntityToDump(sessionRow)); + } + manifest.Steps.Add(new DeletionStep + { + Order = order, + Step = DeletionStepNames.Tombstone, + Class = DeletionStepClass.Final, + RowCount = rows.Count, + Rows = rows, + }); + } + + // ============================================================ Mapping + helpers ======== + + /// + /// Locates the SessionsIndex row corresponding to the given Sessions row. Tries the + /// direct (PartitionKey, RowKey) lookup first using Sessions.IndexRowKey; falls + /// back to a partition-targeted scan on PartitionKey eq {tenantId} and SessionId eq + /// {sessionId} when the IndexRowKey is missing or stale, so a partially-corrupted + /// Sessions row can't strand a SessionsIndex orphan after cascade. + /// + private async Task ResolveSessionsIndexRowAsync( + string tenantId, string sessionId, TableEntity sessionRow, CancellationToken cancellationToken) + { + var indexRowKey = sessionRow.GetString("IndexRowKey"); + if (!string.IsNullOrEmpty(indexRowKey)) + { + var byKey = await _reader.GetSessionsIndexRowAsync(tenantId, indexRowKey!, cancellationToken); + if (byKey != null) return byKey; + } + + // Fallback: partition-targeted property scan. SessionsIndex stores SessionId as a + // typed column on every row; a single match is expected. + var safeTenantId = ODataSanitizer.EscapeValue(tenantId); + var safeSessionId = ODataSanitizer.EscapeValue(sessionId); + var filter = $"PartitionKey eq '{safeTenantId}' and SessionId eq '{safeSessionId}'"; + await foreach (var match in _reader.QueryAsync(Constants.TableNames.SessionsIndex, filter, cancellationToken)) + { + return match; + } + return null; + } + + private static DeletionRowDump MapEntityToDump(TableEntity entity) + { + var props = new Dictionary(entity.Count, StringComparer.Ordinal); + foreach (var key in entity.Keys) + { + if (SystemPropertyNames.Contains(key)) continue; + props[key] = ConvertToPropValue(entity[key]); + } + return new DeletionRowDump + { + Pk = entity.PartitionKey ?? string.Empty, + Rk = entity.RowKey ?? string.Empty, + Etag = entity.ETag.ToString(), + Props = props, + }; + } + + /// + /// Converts a single TableEntity property value into an EDM-tagged + /// . Restore reads + /// to choose the right strongly-typed entity setter — without the tag, a DateTime + /// timestamp coming out of JSON would be re-inserted as a plain string and the Azure + /// Tables column would silently change EDM type. + /// + private static DeletionPropValue ConvertToPropValue(object? value) + { + // null → null JsonElement, EdmType=String (Azure Tables has no first-class null type). + if (value == null) + { + return new DeletionPropValue + { + EdmType = DeletionPropEdmType.String, + Value = ParseJson("null"), + }; + } + + switch (value) + { + case string s: + return new DeletionPropValue { EdmType = DeletionPropEdmType.String, Value = ParseJson(JsonSerializer.Serialize(s)) }; + case bool b: + return new DeletionPropValue { EdmType = DeletionPropEdmType.Boolean, Value = ParseJson(b ? "true" : "false") }; + case int i: + return new DeletionPropValue { EdmType = DeletionPropEdmType.Int32, Value = ParseJson(i.ToString(System.Globalization.CultureInfo.InvariantCulture)) }; + case long l: + return new DeletionPropValue { EdmType = DeletionPropEdmType.Int64, Value = ParseJson(l.ToString(System.Globalization.CultureInfo.InvariantCulture)) }; + case double d: + return new DeletionPropValue { EdmType = DeletionPropEdmType.Double, Value = ParseJson(JsonSerializer.Serialize(d)) }; + case System.Guid g: + return new DeletionPropValue { EdmType = DeletionPropEdmType.Guid, Value = ParseJson(JsonSerializer.Serialize(g.ToString("D"))) }; + case byte[] bytes: + return new DeletionPropValue { EdmType = DeletionPropEdmType.Binary, Value = ParseJson(JsonSerializer.Serialize(Convert.ToBase64String(bytes))) }; + case DateTimeOffset dto: + return new DeletionPropValue { EdmType = DeletionPropEdmType.DateTime, Value = ParseJson(JsonSerializer.Serialize(dto.UtcDateTime.ToString("o", System.Globalization.CultureInfo.InvariantCulture))) }; + case DateTime dt: + return new DeletionPropValue { EdmType = DeletionPropEdmType.DateTime, Value = ParseJson(JsonSerializer.Serialize(dt.ToUniversalTime().ToString("o", System.Globalization.CultureInfo.InvariantCulture))) }; + default: + // Unknown type — fall back to round-tripping through JSON as a string. + return new DeletionPropValue + { + EdmType = DeletionPropEdmType.String, + Value = ParseJson(JsonSerializer.Serialize(value.ToString() ?? string.Empty)), + }; + } + } + + private static JsonElement ParseJson(string json) + { + using var doc = JsonDocument.Parse(json); + return doc.RootElement.Clone(); + } + + /// + /// Decodes the side-row's SoftwareKeysJson (raw JSON or gzip+Base64 per §17.7) into + /// the canonical list. Sorted deterministically by + /// (Vendor, Name, Version) so the manifest is byte-stable across builds with identical input. + /// + private List DecodeDecrementKeys(TableEntity contributionsRow) + { + var rawJson = contributionsRow.GetString("SoftwareKeysJson") ?? string.Empty; + var isCompressed = contributionsRow.GetBoolean("IsCompressed") ?? false; + if (string.IsNullOrEmpty(rawJson)) + { + return new List(); + } + + string decoded; + if (isCompressed) + { + try + { + var gz = Convert.FromBase64String(rawJson); + using var input = new MemoryStream(gz); + using var gunzip = new GZipStream(input, CompressionMode.Decompress); + using var output = new MemoryStream(); + gunzip.CopyTo(output); + decoded = Encoding.UTF8.GetString(output.ToArray()); + } + catch (Exception ex) + { + throw new InvalidDataException( + $"SessionInventoryContributions row for tenant {contributionsRow.PartitionKey} session {contributionsRow.RowKey} " + + "is marked IsCompressed=true but SoftwareKeysJson does not decode as Base64+gzip", ex); + } + } + else + { + decoded = rawJson; + } + + List keys; + try + { + keys = JsonSerializer.Deserialize>(decoded, DeletionManifestJson.SerializerOptions) + ?? new List(); + } + catch (JsonException ex) + { + throw new InvalidDataException( + $"SessionInventoryContributions.SoftwareKeysJson for tenant {contributionsRow.PartitionKey} session {contributionsRow.RowKey} " + + "is not valid JSON for List", ex); + } + + return keys + .OrderBy(k => k.Vendor, StringComparer.Ordinal) + .ThenBy(k => k.Name, StringComparer.Ordinal) + .ThenBy(k => k.Version, StringComparer.Ordinal) + .ToList(); + } + + private static Dictionary ComputePreflightCounts(DeletionManifest manifest) + { + // Convention: well-known camelCase keys per the §3 "preflightCounts" example. + var counts = new Dictionary(StringComparer.Ordinal); + foreach (var step in manifest.Steps) + { + if (step.Class == DeletionStepClass.Final) continue; // Tombstone is not a "count" entry + if (step.Class == DeletionStepClass.Aggregate) + { + counts["softwareInventoryDecrement"] = step.Decrements?.Count ?? 0; + continue; + } + if (string.IsNullOrEmpty(step.Table)) continue; + counts[CamelCase(step.Table!)] = step.RowCount; + } + return counts; + } + + private static string CamelCase(string s) + { + if (string.IsNullOrEmpty(s)) return s; + return char.ToLowerInvariant(s[0]) + s.Substring(1); + } + + private static string ComputeSchemaHash(DeletionManifest manifest) + { + // Hash over the manifest with SchemaHash blanked out, so the field can reproducibly + // be compared by the worker after download. See plan §3. + var savedHash = manifest.SchemaHash; + manifest.SchemaHash = string.Empty; + try + { + var json = JsonSerializer.SerializeToUtf8Bytes(manifest, HashSerializerOptions); + using var sha = SHA256.Create(); + var hash = sha.ComputeHash(json); + return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant(); + } + finally + { + manifest.SchemaHash = savedHash; + } + } + + private static string NewManifestId() + { + // ULID-shaped string: lex-sortable timestamp prefix + random tail. PR1 doesn't need a + // strict ULID library; the producer (PR3) will revisit if downstream consumers require + // monotonic ordering across producers. + var ticks = DateTime.UtcNow.Ticks; + var prefix = ticks.ToString("X16"); + var tail = Guid.NewGuid().ToString("N").Substring(0, 16).ToUpperInvariant(); + return prefix + "_" + tail; + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Deletion/ISessionDeletionInventoryReader.cs b/src/Backend/AutopilotMonitor.Functions/Services/Deletion/ISessionDeletionInventoryReader.cs new file mode 100644 index 00000000..9560a423 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/Deletion/ISessionDeletionInventoryReader.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Azure.Data.Tables; + +namespace AutopilotMonitor.Functions.Services.Deletion +{ + /// + /// Read-only access surface used by to enumerate every + /// table targeted by the cascade. Narrow on purpose: the builder gets exactly the four + /// shapes it needs, nothing more — keeps the test stub trivial. Production implementation is + /// the TableStorageService.Deletion.cs partial; tests inject a Moq. + /// + public interface ISessionDeletionInventoryReader + { + /// + /// Loads the canonical Sessions row, used for IndexRowKey lookup + + /// DiagnosticsBlobName capture (PR1) and DeletionState/PendingDeletionManifestId + /// (PR3+ — ignored in PR1). Returns null when the row does not exist. + /// + Task GetSessionRowAsync(string tenantId, string sessionId, CancellationToken cancellationToken = default); + + /// + /// Loads a SessionsIndex row by its (tenantId, indexRowKey) coordinate. Used for the + /// FINAL tombstone step's row dump. Returns null when missing (treated as already-gone). + /// + Task GetSessionsIndexRowAsync(string tenantId, string indexRowKey, CancellationToken cancellationToken = default); + + /// + /// Streams entities matching on . + /// The builder formulates the OData filter per step class; this method is a pure passthrough. + /// + IAsyncEnumerable QueryAsync(string tableName, string filter, CancellationToken cancellationToken = default); + + /// + /// Direct (PK, RK) lookup against . Returns null on 404, + /// rethrows any other . Used for PK_RK_EXACT + /// classes (VulnerabilityReports, DeviceSnapshot, EventSessionIndex, SessionInventoryContributions). + /// + Task GetEntityOrNullAsync(string tableName, string partitionKey, string rowKey, CancellationToken cancellationToken = default); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Deletion.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Deletion.cs new file mode 100644 index 00000000..a2fd2404 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Deletion.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Azure; +using Azure.Data.Tables; +using AutopilotMonitor.Functions.Services.Deletion; + +namespace AutopilotMonitor.Functions.Services +{ + /// + /// Cascade-deletion read surface. Implements + /// against the existing _tableServiceClient. Pure delegation; fail-loud on every + /// non-404 storage error per memory feedback_storage_helpers_fail_soft. + /// + public partial class TableStorageService : ISessionDeletionInventoryReader + { + public async Task GetSessionRowAsync(string tenantId, string sessionId, CancellationToken cancellationToken = default) + { + var tableClient = _tableServiceClient.GetTableClient(Shared.Constants.TableNames.Sessions); + try + { + var response = await tableClient.GetEntityAsync(tenantId, sessionId, cancellationToken: cancellationToken); + return response.Value; + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + return null; + } + } + + public async Task GetSessionsIndexRowAsync(string tenantId, string indexRowKey, CancellationToken cancellationToken = default) + { + var tableClient = _tableServiceClient.GetTableClient(Shared.Constants.TableNames.SessionsIndex); + try + { + var response = await tableClient.GetEntityAsync(tenantId, indexRowKey, cancellationToken: cancellationToken); + return response.Value; + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + return null; + } + } + + public async IAsyncEnumerable QueryAsync( + string tableName, + string filter, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var tableClient = _tableServiceClient.GetTableClient(tableName); + await foreach (var entity in tableClient.QueryAsync(filter, cancellationToken: cancellationToken)) + { + yield return entity; + } + } + + public async Task GetEntityOrNullAsync(string tableName, string partitionKey, string rowKey, CancellationToken cancellationToken = default) + { + var tableClient = _tableServiceClient.GetTableClient(tableName); + try + { + var response = await tableClient.GetEntityAsync(partitionKey, rowKey, cancellationToken: cancellationToken); + return response.Value; + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + return null; + } + } + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index 62ae72cd..dadac4ae 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -431,6 +431,12 @@ namespace AutopilotMonitor.Shared // Persistent software inventory per tenant (aggregated from enrollment snapshots) public const string SoftwareInventory = "SoftwareInventory"; + // One side-row per inventory-correlated session, keyed by (tenantId, sessionId). + // Drives at-most-once-per-session inventory counter increments via delta-update, + // and gives the cascade-delete the exact decrement keys at preflight time. + // Written by VulnerabilityCorrelationService (PR2); read by DeletionManifestBuilder (PR1). + public const string SessionInventoryContributions = "SessionInventoryContributions"; + // SLA breach status per tenant (one row per tenant, RowKey = "status"). // Persists across host recycles so SLA-breach notifications can be throttled // reliably and a GA cross-tenant overview can be served without re-aggregation. @@ -507,6 +513,7 @@ namespace AutopilotMonitor.Shared VulnerabilityCache, VulnerabilityReports, SoftwareInventory, + SessionInventoryContributions, SlaTenantStatus, EventTypeIndex, DeviceSnapshot, diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Deletion/DeletionManifest.cs b/src/Shared/AutopilotMonitor.Shared/Models/Deletion/DeletionManifest.cs new file mode 100644 index 00000000..fe2454c6 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/Deletion/DeletionManifest.cs @@ -0,0 +1,206 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AutopilotMonitor.Shared.Models.Deletion +{ + /// + /// Snapshot manifest for cascade-delete of a single session. The full set of rows targeted + /// by the cascade is captured here at preflight time, before any delete fires. This blob + /// is both the contract (cascade only deletes what's listed) and the backup (restore replays + /// the dumps back into the tables). See plan §3 for the canonical schema. + /// + public class DeletionManifest + { + /// ULID-shaped identifier of this manifest. Bound to a single producer attempt. + public string ManifestId { get; set; } = string.Empty; + + /// Schema version for forward-compatibility. v1 = 1. + public int ManifestVersion { get; set; } = 1; + + public string TenantId { get; set; } = string.Empty; + public string SessionId { get; set; } = string.Empty; + + /// UTC timestamp at which the manifest was built. + public DateTime CreatedAt { get; set; } + + /// Who or what triggered the cascade. + public DeletionActor CreatedBy { get; set; } = new DeletionActor(); + + /// Free-form reason string (e.g. "admin_delete", "retention_cutoff"). + public string Reason { get; set; } = string.Empty; + + /// Per-tenant retention context at the moment the cascade was triggered. + public DeletionRetentionContext RetentionContext { get; set; } = new DeletionRetentionContext(); + + /// Row counts per logical bucket; convenient for audit + monitoring without parsing every step. + public Dictionary PreflightCounts { get; set; } = new Dictionary(); + + /// + /// Customer-storage diagnostics blob name (informational only — cascade does NOT delete + /// the blob; customer owns the storage account). See plan §3 + §11. + /// + public string? DiagnosticsBlobName { get; set; } + + /// Ordered cascade steps. The worker executes them in order. + public List Steps { get; set; } = new List(); + + /// SHA-256 hash of the manifest contents (excluding this field). Used to detect tampering. + public string SchemaHash { get; set; } = string.Empty; + } + + /// + /// Identifies the trigger source of a cascade. is one of + /// "admin" or "maintenance"; is the user identity for + /// admin paths or "system" for maintenance. + /// + public class DeletionActor + { + public string Type { get; set; } = string.Empty; + public string Actor { get; set; } = string.Empty; + } + + /// + /// Per-tenant retention parameters captured at cascade-build time. Carried in the manifest + /// so the audit log shows *which* retention policy applied to a given retention-driven delete. + /// + public class DeletionRetentionContext + { + public int? TenantRetentionDays { get; set; } + public DateTime? CutoffUtc { get; set; } + } + + /// + /// One ordered step in the cascade. Either targets a real table ( set, + /// null) or a synthetic operation ( set, + /// null — only the SoftwareInventoryDecrement and Tombstone steps). + /// is one of the values defined in . + /// + public class DeletionStep + { + public int Order { get; set; } + + /// Table name; null for synthetic steps (Tombstone, SoftwareInventoryDecrement). + public string? Table { get; set; } + + /// Synthetic step name; null for table-targeted steps. Values from . + public string? Step { get; set; } + + /// Key-class taxonomy. Values from . + public string Class { get; set; } = string.Empty; + + public int RowCount { get; set; } + + /// Full row dumps. Empty for AGGREGATE steps; the Tombstone step always has 2 entries. + public List Rows { get; set; } = new List(); + + /// Decrement keys for the AGGREGATE step (SoftwareInventoryDecrement); null for all other classes. + public List? Decrements { get; set; } + } + + /// + /// Captured snapshot of a single Azure Table row. preserves the original + /// Azure Tables EDM type alongside the JSON value, so a restore can re-Insert the row with + /// the same column types — a bare alone would collapse + /// DateTime / Guid / byte[] / long into ambiguous strings, breaking the + /// manifest-as-backup guarantee (plan §3 "types preserved"). + /// + public class DeletionRowDump + { + public string Pk { get; set; } = string.Empty; + public string Rk { get; set; } = string.Empty; + + /// ETag as observed at preflight; informational only (not used for restore). + public string? Etag { get; set; } + + public Dictionary Props { get; set; } = new Dictionary(); + } + + /// + /// Single Azure Table column value with its EDM type tag. Restore looks at + /// to decide which Get* / strongly-typed entity setter to use; without the tag, a string + /// timestamp coming out of JSON would be re-inserted as a literal string and the column would + /// silently change shape. + /// + public class DeletionPropValue + { + /// + /// One of: String, Int32, Int64, Double, Boolean, + /// DateTime, Guid, Binary. Matches the Azure Tables OData EDM + /// type names (minus the Edm. prefix). + /// + public string EdmType { get; set; } = DeletionPropEdmType.String; + + /// JSON-encoded value. For DateTime / Guid / Binary this is the ISO-8601 / + /// GUID-string / Base64 form respectively. + public JsonElement Value { get; set; } + } + + /// EDM type constants used in . + public static class DeletionPropEdmType + { + public const string String = "String"; + public const string Int32 = "Int32"; + public const string Int64 = "Int64"; + public const string Double = "Double"; + public const string Boolean = "Boolean"; + public const string DateTime = "DateTime"; + public const string Guid = "Guid"; + public const string Binary = "Binary"; + } + + /// + /// One key targeted by the SoftwareInventoryDecrement AGGREGATE step. The cascade applies + /// SessionCount -= 1 with clamp-≥-0 on the matching SoftwareInventory row. + /// Shape mirrors the increment side in VulnerabilityCorrelationService. + /// + public class DeletionDecrementKey + { + public string Vendor { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Version { get; set; } = string.Empty; + } + + /// + /// Per-step key-class taxonomy. Encodes how preflight enumerates the table and how the worker + /// deletes (always by exact (PK,RK) at cascade time — see plan §1 P6 for why). + /// + public static class DeletionStepClass + { + public const string PkBySession = "PK_BY_SESSION"; + public const string PkRkExact = "PK_RK_EXACT"; + public const string PropTenantPk = "PROP_TENANT_PK"; + public const string DiscriminatorPkRkSuffix = "DISCRIMINATOR_PK_RK_SUFFIX"; + public const string DiscriminatorPkRkExact = "DISCRIMINATOR_PK_RK_EXACT"; + public const string DiscriminatorPkProp = "DISCRIMINATOR_PK_PROP"; + public const string Aggregate = "AGGREGATE"; + public const string Final = "FINAL"; + } + + /// Synthetic step names for non-table cascade operations. + public static class DeletionStepNames + { + public const string SoftwareInventoryDecrement = "SoftwareInventoryDecrement"; + public const string Tombstone = "Tombstone"; + } + + /// + /// Centralized JSON options for manifest (de)serialization. CamelCase casing matches the + /// schema documented in plan §3. + /// + public static class DeletionManifestJson + { + public static readonly JsonSerializerOptions SerializerOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + // DictionaryKeyPolicy is intentionally NOT set: uses + // the original Azure Table column names (e.g. "EventType", "MaxSeverity") as dictionary + // keys — those MUST round-trip byte-faithfully so a restore can re-insert the row with + // the correct column names. keys are + // already camelCase by construction. + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false, + }; + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Deletion/DeletionProgress.cs b/src/Shared/AutopilotMonitor.Shared/Models/Deletion/DeletionProgress.cs new file mode 100644 index 00000000..891701ed --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/Deletion/DeletionProgress.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; + +namespace AutopilotMonitor.Shared.Models.Deletion +{ + /// + /// Mutable progress companion to . Tracks which steps have + /// already executed and whether the live verification pass has run. Plan §3 (Round-2 R9): + /// schema is intentionally minimal — observability data goes to AuditLogs, not here. + /// Stored as the sibling {manifestId}.progress.json blob and CAS'd on every write. + /// + public class DeletionProgress + { + /// SHA-256 of the immutable snapshot blob; mismatch = corruption, refuse to proceed. + public string SnapshotSha256 { get; set; } = string.Empty; + + /// Step.Order values that have completed; the worker iterates the remaining ones. + public HashSet CompletedSteps { get; set; } = new HashSet(); + + /// True once the live verification pass succeeded; gates the FINAL tombstone step. + public bool VerificationDone { get; set; } + + /// UTC timestamp once the FINAL tombstone step has completed; null while in flight. + public DateTime? CompletedAt { get; set; } + } +}