mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
+1
-1
Submodule internal updated: 75f9976eab...e9267f21ee
+1
-1
@@ -414,7 +414,7 @@ public class OffboardingMarkerCleanupFunctionTests
|
||||
{
|
||||
}
|
||||
|
||||
public override Task<int> WipeByExactPartitionAsync(string tableName, string normalizedTenantId, CancellationToken ct = default)
|
||||
protected override Task<int> WipeByExactPartitionCoreAsync(string tableName, string normalizedTenantId, string filter, CancellationToken ct)
|
||||
{
|
||||
WipeCalls.Add((tableName, normalizedTenantId));
|
||||
if (ThrowOnNextWipe is { } ex)
|
||||
|
||||
+9
-9
@@ -593,9 +593,9 @@ public class TenantOffboardingHandlerDrainContractTests
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="SafeWipeService"/> 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
|
||||
/// <see cref="SafeWipeService"/> exposes the wipe methods as <c>virtual</c>, 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.
|
||||
/// </summary>
|
||||
private sealed class CountingSafeWipeService : SafeWipeService
|
||||
{
|
||||
@@ -610,37 +610,37 @@ public class TenantOffboardingHandlerDrainContractTests
|
||||
{
|
||||
}
|
||||
|
||||
public override Task<int> WipeByExactPartitionAsync(string tableName, string normalizedTenantId, CancellationToken ct = default)
|
||||
protected override Task<int> WipeByExactPartitionCoreAsync(string tableName, string normalizedTenantId, string filter, CancellationToken ct)
|
||||
{
|
||||
WipeCallCount++;
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public override Task<int> WipeByCompositePartitionRangeAsync(string tableName, string normalizedTenantId, CancellationToken ct = default)
|
||||
protected override Task<int> WipeByCompositePartitionRangeCoreAsync(string tableName, string normalizedTenantId, CancellationToken ct)
|
||||
{
|
||||
WipeCallCount++;
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public override Task<int> WipeByDiscriminatorAndTenantPropertyAsync(string tableName, string discriminator, string normalizedTenantId, CancellationToken ct = default)
|
||||
protected override Task<int> WipeByDiscriminatorAndTenantPropertyCoreAsync(string tableName, string discriminator, string normalizedTenantId, CancellationToken ct)
|
||||
{
|
||||
WipeCallCount++;
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public override Task<int> WipeByTenantIdPropertyAsync(string tableName, string normalizedTenantId, CancellationToken ct = default)
|
||||
protected override Task<int> WipeByTenantIdPropertyCoreAsync(string tableName, string normalizedTenantId, CancellationToken ct)
|
||||
{
|
||||
WipeCallCount++;
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public override Task<int> WipeByRowKeyAsync(string tableName, string normalizedTenantId, CancellationToken ct = default)
|
||||
protected override Task<int> WipeByRowKeyCoreAsync(string tableName, string normalizedTenantId, string filter, CancellationToken ct)
|
||||
{
|
||||
WipeCallCount++;
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public override Task<int> WipeBlobsByTenantPrefixAsync(string containerName, string normalizedTenantId, CancellationToken ct = default)
|
||||
protected override Task<int> WipeBlobsByTenantPrefixCoreAsync(string containerName, string normalizedTenantId, CancellationToken ct)
|
||||
{
|
||||
WipeCallCount++;
|
||||
return Task.FromResult(0);
|
||||
|
||||
+26
-6
@@ -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<BlobStorageService>.Instance, usesManagedIdentity: false),
|
||||
NullLogger<SafeWipeService>.Instance) { }
|
||||
public override Task<int> WipeByExactPartitionAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; ExactPartitionWipes.Add(t); return Task.FromResult(0); }
|
||||
public override Task<int> WipeByCompositePartitionRangeAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; return Task.FromResult(0); }
|
||||
public override Task<int> WipeByDiscriminatorAndTenantPropertyAsync(string t, string d, string i, CancellationToken c = default) { WipeCallCount++; return Task.FromResult(0); }
|
||||
public override Task<int> WipeByTenantIdPropertyAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; PropertyOnlyWipes.Add(t); return Task.FromResult(0); }
|
||||
public override Task<int> WipeByRowKeyAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; RowKeyWipes.Add(t); return Task.FromResult(0); }
|
||||
public override Task<int> WipeBlobsByTenantPrefixAsync(string c, string i, CancellationToken ct = default) { WipeCallCount++; return Task.FromResult(0); }
|
||||
/// <summary>(table, partition key) pairs of the exact-partition wipe — the anchor the delete would run against.</summary>
|
||||
public List<(string Table, string Anchor)> ExactPartitionAnchors { get; } = new();
|
||||
/// <summary>(table, row key) pairs of the RowKey-anchored wipe.</summary>
|
||||
public List<(string Table, string Anchor)> RowKeyAnchors { get; } = new();
|
||||
protected override Task<int> WipeByExactPartitionCoreAsync(string t, string pk, string filter, CancellationToken c) { WipeCallCount++; ExactPartitionWipes.Add(t); ExactPartitionAnchors.Add((t, pk)); return Task.FromResult(0); }
|
||||
protected override Task<int> WipeByCompositePartitionRangeCoreAsync(string t, string i, CancellationToken c) { WipeCallCount++; return Task.FromResult(0); }
|
||||
protected override Task<int> WipeByDiscriminatorAndTenantPropertyCoreAsync(string t, string d, string i, CancellationToken c) { WipeCallCount++; return Task.FromResult(0); }
|
||||
protected override Task<int> WipeByTenantIdPropertyCoreAsync(string t, string i, CancellationToken c) { WipeCallCount++; PropertyOnlyWipes.Add(t); return Task.FromResult(0); }
|
||||
protected override Task<int> WipeByRowKeyCoreAsync(string t, string rk, string filter, CancellationToken c) { WipeCallCount++; RowKeyWipes.Add(t); RowKeyAnchors.Add((t, rk)); return Task.FromResult(0); }
|
||||
protected override Task<int> WipeBlobsByTenantPrefixCoreAsync(string c, string i, CancellationToken ct) { WipeCallCount++; return Task.FromResult(0); }
|
||||
}
|
||||
|
||||
private sealed class NoopCustomsArchivingOrderingHandler : TenantOffboardingHandler
|
||||
|
||||
+5
-5
@@ -288,10 +288,10 @@ public class TenantOffboardingWorkerPoisonTests
|
||||
new BlobStorageService(new BlobServiceClient("UseDevelopmentStorage=true"),
|
||||
NullLogger<BlobStorageService>.Instance, usesManagedIdentity: false),
|
||||
NullLogger<SafeWipeService>.Instance) { }
|
||||
public override Task<int> WipeByExactPartitionAsync(string t, string i, CancellationToken c = default) => Task.FromResult(0);
|
||||
public override Task<int> WipeByCompositePartitionRangeAsync(string t, string i, CancellationToken c = default) => Task.FromResult(0);
|
||||
public override Task<int> WipeByDiscriminatorAndTenantPropertyAsync(string t, string d, string i, CancellationToken c = default) => Task.FromResult(0);
|
||||
public override Task<int> WipeByTenantIdPropertyAsync(string t, string i, CancellationToken c = default) => Task.FromResult(0);
|
||||
public override Task<int> WipeBlobsByTenantPrefixAsync(string c, string i, CancellationToken ct = default) => Task.FromResult(0);
|
||||
protected override Task<int> WipeByExactPartitionCoreAsync(string t, string i, string filter, CancellationToken c) => Task.FromResult(0);
|
||||
protected override Task<int> WipeByCompositePartitionRangeCoreAsync(string t, string i, CancellationToken c) => Task.FromResult(0);
|
||||
protected override Task<int> WipeByDiscriminatorAndTenantPropertyCoreAsync(string t, string d, string i, CancellationToken c) => Task.FromResult(0);
|
||||
protected override Task<int> WipeByTenantIdPropertyCoreAsync(string t, string i, CancellationToken c) => Task.FromResult(0);
|
||||
protected override Task<int> WipeBlobsByTenantPrefixCoreAsync(string c, string i, CancellationToken ct) => Task.FromResult(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ArgumentException>(() => OffboardingFilters.OwnedGroupPartition(input));
|
||||
Assert.Throws<ArgumentException>(() => OffboardingFilters.OwnedGroupRowKey(input));
|
||||
}
|
||||
|
||||
// ── Injection neutralization (defense in depth even after EnsureValidGuid) ──
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -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<SafeWipeVerificationException>(() =>
|
||||
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<SafeWipeVerificationException>(() =>
|
||||
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<TableEntity>());
|
||||
var groupId = Constants.TenantGroupIds.ForHomeTenant(TenantId);
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => harness.Sut.WipeByExactPartitionAsync("t", groupId));
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => harness.Sut.WipeByRowKeyAsync("t", groupId));
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => harness.Sut.WipeByOwnedGroupPartitionAsync("t", groupId));
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => harness.Sut.WipeByOwnedGroupRowKeyAsync("t", groupId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Variants_RejectNonGuidTenantId()
|
||||
{
|
||||
@@ -228,6 +298,8 @@ public class SafeWipeServiceTests
|
||||
{
|
||||
public List<List<TableTransactionAction>> SubmittedBatches { get; } = new();
|
||||
public List<(string Pk, string Rk, ETag Etag)> PerRowDeletes { get; } = new();
|
||||
/// <summary>OData filters the service sent to the table — the server-side anchor.</summary>
|
||||
public List<string> Filters { get; } = new();
|
||||
public SafeWipeService Sut { get; }
|
||||
|
||||
public Harness(
|
||||
@@ -244,6 +316,7 @@ public class SafeWipeServiceTests
|
||||
It.IsAny<int?>(),
|
||||
It.IsAny<IEnumerable<string>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<string, int?, IEnumerable<string>, CancellationToken>((filter, _, _, _) => Filters.Add(filter))
|
||||
.Returns(AsAsyncPageable(queriedRows));
|
||||
|
||||
mockTableClient
|
||||
|
||||
@@ -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)}'";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>PartitionKey eq 'msp-{tenantId}'</c> — 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.
|
||||
/// </summary>
|
||||
public static string OwnedGroupPartition(string normalizedTenantId)
|
||||
{
|
||||
SecurityValidator.EnsureValidGuid(normalizedTenantId, nameof(normalizedTenantId));
|
||||
var groupId = Constants.TenantGroupIds.ForHomeTenant(normalizedTenantId);
|
||||
return $"PartitionKey eq '{ODataSanitizer.EscapeValue(groupId)}'";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>RowKey eq 'msp-{tenantId}'</c> — assignment rows pointing at the tenant's own
|
||||
/// self-service Tenant Group (TenantGroupAssignments: PK=managed tenant, RK=groupId).
|
||||
/// Full-table scan, same reservation as <see cref="RowKeyEquals"/>.
|
||||
/// </summary>
|
||||
public static string OwnedGroupRowKey(string normalizedTenantId)
|
||||
{
|
||||
SecurityValidator.EnsureValidGuid(normalizedTenantId, nameof(normalizedTenantId));
|
||||
var groupId = Constants.TenantGroupIds.ForHomeTenant(normalizedTenantId);
|
||||
return $"RowKey eq '{ODataSanitizer.EscapeValue(groupId)}'";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
/// <summary>
|
||||
/// Variant A (exact tenant PartitionKey). Used for tables where rows live under
|
||||
/// <c>PartitionKey == normalizedTenantId</c> (AuditLogs, UsageMetrics, …).
|
||||
/// </summary>
|
||||
public virtual Task<int> WipeByExactPartitionAsync(
|
||||
public Task<int> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Variant A for the tenant's own self-service Tenant Group: rows live under
|
||||
/// <c>PartitionKey == "msp-{tenantId}"</c>. Takes the tenant GUID and derives the group id
|
||||
/// itself, so the anchor is never a caller-supplied string.
|
||||
/// </summary>
|
||||
public Task<int> 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<int> 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 <c>{tenantId}_{sessionId|discriminator}</c>.
|
||||
/// </summary>
|
||||
public virtual Task<int> WipeByCompositePartitionRangeAsync(
|
||||
public Task<int> WipeByCompositePartitionRangeAsync(
|
||||
string tableName, string normalizedTenantId, CancellationToken ct = default)
|
||||
{
|
||||
SecurityValidator.EnsureValidGuid(normalizedTenantId, nameof(normalizedTenantId));
|
||||
return WipeByCompositePartitionRangeCoreAsync(tableName, normalizedTenantId, ct);
|
||||
}
|
||||
|
||||
protected virtual Task<int> WipeByCompositePartitionRangeCoreAsync(
|
||||
string tableName, string normalizedTenantId, CancellationToken ct)
|
||||
{
|
||||
var prefix = normalizedTenantId + "_";
|
||||
var filter = OffboardingFilters.CompositePartitionRange(normalizedTenantId);
|
||||
|
||||
@@ -81,12 +112,17 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
|
||||
/// <c>TenantId</c> property identifies tenant ownership. Verify-step MUST check the
|
||||
/// property because the PK is non-anchored.
|
||||
/// </summary>
|
||||
public virtual Task<int> WipeByDiscriminatorAndTenantPropertyAsync(
|
||||
public Task<int> 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<int> 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. <c>UserUsageLog</c> keyed by userOid).
|
||||
/// Full-table scan — DO NOT use on high-volume tables without consideration.
|
||||
/// </summary>
|
||||
public virtual Task<int> WipeByTenantIdPropertyAsync(
|
||||
public Task<int> WipeByTenantIdPropertyAsync(
|
||||
string tableName, string normalizedTenantId, CancellationToken ct = default)
|
||||
{
|
||||
SecurityValidator.EnsureValidGuid(normalizedTenantId, nameof(normalizedTenantId));
|
||||
return WipeByTenantIdPropertyCoreAsync(tableName, normalizedTenantId, ct);
|
||||
}
|
||||
|
||||
protected virtual Task<int> 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.
|
||||
/// </summary>
|
||||
public virtual Task<int> WipeByRowKeyAsync(
|
||||
public Task<int> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Variant D for the tenant's own self-service Tenant Group: rows whose
|
||||
/// <c>RowKey == "msp-{tenantId}"</c> (TenantGroupAssignments: PK=managed tenant). Takes
|
||||
/// the tenant GUID and derives the group id itself.
|
||||
/// </summary>
|
||||
public Task<int> 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<int> 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.
|
||||
/// </summary>
|
||||
public virtual async Task<int> WipeBlobsByTenantPrefixAsync(
|
||||
public Task<int> 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<int> WipeBlobsByTenantPrefixCoreAsync(
|
||||
string containerName, string normalizedTenantId, CancellationToken ct)
|
||||
{
|
||||
var prefix = normalizedTenantId + "/";
|
||||
var container = _blobs.GetContainerClient(containerName);
|
||||
|
||||
|
||||
+2
-3
@@ -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 ────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user