mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
feat(backend): scaffold cascade-deletion manifest format + builder (PR1/7)
Read-only foundation for the safe cascade-delete plan (.claude/plans/session-deletion-cascade-safe.md). PR1 ships the manifest shape, the read-only DeletionManifestBuilder enumerator, and the gzip+SHA-256 blob upload/download plumbing. No production caller invokes any of this yet; no existing endpoint behaviour changes. PR2-PR7 wire the producer, worker, and call sites on top. Manifest captures all 15 cascade tables per the §3 taxonomy (4 PK_BY_SESSION, 1 PROP_TENANT_PK, 3 PK_RK_EXACT, 1 DISCRIMINATOR_PK_RK_SUFFIX, 1 DISCRIMINATOR_PK_RK_EXACT, 5 DISCRIMINATOR_PK_PROP), the optional SoftwareInventory side-row (omitted for pre-side-row sessions per §17.5), and the FINAL tombstone step ordered SessionsIndex-then-Sessions. Row dumps tag every column with its EDM type (String / Int32 / Int64 / Double / Boolean / DateTime / Guid / Binary) so DateTime / Guid / byte[] / long round-trip restore-faithfully through the JSON snapshot. Falls back to a SessionsIndex partition scan when Sessions.IndexRowKey is empty or stale, so a partially-corrupted Sessions row can't strand a SessionsIndex orphan after cascade. BlobStorageService.UploadDeletionManifestAsync compresses with gzip, pins SHA-256 of the uncompressed payload to blob metadata, and uses IfNoneMatch=* to enforce snapshot-write-once. DownloadDeletionManifest re-verifies the hash and refuses on mismatch (corruption signal that the §13 restore semantics rely on). Two protected-internal-virtual seams isolate the Azure SDK calls so tests bypass BlobsModelFactory entirely. New SessionInventoryContributions table constant added to TableNames.All so the builder can Try-Read it (404 = pre-side-row session = inventory steps omitted). PR2 adds the side-row write path via VulnerabilityCorrelationService. Tests: 25 new xUnit cases (DeletionManifestSerializationTests, DeletionManifestBuilderTests, BlobStorageServiceDeletionManifestTests) cover counts, dumps with EdmType per Azure Tables type, gzip round-trip, hash-mismatch + missing-metadata corruption signals, IfNoneMatch overwrite refusal, SessionsIndex fallback (both empty-key and stale-key paths), determinism across runs, and pre-side-row inventory skip. Backend suite: 1251/1251 green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+207
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the gzip + SHA-256 + IfNoneMatch behaviour of the new manifest upload/download
|
||||
/// helpers on <see cref="BlobStorageService"/>. Uses a small subclass that overrides the two
|
||||
/// virtual blob-IO seams — much simpler than wrestling with <c>BlobsModelFactory</c>'s
|
||||
/// shifting parameter list, and matches repo convention (no Azurite).
|
||||
/// </summary>
|
||||
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<DeletionManifest>(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<InvalidDataException>(
|
||||
() => 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<InvalidDataException>(
|
||||
() => 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<RequestFailedException>(
|
||||
() => 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<ArgumentNullException>(
|
||||
() => sut.UploadDeletionManifestAsync(null!));
|
||||
await Assert.ThrowsAsync<ArgumentException>(
|
||||
() => 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<DeletionStep>
|
||||
{
|
||||
new DeletionStep { Order = 1, Table = "Events", Class = DeletionStepClass.PkBySession, RowCount = 0 },
|
||||
},
|
||||
SchemaHash = "sha256:placeholder",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<BlobStorageService>.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<string, string>? Metadata)>
|
||||
ReadDeletionManifestBlobAsync(string blobName, CancellationToken cancellationToken)
|
||||
{
|
||||
if (LastWrittenBytes == null)
|
||||
{
|
||||
throw new InvalidOperationException("Test must call UploadDeletionManifestAsync before DownloadDeletionManifestAsync.");
|
||||
}
|
||||
return Task.FromResult<(byte[], IDictionary<string, string>?)>(
|
||||
(LastWrittenBytes, LastWrittenOptions?.Metadata));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Behaviour tests for <see cref="DeletionManifestBuilder"/>. 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 <see cref="ISessionDeletionInventoryReader"/> seam — no Azurite,
|
||||
/// matching repo convention.
|
||||
/// </summary>
|
||||
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<DeletionDecrementKey>
|
||||
{
|
||||
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<DeletionDecrementKey>
|
||||
{
|
||||
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, "<index>{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<DeletionDecrementKey>
|
||||
{
|
||||
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<DeletionManifest>(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<ISessionDeletionInventoryReader>();
|
||||
reader.Setup(r => r.GetSessionRowAsync(TenantId, SessionId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(richRow);
|
||||
reader.Setup(r => r.GetSessionsIndexRowAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((TableEntity?)null);
|
||||
reader.Setup(r => r.GetEntityOrNullAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((TableEntity?)null);
|
||||
reader.Setup(r => r.QueryAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.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<ISessionDeletionInventoryReader>();
|
||||
reader.Setup(r => r.GetSessionRowAsync(TenantId, SessionId, It.IsAny<CancellationToken>()))
|
||||
.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<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((TableEntity?)null);
|
||||
reader.Setup(r => r.GetEntityOrNullAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((TableEntity?)null);
|
||||
reader.Setup(r => r.QueryAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<string, string, CancellationToken>((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<ISessionDeletionInventoryReader>();
|
||||
reader.Setup(r => r.GetSessionRowAsync(TenantId, SessionId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(sessionRow);
|
||||
reader.Setup(r => r.GetSessionsIndexRowAsync(TenantId, staleIndexRowKey, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((TableEntity?)null); // stale key → 404
|
||||
reader.Setup(r => r.GetEntityOrNullAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((TableEntity?)null);
|
||||
reader.Setup(r => r.QueryAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<string, string, CancellationToken>((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<ISessionDeletionInventoryReader>();
|
||||
reader.Setup(r => r.GetSessionRowAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((TableEntity?)null);
|
||||
reader.Setup(r => r.GetSessionsIndexRowAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((TableEntity?)null);
|
||||
reader.Setup(r => r.GetEntityOrNullAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((TableEntity?)null);
|
||||
reader.Setup(r => r.QueryAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.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<DeletionManifestBuilder>.Instance);
|
||||
|
||||
private static ISessionDeletionInventoryReader NewReader(Action<ReaderSeed> seedAction)
|
||||
{
|
||||
var seed = new ReaderSeed();
|
||||
seedAction(seed);
|
||||
return seed.Build();
|
||||
}
|
||||
|
||||
private static TableEntity MakeContributionsRow(List<DeletionDecrementKey> 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<TableEntity> EmptyAsyncEnumerable()
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
yield break;
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<TableEntity> AsyncFrom(IEnumerable<TableEntity> source)
|
||||
{
|
||||
foreach (var entity in source)
|
||||
{
|
||||
yield return entity;
|
||||
}
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fluent test-data seed. Every <c>WithXxx</c> registers Moq setups for the corresponding
|
||||
/// table; tables not seeded return empty / null on the reader.
|
||||
/// </summary>
|
||||
private class ReaderSeed
|
||||
{
|
||||
private readonly Dictionary<string, List<TableEntity>> _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<TableEntity>(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<TableEntity>(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<TableEntity>(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<ISessionDeletionInventoryReader>();
|
||||
|
||||
reader.Setup(r => r.GetSessionRowAsync(TenantId, SessionId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(_sessionsRow);
|
||||
reader.Setup(r => r.GetSessionsIndexRowAsync(TenantId, _sessionsIndexRowKey, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(_sessionsIndexRow);
|
||||
|
||||
// PK_RK_EXACT lookups
|
||||
reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.VulnerabilityReports, It.IsAny<string>(), "report", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(_vulnerabilityReport);
|
||||
reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.DeviceSnapshot, TenantId, SessionId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(_deviceSnapshot);
|
||||
reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.EventSessionIndex, TenantId, SessionId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(_eventSessionIndex);
|
||||
reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.SessionInventoryContributions, TenantId, SessionId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(_sessionInventoryContributions);
|
||||
// Default null for any other GetEntityOrNullAsync.
|
||||
reader.Setup(r => r.GetEntityOrNullAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.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<string>(), "report", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(_vulnerabilityReport);
|
||||
reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.DeviceSnapshot, TenantId, SessionId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(_deviceSnapshot);
|
||||
reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.EventSessionIndex, TenantId, SessionId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(_eventSessionIndex);
|
||||
reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.SessionInventoryContributions, TenantId, SessionId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(_sessionInventoryContributions);
|
||||
|
||||
// QueryAsync: per-table seeded list, otherwise empty.
|
||||
reader.Setup(r => r.QueryAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<string, string, CancellationToken>((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<TableEntity>(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<TableEntity>(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<DeletionManifest>(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<DeletionRowDump>(),
|
||||
Decrements = new List<DeletionDecrementKey>
|
||||
{
|
||||
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<DeletionStep>(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<DeletionRowDump>
|
||||
{
|
||||
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<DeletionStep>(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<string, DeletionPropValue>(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<DeletionRowDump>(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<int> { 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<DeletionProgress>(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<string, int> { ["events"] = 5, ["ruleResults"] = 1 },
|
||||
DiagnosticsBlobName = "diag.zip",
|
||||
Steps = new List<DeletionStep>
|
||||
{
|
||||
new DeletionStep
|
||||
{
|
||||
Order = 1,
|
||||
Table = "Events",
|
||||
Class = DeletionStepClass.PkBySession,
|
||||
RowCount = 1,
|
||||
Rows = new List<DeletionRowDump>
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -126,6 +126,11 @@ builder.Services.AddMemoryCache();
|
||||
builder.Services.AddHttpClient();
|
||||
builder.Services.AddSingleton<ResiliencePolicies>();
|
||||
builder.Services.AddSingleton<TableStorageService>();
|
||||
// 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<AutopilotMonitor.Functions.Services.Deletion.ISessionDeletionInventoryReader>(
|
||||
sp => sp.GetRequiredService<TableStorageService>());
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Deletion.DeletionManifestBuilder>();
|
||||
builder.Services.AddHostedService<TableInitializerService>(); // Initialize all tables at startup
|
||||
|
||||
// Data Access Layer — repository interfaces backed by Table Storage.
|
||||
|
||||
@@ -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
|
||||
/// </summary>
|
||||
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<BlobStorageService> _logger;
|
||||
private readonly bool _usesManagedIdentity;
|
||||
@@ -46,6 +55,17 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test seam: construct directly from a (possibly Moq'd) <see cref="BlobServiceClient"/>.
|
||||
/// Used by xUnit so manifest upload/download paths can be exercised without hitting Azure.
|
||||
/// </summary>
|
||||
internal BlobStorageService(BlobServiceClient blobServiceClient, ILogger<BlobStorageService> logger, bool usesManagedIdentity = false)
|
||||
{
|
||||
_blobServiceClient = blobServiceClient;
|
||||
_logger = logger;
|
||||
_usesManagedIdentity = usesManagedIdentity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a BlobContainerClient for the specified container.
|
||||
/// </summary>
|
||||
@@ -93,5 +113,176 @@ namespace AutopilotMonitor.Functions.Services
|
||||
return blobClient.Uri.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================ Cascade-deletion manifests ====
|
||||
|
||||
/// <summary>
|
||||
/// Uploads the immutable snapshot blob for a cascade-deletion manifest. Path:
|
||||
/// <c>{tenantId}/{sessionId}/{manifestId}.snapshot.json.gz</c>. Gzipped on the wire,
|
||||
/// SHA-256 of the uncompressed payload pinned to the blob's <c>sha256</c> metadata.
|
||||
/// Uses <c>If-None-Match=*</c> so a duplicate-id upload fails loud — the snapshot is
|
||||
/// supposed to be written exactly once per producer attempt (plan §1 P1).
|
||||
/// </summary>
|
||||
public async Task<DeletionManifestBlobPointer> 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<string, string>(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,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads the immutable snapshot blob, gunzips it, deserializes the manifest, and
|
||||
/// verifies the uncompressed-payload SHA-256 against the blob's <c>sha256</c> metadata.
|
||||
/// Throws <see cref="InvalidDataException"/> on hash mismatch (corruption signal — plan §13
|
||||
/// restore semantics rely on this); rethrows the underlying <see cref="RequestFailedException"/>
|
||||
/// on 404 or other storage error.
|
||||
/// </summary>
|
||||
public async Task<DeletionManifest> 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<DeletionManifest>(uncompressed, DeletionManifestJson.SerializerOptions);
|
||||
if (manifest == null)
|
||||
{
|
||||
throw new InvalidDataException($"Deletion manifest blob {blobName} deserialized to null.");
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test seam: reads the gzipped manifest blob and its metadata. Production override
|
||||
/// calls Azure Blob Storage; tests subclass this method to return canned bytes.
|
||||
/// </summary>
|
||||
protected internal virtual async Task<(byte[] Gzipped, IDictionary<string, string>? 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";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pointer record returned by <see cref="BlobStorageService.UploadDeletionManifestAsync"/>.
|
||||
/// Producer (PR3) persists this in the audit log + uses the SHA to bind the snapshot to its
|
||||
/// progress blob.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Read-only enumerator that produces a <see cref="DeletionManifest"/> 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.
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class DeletionManifestBuilder
|
||||
{
|
||||
private readonly ISessionDeletionInventoryReader _reader;
|
||||
private readonly ILogger<DeletionManifestBuilder> _logger;
|
||||
|
||||
// Azure Tables system properties that should not appear in the per-row Props bag.
|
||||
private static readonly HashSet<string> SystemPropertyNames = new HashSet<string>(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<DeletionManifestBuilder> logger)
|
||||
{
|
||||
_reader = reader;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public async Task<DeletionManifest> 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<DeletionRowDump>();
|
||||
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<DeletionRowDump>();
|
||||
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<DeletionRowDump>();
|
||||
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<DeletionRowDump>();
|
||||
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<DeletionRowDump>();
|
||||
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<DeletionRowDump>();
|
||||
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<DeletionRowDump>(),
|
||||
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<DeletionRowDump> { MapEntityToDump(contributionsRow) },
|
||||
});
|
||||
}
|
||||
|
||||
private void AddTombstoneStep(DeletionManifest manifest, int order, TableEntity? sessionsIndexRow, TableEntity? sessionRow)
|
||||
{
|
||||
var rows = new List<DeletionRowDump>(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 ========
|
||||
|
||||
/// <summary>
|
||||
/// Locates the SessionsIndex row corresponding to the given Sessions row. Tries the
|
||||
/// direct (PartitionKey, RowKey) lookup first using <c>Sessions.IndexRowKey</c>; falls
|
||||
/// back to a partition-targeted scan on <c>PartitionKey eq {tenantId} and SessionId eq
|
||||
/// {sessionId}</c> when the IndexRowKey is missing or stale, so a partially-corrupted
|
||||
/// Sessions row can't strand a SessionsIndex orphan after cascade.
|
||||
/// </summary>
|
||||
private async Task<TableEntity?> 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<string, DeletionPropValue>(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,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a single TableEntity property value into an EDM-tagged
|
||||
/// <see cref="DeletionPropValue"/>. Restore reads <see cref="DeletionPropValue.EdmType"/>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes the side-row's <c>SoftwareKeysJson</c> (raw JSON or gzip+Base64 per §17.7) into
|
||||
/// the canonical <see cref="DeletionDecrementKey"/> list. Sorted deterministically by
|
||||
/// (Vendor, Name, Version) so the manifest is byte-stable across builds with identical input.
|
||||
/// </summary>
|
||||
private List<DeletionDecrementKey> DecodeDecrementKeys(TableEntity contributionsRow)
|
||||
{
|
||||
var rawJson = contributionsRow.GetString("SoftwareKeysJson") ?? string.Empty;
|
||||
var isCompressed = contributionsRow.GetBoolean("IsCompressed") ?? false;
|
||||
if (string.IsNullOrEmpty(rawJson))
|
||||
{
|
||||
return new List<DeletionDecrementKey>();
|
||||
}
|
||||
|
||||
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<DeletionDecrementKey> keys;
|
||||
try
|
||||
{
|
||||
keys = JsonSerializer.Deserialize<List<DeletionDecrementKey>>(decoded, DeletionManifestJson.SerializerOptions)
|
||||
?? new List<DeletionDecrementKey>();
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"SessionInventoryContributions.SoftwareKeysJson for tenant {contributionsRow.PartitionKey} session {contributionsRow.RowKey} " +
|
||||
"is not valid JSON for List<DeletionDecrementKey>", 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<string, int> ComputePreflightCounts(DeletionManifest manifest)
|
||||
{
|
||||
// Convention: well-known camelCase keys per the §3 "preflightCounts" example.
|
||||
var counts = new Dictionary<string, int>(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.Data.Tables;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services.Deletion
|
||||
{
|
||||
/// <summary>
|
||||
/// Read-only access surface used by <see cref="DeletionManifestBuilder"/> 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 <c>TableStorageService.Deletion.cs</c> partial; tests inject a Moq.
|
||||
/// </summary>
|
||||
public interface ISessionDeletionInventoryReader
|
||||
{
|
||||
/// <summary>
|
||||
/// Loads the canonical Sessions row, used for <c>IndexRowKey</c> lookup +
|
||||
/// <c>DiagnosticsBlobName</c> capture (PR1) and <c>DeletionState</c>/<c>PendingDeletionManifestId</c>
|
||||
/// (PR3+ — ignored in PR1). Returns null when the row does not exist.
|
||||
/// </summary>
|
||||
Task<TableEntity?> GetSessionRowAsync(string tenantId, string sessionId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
Task<TableEntity?> GetSessionsIndexRowAsync(string tenantId, string indexRowKey, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Streams entities matching <paramref name="filter"/> on <paramref name="tableName"/>.
|
||||
/// The builder formulates the OData filter per step class; this method is a pure passthrough.
|
||||
/// </summary>
|
||||
IAsyncEnumerable<TableEntity> QueryAsync(string tableName, string filter, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Direct (PK, RK) lookup against <paramref name="tableName"/>. Returns null on 404,
|
||||
/// rethrows any other <see cref="Azure.RequestFailedException"/>. Used for PK_RK_EXACT
|
||||
/// classes (VulnerabilityReports, DeviceSnapshot, EventSessionIndex, SessionInventoryContributions).
|
||||
/// </summary>
|
||||
Task<TableEntity?> GetEntityOrNullAsync(string tableName, string partitionKey, string rowKey, CancellationToken cancellationToken = default);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Cascade-deletion read surface. Implements <see cref="ISessionDeletionInventoryReader"/>
|
||||
/// against the existing <c>_tableServiceClient</c>. Pure delegation; fail-loud on every
|
||||
/// non-404 storage error per memory <c>feedback_storage_helpers_fail_soft</c>.
|
||||
/// </summary>
|
||||
public partial class TableStorageService : ISessionDeletionInventoryReader
|
||||
{
|
||||
public async Task<TableEntity?> GetSessionRowAsync(string tenantId, string sessionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tableClient = _tableServiceClient.GetTableClient(Shared.Constants.TableNames.Sessions);
|
||||
try
|
||||
{
|
||||
var response = await tableClient.GetEntityAsync<TableEntity>(tenantId, sessionId, cancellationToken: cancellationToken);
|
||||
return response.Value;
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<TableEntity?> GetSessionsIndexRowAsync(string tenantId, string indexRowKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tableClient = _tableServiceClient.GetTableClient(Shared.Constants.TableNames.SessionsIndex);
|
||||
try
|
||||
{
|
||||
var response = await tableClient.GetEntityAsync<TableEntity>(tenantId, indexRowKey, cancellationToken: cancellationToken);
|
||||
return response.Value;
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<TableEntity> QueryAsync(
|
||||
string tableName,
|
||||
string filter,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tableClient = _tableServiceClient.GetTableClient(tableName);
|
||||
await foreach (var entity in tableClient.QueryAsync<TableEntity>(filter, cancellationToken: cancellationToken))
|
||||
{
|
||||
yield return entity;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<TableEntity?> GetEntityOrNullAsync(string tableName, string partitionKey, string rowKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tableClient = _tableServiceClient.GetTableClient(tableName);
|
||||
try
|
||||
{
|
||||
var response = await tableClient.GetEntityAsync<TableEntity>(partitionKey, rowKey, cancellationToken: cancellationToken);
|
||||
return response.Value;
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AutopilotMonitor.Shared.Models.Deletion
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class DeletionManifest
|
||||
{
|
||||
/// <summary>ULID-shaped identifier of this manifest. Bound to a single producer attempt.</summary>
|
||||
public string ManifestId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Schema version for forward-compatibility. v1 = 1.</summary>
|
||||
public int ManifestVersion { get; set; } = 1;
|
||||
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
public string SessionId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>UTC timestamp at which the manifest was built.</summary>
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
/// <summary>Who or what triggered the cascade.</summary>
|
||||
public DeletionActor CreatedBy { get; set; } = new DeletionActor();
|
||||
|
||||
/// <summary>Free-form reason string (e.g. "admin_delete", "retention_cutoff").</summary>
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Per-tenant retention context at the moment the cascade was triggered.</summary>
|
||||
public DeletionRetentionContext RetentionContext { get; set; } = new DeletionRetentionContext();
|
||||
|
||||
/// <summary>Row counts per logical bucket; convenient for audit + monitoring without parsing every step.</summary>
|
||||
public Dictionary<string, int> PreflightCounts { get; set; } = new Dictionary<string, int>();
|
||||
|
||||
/// <summary>
|
||||
/// Customer-storage diagnostics blob name (informational only — cascade does NOT delete
|
||||
/// the blob; customer owns the storage account). See plan §3 + §11.
|
||||
/// </summary>
|
||||
public string? DiagnosticsBlobName { get; set; }
|
||||
|
||||
/// <summary>Ordered cascade steps. The worker executes them in <see cref="DeletionStep.Order"/> order.</summary>
|
||||
public List<DeletionStep> Steps { get; set; } = new List<DeletionStep>();
|
||||
|
||||
/// <summary>SHA-256 hash of the manifest contents (excluding this field). Used to detect tampering.</summary>
|
||||
public string SchemaHash { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Identifies the trigger source of a cascade. <see cref="Type"/> is one of
|
||||
/// <c>"admin"</c> or <c>"maintenance"</c>; <see cref="Actor"/> is the user identity for
|
||||
/// admin paths or <c>"system"</c> for maintenance.
|
||||
/// </summary>
|
||||
public class DeletionActor
|
||||
{
|
||||
public string Type { get; set; } = string.Empty;
|
||||
public string Actor { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class DeletionRetentionContext
|
||||
{
|
||||
public int? TenantRetentionDays { get; set; }
|
||||
public DateTime? CutoffUtc { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One ordered step in the cascade. Either targets a real table (<see cref="Table"/> set,
|
||||
/// <see cref="Step"/> null) or a synthetic operation (<see cref="Step"/> set,
|
||||
/// <see cref="Table"/> null — only the SoftwareInventoryDecrement and Tombstone steps).
|
||||
/// <see cref="Class"/> is one of the values defined in <see cref="DeletionStepClass"/>.
|
||||
/// </summary>
|
||||
public class DeletionStep
|
||||
{
|
||||
public int Order { get; set; }
|
||||
|
||||
/// <summary>Table name; null for synthetic steps (Tombstone, SoftwareInventoryDecrement).</summary>
|
||||
public string? Table { get; set; }
|
||||
|
||||
/// <summary>Synthetic step name; null for table-targeted steps. Values from <see cref="DeletionStepNames"/>.</summary>
|
||||
public string? Step { get; set; }
|
||||
|
||||
/// <summary>Key-class taxonomy. Values from <see cref="DeletionStepClass"/>.</summary>
|
||||
public string Class { get; set; } = string.Empty;
|
||||
|
||||
public int RowCount { get; set; }
|
||||
|
||||
/// <summary>Full row dumps. Empty for AGGREGATE steps; the Tombstone step always has 2 entries.</summary>
|
||||
public List<DeletionRowDump> Rows { get; set; } = new List<DeletionRowDump>();
|
||||
|
||||
/// <summary>Decrement keys for the AGGREGATE step (SoftwareInventoryDecrement); null for all other classes.</summary>
|
||||
public List<DeletionDecrementKey>? Decrements { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captured snapshot of a single Azure Table row. <see cref="Props"/> 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 <see cref="JsonElement"/> alone would collapse
|
||||
/// DateTime / Guid / byte[] / long into ambiguous strings, breaking the
|
||||
/// manifest-as-backup guarantee (plan §3 "types preserved").
|
||||
/// </summary>
|
||||
public class DeletionRowDump
|
||||
{
|
||||
public string Pk { get; set; } = string.Empty;
|
||||
public string Rk { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>ETag as observed at preflight; informational only (not used for restore).</summary>
|
||||
public string? Etag { get; set; }
|
||||
|
||||
public Dictionary<string, DeletionPropValue> Props { get; set; } = new Dictionary<string, DeletionPropValue>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single Azure Table column value with its EDM type tag. Restore looks at <see cref="EdmType"/>
|
||||
/// to decide which <c>Get*</c> / 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.
|
||||
/// </summary>
|
||||
public class DeletionPropValue
|
||||
{
|
||||
/// <summary>
|
||||
/// One of: <c>String</c>, <c>Int32</c>, <c>Int64</c>, <c>Double</c>, <c>Boolean</c>,
|
||||
/// <c>DateTime</c>, <c>Guid</c>, <c>Binary</c>. Matches the Azure Tables OData EDM
|
||||
/// type names (minus the <c>Edm.</c> prefix).
|
||||
/// </summary>
|
||||
public string EdmType { get; set; } = DeletionPropEdmType.String;
|
||||
|
||||
/// <summary>JSON-encoded value. For DateTime / Guid / Binary this is the ISO-8601 /
|
||||
/// GUID-string / Base64 form respectively.</summary>
|
||||
public JsonElement Value { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>EDM type constants used in <see cref="DeletionPropValue.EdmType"/>.</summary>
|
||||
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";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One key targeted by the SoftwareInventoryDecrement AGGREGATE step. The cascade applies
|
||||
/// <c>SessionCount -= 1</c> with clamp-≥-0 on the matching <c>SoftwareInventory</c> row.
|
||||
/// Shape mirrors the increment side in <c>VulnerabilityCorrelationService</c>.
|
||||
/// </summary>
|
||||
public class DeletionDecrementKey
|
||||
{
|
||||
public string Vendor { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Version { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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";
|
||||
}
|
||||
|
||||
/// <summary>Synthetic step names for non-table cascade operations.</summary>
|
||||
public static class DeletionStepNames
|
||||
{
|
||||
public const string SoftwareInventoryDecrement = "SoftwareInventoryDecrement";
|
||||
public const string Tombstone = "Tombstone";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Centralized JSON options for manifest (de)serialization. CamelCase casing matches the
|
||||
/// schema documented in plan §3.
|
||||
/// </summary>
|
||||
public static class DeletionManifestJson
|
||||
{
|
||||
public static readonly JsonSerializerOptions SerializerOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
// DictionaryKeyPolicy is intentionally NOT set: <see cref="DeletionRowDump.Props"/> 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. <see cref="DeletionManifest.PreflightCounts"/> keys are
|
||||
// already camelCase by construction.
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AutopilotMonitor.Shared.Models.Deletion
|
||||
{
|
||||
/// <summary>
|
||||
/// Mutable progress companion to <see cref="DeletionManifest"/>. 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 <c>{manifestId}.progress.json</c> blob and CAS'd on every write.
|
||||
/// </summary>
|
||||
public class DeletionProgress
|
||||
{
|
||||
/// <summary>SHA-256 of the immutable snapshot blob; mismatch = corruption, refuse to proceed.</summary>
|
||||
public string SnapshotSha256 { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Step.Order values that have completed; the worker iterates the remaining ones.</summary>
|
||||
public HashSet<int> CompletedSteps { get; set; } = new HashSet<int>();
|
||||
|
||||
/// <summary>True once the live verification pass succeeded; gates the FINAL tombstone step.</summary>
|
||||
public bool VerificationDone { get; set; }
|
||||
|
||||
/// <summary>UTC timestamp once the FINAL tombstone step has completed; null while in flight.</summary>
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user