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 ────────────────────────────────────────────────────────