From b3fe321f0d083c8e09e9ad816f817574762e3545 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Fri, 18 Sep 2026 09:40:51 +0200 Subject: [PATCH] fix(offboarding): owned-group wipe no longer poisons every tenant offboarding The wipe of the tenant's own self-service Tenant Group handed the derived group id ("msp-{tenantId}") to the tenant-GUID entry points of SafeWipeService. Their GUID guard threw on every attempt, so each offboarding since the self-service delegation change ran into max_dequeue and ended Failed after the tenant tables were already wiped. - SafeWipeService: WipeByOwnedGroupPartitionAsync / WipeByOwnedGroupRowKeyAsync take the tenant GUID and derive the group id themselves; matching filters in OffboardingFilters. - The public wipe entry points are no longer virtual: they validate and hand over to protected virtual cores. Test fakes override the cores only, so the guard runs under every fake. With the old handler code 16 handler tests fail on the guard, which is how this shipped unnoticed. - Tests: handler regression on the owned-group anchors, owned-group variants incl. verify-abort on a foreign group, filter shapes and guards. Deploy and recovery of the stuck offboarding: B-5g6. Co-Authored-By: Claude Fable 5.1 --- internal | 2 +- .../OffboardingMarkerCleanupFunctionTests.cs | 2 +- ...antOffboardingHandlerDrainContractTests.cs | 18 ++-- .../TenantOffboardingHandlerOrderingTests.cs | 32 +++++-- .../TenantOffboardingWorkerPoisonTests.cs | 10 +- .../OffboardingFiltersTests.cs | 17 ++++ .../SafeWipeServiceTests.cs | 73 +++++++++++++++ .../Offboarding/OffboardingFilters.cs | 25 +++++ .../Services/Offboarding/SafeWipeService.cs | 92 ++++++++++++++++--- .../Offboarding/TenantOffboardingHandler.cs | 5 +- 10 files changed, 239 insertions(+), 37 deletions(-) diff --git a/internal b/internal index 75f9976e..e9267f21 160000 --- a/internal +++ b/internal @@ -1 +1 @@ -Subproject commit 75f9976eab61ea6e321ad2030f01d9e9f30d0d64 +Subproject commit e9267f21eec80fda4ca6f593f9e5196cc372a0bf diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/OffboardingMarkerCleanupFunctionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/OffboardingMarkerCleanupFunctionTests.cs index 3fe703cf..a700c4cb 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/OffboardingMarkerCleanupFunctionTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/OffboardingMarkerCleanupFunctionTests.cs @@ -414,7 +414,7 @@ public class OffboardingMarkerCleanupFunctionTests { } - public override Task WipeByExactPartitionAsync(string tableName, string normalizedTenantId, CancellationToken ct = default) + protected override Task WipeByExactPartitionCoreAsync(string tableName, string normalizedTenantId, string filter, CancellationToken ct) { WipeCalls.Add((tableName, normalizedTenantId)); if (ThrowOnNextWipe is { } ex) diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerDrainContractTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerDrainContractTests.cs index 671d0220..6ba54770 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerDrainContractTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerDrainContractTests.cs @@ -593,9 +593,9 @@ public class TenantOffboardingHandlerDrainContractTests /// /// that records call counts without performing any storage IO. - /// Tests use this to assert that fail-closed paths NEVER touch the wipe methods. Now that - /// exposes the wipe methods as virtual, the production - /// dispatch lands here. + /// Tests use this to assert that fail-closed paths NEVER touch the wipe methods. Only the + /// protected cores are overridden, so the tenant-GUID guard of the public entry points still + /// runs against whatever the handler passes in. /// private sealed class CountingSafeWipeService : SafeWipeService { @@ -610,37 +610,37 @@ public class TenantOffboardingHandlerDrainContractTests { } - public override Task WipeByExactPartitionAsync(string tableName, string normalizedTenantId, CancellationToken ct = default) + protected override Task WipeByExactPartitionCoreAsync(string tableName, string normalizedTenantId, string filter, CancellationToken ct) { WipeCallCount++; return Task.FromResult(0); } - public override Task WipeByCompositePartitionRangeAsync(string tableName, string normalizedTenantId, CancellationToken ct = default) + protected override Task WipeByCompositePartitionRangeCoreAsync(string tableName, string normalizedTenantId, CancellationToken ct) { WipeCallCount++; return Task.FromResult(0); } - public override Task WipeByDiscriminatorAndTenantPropertyAsync(string tableName, string discriminator, string normalizedTenantId, CancellationToken ct = default) + protected override Task WipeByDiscriminatorAndTenantPropertyCoreAsync(string tableName, string discriminator, string normalizedTenantId, CancellationToken ct) { WipeCallCount++; return Task.FromResult(0); } - public override Task WipeByTenantIdPropertyAsync(string tableName, string normalizedTenantId, CancellationToken ct = default) + protected override Task WipeByTenantIdPropertyCoreAsync(string tableName, string normalizedTenantId, CancellationToken ct) { WipeCallCount++; return Task.FromResult(0); } - public override Task WipeByRowKeyAsync(string tableName, string normalizedTenantId, CancellationToken ct = default) + protected override Task WipeByRowKeyCoreAsync(string tableName, string normalizedTenantId, string filter, CancellationToken ct) { WipeCallCount++; return Task.FromResult(0); } - public override Task WipeBlobsByTenantPrefixAsync(string containerName, string normalizedTenantId, CancellationToken ct = default) + protected override Task WipeBlobsByTenantPrefixCoreAsync(string containerName, string normalizedTenantId, CancellationToken ct) { WipeCallCount++; return Task.FromResult(0); diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerOrderingTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerOrderingTests.cs index 589ce307..cb54b707 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerOrderingTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerOrderingTests.cs @@ -320,6 +320,22 @@ public class TenantOffboardingHandlerOrderingTests Assert.Contains(Constants.TableNames.ScriptNameCache, harness.SafeWipeProbe.ExactPartitionWipes); } + [Fact] + public async Task PostDrain_OwnedGroupWipe_AnchorsOnSelfServiceGroupId() + { + // The tenant's own self-service group lives under "msp-{tenantId}" — not a GUID. The wipe + // must go through the owned-group entry points (tenant GUID in, group id derived inside); + // handing the group id to a tenant-GUID entry point throws and poisons every offboarding. + var harness = Harness.New(); + var ownedGroupId = Constants.TenantGroupIds.ForHomeTenant(TenantId); + + await harness.Sut.HandleAsync(harness.Envelope()); + + Assert.Contains((Constants.TableNames.TenantGroups, ownedGroupId), harness.SafeWipeProbe.ExactPartitionAnchors); + Assert.Contains((Constants.TableNames.TenantGroupAssignments, ownedGroupId), harness.SafeWipeProbe.RowKeyAnchors); + Assert.Equal("Completed", harness.Repo.History[HistoryRowKey].Status); + } + // ── Harness (copied minimal — only what these tests need) ─────────────────── private sealed class Harness @@ -470,12 +486,16 @@ public class TenantOffboardingHandlerOrderingTests new BlobStorageService(new BlobServiceClient("UseDevelopmentStorage=true"), NullLogger.Instance, usesManagedIdentity: false), NullLogger.Instance) { } - public override Task WipeByExactPartitionAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; ExactPartitionWipes.Add(t); return Task.FromResult(0); } - public override Task WipeByCompositePartitionRangeAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; return Task.FromResult(0); } - public override Task WipeByDiscriminatorAndTenantPropertyAsync(string t, string d, string i, CancellationToken c = default) { WipeCallCount++; return Task.FromResult(0); } - public override Task WipeByTenantIdPropertyAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; PropertyOnlyWipes.Add(t); return Task.FromResult(0); } - public override Task WipeByRowKeyAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; RowKeyWipes.Add(t); return Task.FromResult(0); } - public override Task WipeBlobsByTenantPrefixAsync(string c, string i, CancellationToken ct = default) { WipeCallCount++; return Task.FromResult(0); } + /// (table, partition key) pairs of the exact-partition wipe — the anchor the delete would run against. + public List<(string Table, string Anchor)> ExactPartitionAnchors { get; } = new(); + /// (table, row key) pairs of the RowKey-anchored wipe. + public List<(string Table, string Anchor)> RowKeyAnchors { get; } = new(); + protected override Task WipeByExactPartitionCoreAsync(string t, string pk, string filter, CancellationToken c) { WipeCallCount++; ExactPartitionWipes.Add(t); ExactPartitionAnchors.Add((t, pk)); return Task.FromResult(0); } + protected override Task WipeByCompositePartitionRangeCoreAsync(string t, string i, CancellationToken c) { WipeCallCount++; return Task.FromResult(0); } + protected override Task WipeByDiscriminatorAndTenantPropertyCoreAsync(string t, string d, string i, CancellationToken c) { WipeCallCount++; return Task.FromResult(0); } + protected override Task WipeByTenantIdPropertyCoreAsync(string t, string i, CancellationToken c) { WipeCallCount++; PropertyOnlyWipes.Add(t); return Task.FromResult(0); } + protected override Task WipeByRowKeyCoreAsync(string t, string rk, string filter, CancellationToken c) { WipeCallCount++; RowKeyWipes.Add(t); RowKeyAnchors.Add((t, rk)); return Task.FromResult(0); } + protected override Task WipeBlobsByTenantPrefixCoreAsync(string c, string i, CancellationToken ct) { WipeCallCount++; return Task.FromResult(0); } } private sealed class NoopCustomsArchivingOrderingHandler : TenantOffboardingHandler diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingWorkerPoisonTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingWorkerPoisonTests.cs index b2c83d39..72bcd610 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingWorkerPoisonTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingWorkerPoisonTests.cs @@ -288,10 +288,10 @@ public class TenantOffboardingWorkerPoisonTests new BlobStorageService(new BlobServiceClient("UseDevelopmentStorage=true"), NullLogger.Instance, usesManagedIdentity: false), NullLogger.Instance) { } - public override Task WipeByExactPartitionAsync(string t, string i, CancellationToken c = default) => Task.FromResult(0); - public override Task WipeByCompositePartitionRangeAsync(string t, string i, CancellationToken c = default) => Task.FromResult(0); - public override Task WipeByDiscriminatorAndTenantPropertyAsync(string t, string d, string i, CancellationToken c = default) => Task.FromResult(0); - public override Task WipeByTenantIdPropertyAsync(string t, string i, CancellationToken c = default) => Task.FromResult(0); - public override Task WipeBlobsByTenantPrefixAsync(string c, string i, CancellationToken ct = default) => Task.FromResult(0); + protected override Task WipeByExactPartitionCoreAsync(string t, string i, string filter, CancellationToken c) => Task.FromResult(0); + protected override Task WipeByCompositePartitionRangeCoreAsync(string t, string i, CancellationToken c) => Task.FromResult(0); + protected override Task WipeByDiscriminatorAndTenantPropertyCoreAsync(string t, string d, string i, CancellationToken c) => Task.FromResult(0); + protected override Task WipeByTenantIdPropertyCoreAsync(string t, string i, CancellationToken c) => Task.FromResult(0); + protected override Task WipeBlobsByTenantPrefixCoreAsync(string c, string i, CancellationToken ct) => Task.FromResult(0); } } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/OffboardingFiltersTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/OffboardingFiltersTests.cs index e81a955e..c359ed4c 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/OffboardingFiltersTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/OffboardingFiltersTests.cs @@ -116,6 +116,23 @@ public class OffboardingFiltersTests Assert.DoesNotContain("PartitionKey", filter); } + [Fact] + public void OwnedGroupFilters_AnchorOnTheDerivedSelfServiceGroupId() + { + Assert.Equal($"PartitionKey eq 'msp-{ValidTenant}'", OffboardingFilters.OwnedGroupPartition(ValidTenant)); + Assert.Equal($"RowKey eq 'msp-{ValidTenant}'", OffboardingFilters.OwnedGroupRowKey(ValidTenant)); + } + + [Theory] + [InlineData("not-a-guid")] + [InlineData("msp-11111111-1111-1111-1111-111111111111")] // the group id itself is not an input + [InlineData("")] + public void OwnedGroupFilters_ThrowForNonGuid(string input) + { + Assert.Throws(() => OffboardingFilters.OwnedGroupPartition(input)); + Assert.Throws(() => OffboardingFilters.OwnedGroupRowKey(input)); + } + // ── Injection neutralization (defense in depth even after EnsureValidGuid) ── [Fact] diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SafeWipeServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SafeWipeServiceTests.cs index 15edbe66..8d455836 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/SafeWipeServiceTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SafeWipeServiceTests.cs @@ -8,6 +8,7 @@ using Azure.Data.Tables; using Azure.Storage.Blobs; using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Functions.Services.Offboarding; +using AutopilotMonitor.Shared; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; @@ -205,6 +206,75 @@ public class SafeWipeServiceTests Assert.Empty(harness.SubmittedBatches); } + [Fact] + public async Task OwnedGroupPartition_DeletesRowsUnderSelfServiceGroupId() + { + // TenantGroups: PK = "msp-{tenantId}". The entry point takes the tenant GUID and derives + // the group id — the rows under that partition are the ones deleted. + var groupId = Constants.TenantGroupIds.ForHomeTenant(TenantId); + var harness = new Harness( + queriedRows: new[] + { + new TableEntity(groupId, "meta"), + new TableEntity(groupId, OtherTenant), + }); + + var deleted = await harness.Sut.WipeByOwnedGroupPartitionAsync("FakeTable", TenantId); + + Assert.Equal(2, deleted); + Assert.Equal($"PartitionKey eq '{groupId}'", Assert.Single(harness.Filters)); + } + + [Fact] + public async Task OwnedGroupPartition_AbortsWhenForeignGroupReturned() + { + var harness = new Harness( + queriedRows: new[] + { + new TableEntity(Constants.TenantGroupIds.ForHomeTenant(TenantId), "meta"), + new TableEntity(Constants.TenantGroupIds.ForHomeTenant(OtherTenant), "meta"), // <-- another manager's group + }); + + await Assert.ThrowsAsync(() => + harness.Sut.WipeByOwnedGroupPartitionAsync("FakeTable", TenantId)); + + Assert.Empty(harness.SubmittedBatches); + Assert.Empty(harness.PerRowDeletes); + } + + [Fact] + public async Task OwnedGroupRowKey_AbortsWhenForeignGroupAssignmentReturned() + { + // TenantGroupAssignments: PK = managed tenant, RK = groupId. An assignment pointing at + // another manager's group must never be deleted. + var harness = new Harness( + queriedRows: new[] + { + new TableEntity(OtherTenant, Constants.TenantGroupIds.ForHomeTenant(TenantId)), + new TableEntity(OtherTenant, Constants.TenantGroupIds.ForHomeTenant(OtherTenant)), + }); + + await Assert.ThrowsAsync(() => + harness.Sut.WipeByOwnedGroupRowKeyAsync("FakeTable", TenantId)); + + Assert.Empty(harness.SubmittedBatches); + Assert.Empty(harness.PerRowDeletes); + } + + [Fact] + public async Task TenantGuidEntryPoints_RejectTheDerivedGroupId() + { + // The production bug: the handler passed "msp-{tenantId}" into the tenant-GUID entry + // points and every offboarding poisoned. Those entry points stay GUID-only. + var harness = new Harness(queriedRows: Array.Empty()); + var groupId = Constants.TenantGroupIds.ForHomeTenant(TenantId); + + await Assert.ThrowsAsync(() => harness.Sut.WipeByExactPartitionAsync("t", groupId)); + await Assert.ThrowsAsync(() => harness.Sut.WipeByRowKeyAsync("t", groupId)); + await Assert.ThrowsAsync(() => harness.Sut.WipeByOwnedGroupPartitionAsync("t", groupId)); + await Assert.ThrowsAsync(() => harness.Sut.WipeByOwnedGroupRowKeyAsync("t", groupId)); + } + [Fact] public async Task Variants_RejectNonGuidTenantId() { @@ -228,6 +298,8 @@ public class SafeWipeServiceTests { public List> SubmittedBatches { get; } = new(); public List<(string Pk, string Rk, ETag Etag)> PerRowDeletes { get; } = new(); + /// OData filters the service sent to the table — the server-side anchor. + public List Filters { get; } = new(); public SafeWipeService Sut { get; } public Harness( @@ -244,6 +316,7 @@ public class SafeWipeServiceTests It.IsAny(), It.IsAny>(), It.IsAny())) + .Callback, CancellationToken>((filter, _, _, _) => Filters.Add(filter)) .Returns(AsAsyncPageable(queriedRows)); mockTableClient diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/OffboardingFilters.cs b/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/OffboardingFilters.cs index d3bf6c9a..f1065f81 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/OffboardingFilters.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/OffboardingFilters.cs @@ -1,5 +1,6 @@ using System; using AutopilotMonitor.Functions.Security; +using AutopilotMonitor.Shared; namespace AutopilotMonitor.Functions.Services.Offboarding { @@ -77,5 +78,29 @@ namespace AutopilotMonitor.Functions.Services.Offboarding SecurityValidator.EnsureValidGuid(normalizedTenantId, nameof(normalizedTenantId)); return $"RowKey eq '{ODataSanitizer.EscapeValue(normalizedTenantId)}'"; } + + /// + /// PartitionKey eq 'msp-{tenantId}' — the tenant's own self-service Tenant Group + /// (TenantGroups: PK=groupId). The group id is derived from the validated tenant GUID here, + /// never passed in. + /// + public static string OwnedGroupPartition(string normalizedTenantId) + { + SecurityValidator.EnsureValidGuid(normalizedTenantId, nameof(normalizedTenantId)); + var groupId = Constants.TenantGroupIds.ForHomeTenant(normalizedTenantId); + return $"PartitionKey eq '{ODataSanitizer.EscapeValue(groupId)}'"; + } + + /// + /// RowKey eq 'msp-{tenantId}' — assignment rows pointing at the tenant's own + /// self-service Tenant Group (TenantGroupAssignments: PK=managed tenant, RK=groupId). + /// Full-table scan, same reservation as . + /// + public static string OwnedGroupRowKey(string normalizedTenantId) + { + SecurityValidator.EnsureValidGuid(normalizedTenantId, nameof(normalizedTenantId)); + var groupId = Constants.TenantGroupIds.ForHomeTenant(normalizedTenantId); + return $"RowKey eq '{ODataSanitizer.EscapeValue(groupId)}'"; + } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/SafeWipeService.cs b/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/SafeWipeService.cs index 177d48c7..87528d7c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/SafeWipeService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/SafeWipeService.cs @@ -38,21 +38,46 @@ namespace AutopilotMonitor.Functions.Services.Offboarding _logger = logger; } + // Every public entry point validates the tenant GUID and builds the filter, then hands + // over to a protected virtual core. Test fakes override the cores only, so the guard runs + // under every fake — a caller that passes anything but the tenant GUID fails in tests. + /// /// Variant A (exact tenant PartitionKey). Used for tables where rows live under /// PartitionKey == normalizedTenantId (AuditLogs, UsageMetrics, …). /// - public virtual Task WipeByExactPartitionAsync( + public Task WipeByExactPartitionAsync( string tableName, string normalizedTenantId, CancellationToken ct = default) { SecurityValidator.EnsureValidGuid(normalizedTenantId, nameof(normalizedTenantId)); - var filter = OffboardingFilters.ExactPartition(normalizedTenantId); + return WipeByExactPartitionCoreAsync( + tableName, normalizedTenantId, OffboardingFilters.ExactPartition(normalizedTenantId), ct); + } + /// + /// Variant A for the tenant's own self-service Tenant Group: rows live under + /// PartitionKey == "msp-{tenantId}". Takes the tenant GUID and derives the group id + /// itself, so the anchor is never a caller-supplied string. + /// + public Task WipeByOwnedGroupPartitionAsync( + string tableName, string normalizedTenantId, CancellationToken ct = default) + { + SecurityValidator.EnsureValidGuid(normalizedTenantId, nameof(normalizedTenantId)); + return WipeByExactPartitionCoreAsync( + tableName, + Constants.TenantGroupIds.ForHomeTenant(normalizedTenantId), + OffboardingFilters.OwnedGroupPartition(normalizedTenantId), + ct); + } + + protected virtual Task WipeByExactPartitionCoreAsync( + string tableName, string partitionKey, string filter, CancellationToken ct) + { return RunFetchVerifyDeleteAsync( tableName, filter, - expectedAnchor: normalizedTenantId, - verifyRow: e => string.Equals(e.PartitionKey, normalizedTenantId, StringComparison.Ordinal), + expectedAnchor: partitionKey, + verifyRow: e => string.Equals(e.PartitionKey, partitionKey, StringComparison.Ordinal), ct); } @@ -60,10 +85,16 @@ namespace AutopilotMonitor.Functions.Services.Offboarding /// Variant A range (composite tenant PartitionKey). Used for tables where rows live /// under {tenantId}_{sessionId|discriminator}. /// - public virtual Task WipeByCompositePartitionRangeAsync( + public Task WipeByCompositePartitionRangeAsync( string tableName, string normalizedTenantId, CancellationToken ct = default) { SecurityValidator.EnsureValidGuid(normalizedTenantId, nameof(normalizedTenantId)); + return WipeByCompositePartitionRangeCoreAsync(tableName, normalizedTenantId, ct); + } + + protected virtual Task WipeByCompositePartitionRangeCoreAsync( + string tableName, string normalizedTenantId, CancellationToken ct) + { var prefix = normalizedTenantId + "_"; var filter = OffboardingFilters.CompositePartitionRange(normalizedTenantId); @@ -81,12 +112,17 @@ namespace AutopilotMonitor.Functions.Services.Offboarding /// TenantId property identifies tenant ownership. Verify-step MUST check the /// property because the PK is non-anchored. /// - public virtual Task WipeByDiscriminatorAndTenantPropertyAsync( + public Task WipeByDiscriminatorAndTenantPropertyAsync( string tableName, string discriminator, string normalizedTenantId, CancellationToken ct = default) { SecurityValidator.EnsureValidGuid(normalizedTenantId, nameof(normalizedTenantId)); if (string.IsNullOrEmpty(discriminator)) throw new ArgumentException("Discriminator required", nameof(discriminator)); + return WipeByDiscriminatorAndTenantPropertyCoreAsync(tableName, discriminator, normalizedTenantId, ct); + } + protected virtual Task WipeByDiscriminatorAndTenantPropertyCoreAsync( + string tableName, string discriminator, string normalizedTenantId, CancellationToken ct) + { var filter = OffboardingFilters.DiscriminatorWithTenantProp(discriminator, normalizedTenantId); var expectedAnchor = $"{discriminator}+TenantId={normalizedTenantId}"; @@ -104,10 +140,16 @@ namespace AutopilotMonitor.Functions.Services.Offboarding /// PartitionKey is not tenant-anchored (e.g. UserUsageLog keyed by userOid). /// Full-table scan — DO NOT use on high-volume tables without consideration. /// - public virtual Task WipeByTenantIdPropertyAsync( + public Task WipeByTenantIdPropertyAsync( string tableName, string normalizedTenantId, CancellationToken ct = default) { SecurityValidator.EnsureValidGuid(normalizedTenantId, nameof(normalizedTenantId)); + return WipeByTenantIdPropertyCoreAsync(tableName, normalizedTenantId, ct); + } + + protected virtual Task WipeByTenantIdPropertyCoreAsync( + string tableName, string normalizedTenantId, CancellationToken ct) + { var filter = OffboardingFilters.TenantIdProperty(normalizedTenantId); return RunFetchVerifyDeleteAsync( @@ -124,17 +166,38 @@ namespace AutopilotMonitor.Functions.Services.Offboarding /// PK=date, RK=tenantId|"global"). Full-table scan — reserve for retention-bounded /// tables. /// - public virtual Task WipeByRowKeyAsync( + public Task WipeByRowKeyAsync( string tableName, string normalizedTenantId, CancellationToken ct = default) { SecurityValidator.EnsureValidGuid(normalizedTenantId, nameof(normalizedTenantId)); - var filter = OffboardingFilters.RowKeyEquals(normalizedTenantId); + return WipeByRowKeyCoreAsync( + tableName, normalizedTenantId, OffboardingFilters.RowKeyEquals(normalizedTenantId), ct); + } + /// + /// Variant D for the tenant's own self-service Tenant Group: rows whose + /// RowKey == "msp-{tenantId}" (TenantGroupAssignments: PK=managed tenant). Takes + /// the tenant GUID and derives the group id itself. + /// + public Task WipeByOwnedGroupRowKeyAsync( + string tableName, string normalizedTenantId, CancellationToken ct = default) + { + SecurityValidator.EnsureValidGuid(normalizedTenantId, nameof(normalizedTenantId)); + return WipeByRowKeyCoreAsync( + tableName, + Constants.TenantGroupIds.ForHomeTenant(normalizedTenantId), + OffboardingFilters.OwnedGroupRowKey(normalizedTenantId), + ct); + } + + protected virtual Task WipeByRowKeyCoreAsync( + string tableName, string rowKey, string filter, CancellationToken ct) + { return RunFetchVerifyDeleteAsync( tableName, filter, - expectedAnchor: $"RowKey={normalizedTenantId}", - verifyRow: e => string.Equals(e.RowKey, normalizedTenantId, StringComparison.Ordinal), + expectedAnchor: $"RowKey={rowKey}", + verifyRow: e => string.Equals(e.RowKey, rowKey, StringComparison.Ordinal), ct); } @@ -144,12 +207,17 @@ namespace AutopilotMonitor.Functions.Services.Offboarding /// per-blob (DeleteIfExistsAsync) so the helper stays idempotent across crash/resume. /// Mismatch → abort before any delete. Returns the number of blobs deleted. /// - public virtual async Task WipeBlobsByTenantPrefixAsync( + public Task WipeBlobsByTenantPrefixAsync( string containerName, string normalizedTenantId, CancellationToken ct = default) { SecurityValidator.EnsureValidGuid(normalizedTenantId, nameof(normalizedTenantId)); if (string.IsNullOrEmpty(containerName)) throw new ArgumentException("Container required", nameof(containerName)); + return WipeBlobsByTenantPrefixCoreAsync(containerName, normalizedTenantId, ct); + } + protected virtual async Task WipeBlobsByTenantPrefixCoreAsync( + string containerName, string normalizedTenantId, CancellationToken ct) + { var prefix = normalizedTenantId + "/"; var container = _blobs.GetContainerClient(containerName); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingHandler.cs b/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingHandler.cs index b62568c1..bcf4b6ef 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingHandler.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingHandler.cs @@ -1003,11 +1003,10 @@ namespace AutopilotMonitor.Functions.Services.Offboarding // The tenant's own self-service Tenant Group ("msp-{tenantId}": PK=groupId, which no bucket above // matches) and the assignment rows pointing at it — the managing side of self-service delegation. ct.ThrowIfCancellationRequested(); - var ownedGroupId = Constants.TenantGroupIds.ForHomeTenant(tenantId); counts[$"{Constants.TableNames.TenantGroups}/owned"] = - await _safeWipe.WipeByExactPartitionAsync(Constants.TableNames.TenantGroups, ownedGroupId, ct); + await _safeWipe.WipeByOwnedGroupPartitionAsync(Constants.TableNames.TenantGroups, tenantId, ct); counts[$"{Constants.TableNames.TenantGroupAssignments}/owned"] = - await _safeWipe.WipeByRowKeyAsync(Constants.TableNames.TenantGroupAssignments, ownedGroupId, ct); + await _safeWipe.WipeByOwnedGroupRowKeyAsync(Constants.TableNames.TenantGroupAssignments, tenantId, ct); } // ── Failure path ────────────────────────────────────────────────────────