Compare commits

..

4 Commits

Author SHA1 Message Date
Oliver Kieselbach 8ec5a5debd offboarding: close the un-offboard race — config freeze + fail-closed wipe guard
While a tenant offboarding is queued or draining, the TenantConfiguration
tombstone (Disabled=true, 'Offboarding in progress') is the cascade's only
auth gate. A GA lifting Disabled mid-cascade re-opened the tenant against a
wipe that was still coming: data written afterwards was destroyed, and
sessions registered after enumeration survived as orphans while their event
partitions were wiped.

Three layers close it:
- PUT config/{tenantId} returns 409 while the row is the offboarding
  tombstone — for all callers, GA included
- TenantConfigPatchService refuses patch AND revert against the tombstone
  (a revert restores a pre-offboarding snapshot with Disabled=false — the
  exact race)
- TenantOffboardingHandler re-reads the config before the destructive
  post-drain phases and fail-closes with 'unoffboard_race' when the
  tombstone is gone but the wipe has not run (last line of defense)

Shared predicate: TenantOffboardFunction.IsOffboardingTombstone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 16:25:03 +02:00
Oliver Kieselbach ff35270d5d agent: legacy bootstrap-config without TenantIdWaitSeconds must not disable the wait
A bootstrap-config.json written before the TenantIdWaitSeconds property
existed deserialized the missing field to 0, which the runtime treated as
an explicit opt-out and skipped the 600s TenantId wait entirely — exactly
the wait Hybrid-Join devices need while their Entra registration lands.

TenantIdWaitSeconds is now nullable: missing/null means 'not configured'
and falls through to the agent default (600s); an explicit 0 remains the
legacy fast-fail opt-out. The install merge carries null through instead
of freezing the default into the file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 16:24:50 +02:00
Oliver Kieselbach 312e849573 email: welcome + farewell templates to green branding
Header gradient, CTA button, and links move from the old blue
(#2563eb/#4f46e5) to the landing-page green palette (#1e8a4c/#33b161).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 16:11:23 +02:00
Oliver Kieselbach 7433df528d web: mark-led OG/Twitter image + PNG apple-touch-icon
The OG image was an unreadable shrunken hero at card size; replace it
with a centered mark-first design (big green shape, large wordmark,
single tagline). Safari cannot use SVG for apple-touch-icon and fell
back to a letter tile — point the icons config at the file-convention
apple-icon.png, now the green mark on white (180x180).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 15:17:23 +02:00
20 changed files with 360 additions and 34 deletions
@@ -132,10 +132,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
}
[Fact]
public void First_install_without_wait_arg_uses_agent_default_of_600()
public void First_install_without_wait_arg_persists_null_so_runtime_default_applies()
{
// Generic bootstrap path: PS1 calls `--install` without --tenant-id-wait,
// and there is no persisted config yet. Agent owns the default (600 s).
// and there is no persisted config yet. "Not configured" is persisted as null
// so BuildAgentConfiguration applies the agent-side default (600 s) — freezing
// a number into the file would decouple deployed devices from default changes.
var merged = AutopilotMonitor.Agent.V2.Program.MergeBootstrapConfig(
existing: null,
bootstrapTokenArg: null!, bootstrapTokenGiven: false,
@@ -144,20 +146,42 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
Assert.Null(merged.BootstrapToken);
Assert.Null(merged.TenantId);
Assert.Equal(600, merged.TenantIdWaitSeconds);
Assert.Null(merged.TenantIdWaitSeconds);
}
[Fact]
public void Existing_config_without_wait_field_treated_as_zero()
public void Legacy_config_without_wait_field_stays_null_on_plain_reinstall()
{
// BackCompat: pre-fix bootstrap-config.json has no TenantIdWaitSeconds field;
// Newtonsoft deserialises that to 0. New install with --tenant-id-wait 600
// overrides cleanly; no extra migration logic required.
// BackCompat: pre-feature bootstrap-config.json has no TenantIdWaitSeconds
// property → nullable deserialisation yields null. A reinstall that does not
// pass --tenant-id-wait must NOT turn that into 0 (which would silently
// disable the wait); null carries through and the runtime default applies.
var existing = new BootstrapConfigFile
{
BootstrapToken = "tok-legacy",
TenantId = "tenant-legacy",
TenantIdWaitSeconds = 0,
TenantIdWaitSeconds = null,
};
var merged = AutopilotMonitor.Agent.V2.Program.MergeBootstrapConfig(
existing,
bootstrapTokenArg: null!, bootstrapTokenGiven: false,
tenantIdArg: null!, tenantIdGiven: false,
tenantIdWaitSeconds: 0, tenantIdWaitGiven: false);
Assert.Equal("tok-legacy", merged.BootstrapToken);
Assert.Equal("tenant-legacy", merged.TenantId);
Assert.Null(merged.TenantIdWaitSeconds);
}
[Fact]
public void Legacy_config_without_wait_field_takes_explicit_wait_arg()
{
var existing = new BootstrapConfigFile
{
BootstrapToken = "tok-legacy",
TenantId = "tenant-legacy",
TenantIdWaitSeconds = null,
};
var merged = AutopilotMonitor.Agent.V2.Program.MergeBootstrapConfig(
@@ -170,5 +194,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
Assert.Equal("tenant-legacy", merged.TenantId);
Assert.Equal(600, merged.TenantIdWaitSeconds);
}
[Fact]
public void Explicit_zero_opt_out_survives_redeploy_without_wait_arg()
{
// An admin's deliberate `--tenant-id-wait 0` must not be resurrected to a
// wait by a later redeploy that omits the flag.
var existing = new BootstrapConfigFile
{
BootstrapToken = "tok-abc",
TenantId = "tenant-xyz",
TenantIdWaitSeconds = 0,
};
var merged = AutopilotMonitor.Agent.V2.Program.MergeBootstrapConfig(
existing,
bootstrapTokenArg: null!, bootstrapTokenGiven: false,
tenantIdArg: null!, tenantIdGiven: false,
tenantIdWaitSeconds: 0, tenantIdWaitGiven: false);
Assert.Equal(0, merged.TenantIdWaitSeconds);
}
}
}
@@ -0,0 +1,90 @@
using AutopilotMonitor.Agent.V2;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
{
/// <summary>
/// Pins the TenantIdWaitSeconds resolution order in
/// <see cref="AutopilotMonitor.Agent.V2.Program.BuildAgentConfiguration"/>:
/// CLI arg &gt; persisted bootstrap-config.json value &gt; agent default (600 s).
/// The regression this guards: a legacy bootstrap-config.json written before the
/// TenantIdWaitSeconds property existed deserialises to null — that must fall
/// through to the 600 s default, NOT be treated as an explicit 0 opt-out
/// (which silently disabled the wait and broke Hybrid-AAD-join devices whose
/// registry TenantId lands minutes after first boot).
/// </summary>
public sealed class TenantIdWaitResolutionTests
{
private static AgentConfiguration Build(BootstrapConfigFile bootstrapConfig, params string[] args) =>
AutopilotMonitor.Agent.V2.Program.BuildAgentConfiguration(
args: args,
tenantId: "tenant-t",
sessionId: "session-s",
bootstrapConfig: bootstrapConfig,
awaitConfig: null);
[Fact]
public void No_cli_and_no_bootstrap_config_uses_default_600()
{
var cfg = Build(bootstrapConfig: null);
Assert.Equal(600, cfg.TenantIdWaitSeconds);
}
[Fact]
public void Legacy_bootstrap_config_with_null_wait_uses_default_600()
{
var legacy = new BootstrapConfigFile
{
BootstrapToken = "tok-legacy",
TenantId = "tenant-legacy",
TenantIdWaitSeconds = null,
};
var cfg = Build(legacy);
Assert.Equal(600, cfg.TenantIdWaitSeconds);
}
[Fact]
public void Explicit_zero_in_bootstrap_config_disables_wait()
{
var optedOut = new BootstrapConfigFile { TenantIdWaitSeconds = 0 };
var cfg = Build(optedOut);
Assert.Equal(0, cfg.TenantIdWaitSeconds);
}
[Fact]
public void Nonzero_bootstrap_config_value_wins_over_default()
{
var configured = new BootstrapConfigFile { TenantIdWaitSeconds = 1800 };
var cfg = Build(configured);
Assert.Equal(1800, cfg.TenantIdWaitSeconds);
}
[Fact]
public void Cli_arg_wins_over_bootstrap_config_value()
{
var configured = new BootstrapConfigFile { TenantIdWaitSeconds = 1800 };
var cfg = Build(configured, "--tenant-id-wait", "300");
Assert.Equal(300, cfg.TenantIdWaitSeconds);
}
[Fact]
public void Cli_zero_wins_over_bootstrap_config_value()
{
var configured = new BootstrapConfigFile { TenantIdWaitSeconds = 1800 };
var cfg = Build(configured, "--tenant-id-wait", "0");
Assert.Equal(0, cfg.TenantIdWaitSeconds);
}
}
}
@@ -272,9 +272,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
/// CloudDomainJoin keys and re-probes on every change until either resolution
/// or this timeout. 0 = no wait, fast-fail on miss (legacy behaviour).
/// <para>
/// Set via the install-time CLI flag <c>--tenant-id-wait &lt;sec&gt;</c>, persisted
/// to <c>bootstrap-config.json</c>. The PS1 bootstrap script owns the production
/// default; the agent itself never assumes one.
/// Set via the CLI flag <c>--tenant-id-wait &lt;sec&gt;</c> or a persisted
/// <c>bootstrap-config.json</c> value; when neither supplies a value the agent
/// applies its own default of 600 s in BuildAgentConfiguration. The 0 default
/// here is only the POCO baseline, not the effective runtime default.
/// </para>
/// </summary>
public int TenantIdWaitSeconds { get; set; } = 0;
@@ -16,8 +16,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
/// <summary>
/// TenantId-wait timeout in seconds, persisted from the <c>--tenant-id-wait</c>
/// install-time arg. Read by AgentBootstrap when the registry probe finds no
/// TenantId on first try. 0 / missing = no wait, legacy fast-fail.
/// TenantId on first try. 0 = explicit opt-out (legacy fast-fail).
/// Null / missing (files written before this property existed) = not configured —
/// the agent-side default (600 s) applies. Nullable so a legacy file without the
/// property cannot masquerade as an explicit 0 and silently disable the wait.
/// </summary>
public int TenantIdWaitSeconds { get; set; }
public int? TenantIdWaitSeconds { get; set; }
}
}
@@ -135,7 +135,7 @@ namespace AutopilotMonitor.Agent.V2
$"{(bootstrapTokenGiven ? "*" : "")}, " +
$"tenantId={(string.IsNullOrEmpty(merged.TenantId) ? "no" : "yes")}" +
$"{(tenantIdGiven ? "*" : "")}, " +
$"tenantIdWait={merged.TenantIdWaitSeconds}s" +
$"tenantIdWait={(merged.TenantIdWaitSeconds is int w ? $"{w}s" : "default")}" +
$"{(tenantIdWaitGiven ? "*" : "")})." +
" Star = explicitly set on this --install invocation, others were preserved from prior config.");
}
@@ -361,9 +361,12 @@ namespace AutopilotMonitor.Agent.V2
{
BootstrapToken = bootstrapTokenGiven ? bootstrapTokenArg : existing?.BootstrapToken,
TenantId = tenantIdGiven ? tenantIdArg : existing?.TenantId,
// Not given → carry the prior value through, INCLUDING null. Persisting a
// number here would freeze the agent-side default into the file; persisting
// null keeps "not configured" distinguishable from an explicit opt-out (0).
TenantIdWaitSeconds = tenantIdWaitGiven
? tenantIdWaitSeconds
: (existing?.TenantIdWaitSeconds ?? 600),
: existing?.TenantIdWaitSeconds,
};
}
@@ -339,13 +339,15 @@ namespace AutopilotMonitor.Agent.V2
// over the agent-side default (600 s). Hybrid-AAD-joined devices typically
// need ~5 min for the AAD device cert to land in the registry; 600 s leaves
// headroom. Pass `--tenant-id-wait 0` to opt out (legacy fast-fail).
// A bootstrap-config.json whose TenantIdWaitSeconds is null (file predates the
// property) is "not configured", NOT an opt-out — the default must still apply.
var tenantIdWaitRaw = GetArgValue(args, "--tenant-id-wait");
const int tenantIdWaitDefaultSeconds = 600;
int tenantIdWaitSeconds = tenantIdWaitDefaultSeconds;
if (!string.IsNullOrEmpty(tenantIdWaitRaw) && int.TryParse(tenantIdWaitRaw, out var parsedTenantIdWait))
tenantIdWaitSeconds = parsedTenantIdWait;
else if (bootstrapConfig != null)
tenantIdWaitSeconds = bootstrapConfig.TenantIdWaitSeconds;
else if (bootstrapConfig?.TenantIdWaitSeconds != null)
tenantIdWaitSeconds = bootstrapConfig.TenantIdWaitSeconds.Value;
var useBootstrapTokenAuth = !string.IsNullOrEmpty(bootstrapToken);
@@ -42,6 +42,7 @@ public sealed class TenantCustomsArchiveContractTests
reEnqueuer: null!,
opsEvents: null!,
customsArchive: archive,
configRepo: null!,
farewellEmail: new FakeOffboardFarewellEmailSender(),
logger: NullLogger<TenantOffboardingHandler>.Instance);
}
@@ -9,6 +9,7 @@ using Azure;
using Azure.Data.Tables;
using Azure.Storage.Blobs;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Functions.Admin;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Deletion;
using AutopilotMonitor.Functions.Services.Notifications;
@@ -301,6 +302,47 @@ public class TenantOffboardingHandlerDrainContractTests
"Phase 2.G must delete the Expectations blob as its last step");
}
[Fact]
public async Task UnoffboardRace_ConfigNoLongerTombstone_FailsClosed_NoSafeWipe()
{
// 2.D-guard: an operator re-enabled the tenant while the cascade was queued/draining
// (the config write paths refuse this, but the guard is the last line of defense).
// Wiping now would destroy data of a live tenant → fail-closed, zero wipes.
var harness = Harness.New();
harness.ConfigRepo
.Setup(r => r.GetTenantConfigurationAsync(TenantId))
.ReturnsAsync(new TenantConfiguration { TenantId = TenantId, Disabled = false });
await Assert.ThrowsAsync<InvalidOperationException>(() =>
harness.Sut.HandleAsync(harness.Envelope(drainPollCount: 0)));
Assert.Equal("Failed", harness.Repo.Markers[TenantId].Status);
Assert.Equal("unoffboard_race", harness.Repo.Markers[TenantId].FailedPhase);
Assert.Equal("Failed", harness.Repo.History[HistoryRowKey].Status);
Assert.Equal(0, harness.SafeWipeProbe.WipeCallCount);
}
[Fact]
public async Task UnoffboardGuard_TombstoneStillInPlace_ProceedsToWipeAndCompleted()
{
// The normal case: Phase 1's tombstone is untouched → the guard must NOT get in
// the way of a legitimate offboarding.
var harness = Harness.New();
harness.ConfigRepo
.Setup(r => r.GetTenantConfigurationAsync(TenantId))
.ReturnsAsync(new TenantConfiguration
{
TenantId = TenantId,
Disabled = true,
DisabledReason = TenantOffboardFunction.OffboardingDisabledReason,
});
await harness.Sut.HandleAsync(harness.Envelope(drainPollCount: 0));
Assert.Equal("Completed", harness.Repo.Markers[TenantId].Status);
Assert.True(harness.SafeWipeProbe.WipeCallCount > 0);
}
[Fact]
public async Task SessionNotFound_CountsAsSatisfied_NoDrainProbeCall()
{
@@ -393,6 +435,7 @@ public class TenantOffboardingHandlerDrainContractTests
public Mock<OffboardingSessionEnumerator> Enumerator { get; }
public CountingSafeWipeService SafeWipeProbe { get; } = new();
public Mock<IMaintenanceRepository> Maintenance { get; } = new();
public Mock<IConfigRepository> ConfigRepo { get; } = new();
public List<string> EnumeratorYields { get; } = new();
public Exception? EnumeratorThrow { get; set; }
@@ -450,6 +493,7 @@ public class TenantOffboardingHandlerDrainContractTests
h.ReEnqueuer.Object,
opsService,
Mock.Of<ITenantCustomsArchiveRepository>(),
h.ConfigRepo.Object,
new FakeOffboardFarewellEmailSender(),
NullLogger<TenantOffboardingHandler>.Instance);
@@ -622,10 +666,11 @@ public class TenantOffboardingHandlerDrainContractTests
ITenantOffboardingEnqueuer reEnqueuer,
OpsEventService opsEvents,
ITenantCustomsArchiveRepository customsArchive,
IConfigRepository configRepo,
IOffboardFarewellEmailSender farewellEmail,
ILogger<TenantOffboardingHandler> logger)
: base(auditRepo, enumerator, cascadeEnqueuer, expectations, drainProbe, safeWipe,
storage, maintenance, reEnqueuer, opsEvents, customsArchive, farewellEmail, logger)
storage, maintenance, reEnqueuer, opsEvents, customsArchive, configRepo, farewellEmail, logger)
{
}
@@ -358,6 +358,7 @@ public class TenantOffboardingHandlerOrderingTests
h.ReEnqueuer.Object,
BuildOpsService(),
Mock.Of<ITenantCustomsArchiveRepository>(),
Mock.Of<IConfigRepository>(),
h.FarewellEmail,
NullLogger<TenantOffboardingHandler>.Instance);
@@ -459,9 +460,9 @@ public class TenantOffboardingHandlerOrderingTests
IOffboardingAuditRepository a, OffboardingSessionEnumerator e, ISessionDeletionEnqueuer c,
IOffboardingExpectationsStore exp, IDeletionProgressDrainProbe d, SafeWipeService sw,
TableStorageService s, IMaintenanceRepository m, ITenantOffboardingEnqueuer re,
OpsEventService o, ITenantCustomsArchiveRepository ca, IOffboardFarewellEmailSender fe,
ILogger<TenantOffboardingHandler> log)
: base(a, e, c, exp, d, sw, s, m, re, o, ca, fe, log) { }
OpsEventService o, ITenantCustomsArchiveRepository ca, IConfigRepository cr,
IOffboardFarewellEmailSender fe, ILogger<TenantOffboardingHandler> log)
: base(a, e, c, exp, d, sw, s, m, re, o, ca, cr, fe, log) { }
internal override Task ArchiveAndWipeRulesTableAsync(
string tableName, string tenantId, string historyRowKey, CancellationToken ct)
@@ -245,6 +245,7 @@ public class TenantOffboardingWorkerPoisonTests
Mock.Of<ITenantOffboardingEnqueuer>(),
BuildOpsService(),
Mock.Of<ITenantCustomsArchiveRepository>(),
Mock.Of<IConfigRepository>(),
new FakeOffboardFarewellEmailSender(),
NullLogger<TenantOffboardingHandler>.Instance);
}
@@ -255,9 +256,9 @@ public class TenantOffboardingWorkerPoisonTests
IOffboardingAuditRepository a, OffboardingSessionEnumerator e, ISessionDeletionEnqueuer c,
IOffboardingExpectationsStore exp, IDeletionProgressDrainProbe d, SafeWipeService sw,
TableStorageService s, IMaintenanceRepository m, ITenantOffboardingEnqueuer re,
OpsEventService o, ITenantCustomsArchiveRepository ca, IOffboardFarewellEmailSender fe,
ILogger<TenantOffboardingHandler> log)
: base(a, e, c, exp, d, sw, s, m, re, o, ca, fe, log) { }
OpsEventService o, ITenantCustomsArchiveRepository ca, IConfigRepository cr,
IOffboardFarewellEmailSender fe, ILogger<TenantOffboardingHandler> log)
: base(a, e, c, exp, d, sw, s, m, re, o, ca, cr, fe, log) { }
internal override Task ArchiveAndWipeRulesTableAsync(
string tableName, string tenantId, string historyRowKey, CancellationToken ct)
@@ -4,6 +4,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Functions.Admin;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
@@ -154,6 +155,68 @@ public class TenantConfigPatchServiceTests
Assert.Null(harness.Current!.ContactEmail);
}
// ── Mid-offboarding freeze ──────────────────────────────────────────────
[Fact]
public async Task Patch_OffboardingTombstone_IsFrozen_EvenForGa()
{
// While the row is the offboarding tombstone, the cascade owns it: a GA patch
// lifting Disabled would un-gate agents against a tenant whose partitions are
// queued for deletion (un-offboard race).
var stored = Stored();
stored.Disabled = true;
stored.DisabledReason = TenantOffboardFunction.OffboardingDisabledReason;
var harness = new Harness(stored);
var outcome = await harness.Sut.ApplyFieldPatchAsync(
TenantId, Fields(("disabled", false)), Ga, "mcp-patch", "trying to un-offboard");
Assert.False(outcome.Success);
Assert.Equal(PatchFailure.ValidationFailed, outcome.Failure);
Assert.Contains("offboarding", outcome.Error, StringComparison.OrdinalIgnoreCase);
Assert.True(harness.Current!.Disabled, "tombstone must be untouched");
Assert.Equal(0, harness.ReplaceCalls);
}
[Fact]
public async Task Patch_OrdinarySuspension_IsNotFrozen()
{
// The freeze is specific to the offboarding tombstone — a normally suspended
// tenant (different DisabledReason) stays fully patchable.
var stored = Stored();
stored.Disabled = true;
stored.DisabledReason = "Manual suspension";
var harness = new Harness(stored);
var outcome = await harness.Sut.ApplyFieldPatchAsync(
TenantId, Fields(("dataRetentionDays", 60)), Ga, "mcp-patch", null);
Assert.True(outcome.Success);
Assert.Equal(60, harness.Current!.DataRetentionDays);
}
[Fact]
public async Task Revert_OffboardingTombstone_IsFrozen()
{
// A revert restores a pre-offboarding snapshot with Disabled=false — the exact
// un-offboard race. Build a backup while the tenant is healthy, then tombstone.
var harness = new Harness(Stored());
var patched = await harness.Sut.ApplyFieldPatchAsync(
TenantId, Fields(("dataRetentionDays", 90)), Ga, "mcp-patch", null);
Assert.True(patched.Success);
harness.Current!.Disabled = true;
harness.Current.DisabledReason = TenantOffboardFunction.OffboardingDisabledReason;
var outcome = await harness.Sut.RevertAsync(
TenantId, backupId: null, includeProtectedFields: false, Ga, "mcp-revert", null);
Assert.False(outcome.Success);
Assert.Equal(PatchFailure.ValidationFailed, outcome.Failure);
Assert.Contains("offboarding", outcome.Error, StringComparison.OrdinalIgnoreCase);
Assert.True(harness.Current.Disabled, "tombstone must be untouched");
}
[Fact]
public async Task Patch_NoOp_ValueEqualsStored_NoBackupNoWrite()
{
@@ -517,6 +517,17 @@ public class TenantOffboardFunction
/// </summary>
internal const string OffboardingDisabledReason = "Offboarding in progress";
/// <summary>
/// True when the row is the offboarding tombstone written by
/// <c>EnsureTenantDisabledAsync</c>. Shared predicate for every config writer and
/// wiper that must distinguish the mid-offboarding freeze from an ordinary
/// suspended (or re-onboarded) tenant: while this is true, the tenant's
/// configuration is frozen and the offboarding cascade owns the row.
/// </summary>
internal static bool IsOffboardingTombstone(TenantConfiguration? config) =>
config is { Disabled: true }
&& string.Equals(config.DisabledReason, OffboardingDisabledReason, StringComparison.Ordinal);
/// <summary>
/// Result of <see cref="EnsureTenantDisabledAsync"/>. Carries both whether a fresh
/// write happened AND the tombstone's <c>LastUpdated</c> timestamp so the resume-path
@@ -3,6 +3,7 @@ using System.IO;
using System.Net;
using System.Threading.Tasks;
using System.Collections.Generic;
using AutopilotMonitor.Functions.Functions.Admin;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
@@ -71,6 +72,23 @@ namespace AutopilotMonitor.Functions.Functions.Config
// before validation/save and (2) protect GA-only fields below.
var existingConfig = await _configService.GetConfigurationAsync(requestCtx.TargetTenantId);
// Mid-offboarding freeze: the Disabled tombstone written by TenantOffboardFunction
// is the cascade's ONLY auth gate. A PUT that lifts it (or edits anything else)
// races the queued wipe — data written afterwards gets destroyed, and agents
// would be un-gated against a tenant whose partitions are being deleted. Refuse
// for ALL callers, GA included; once the cascade completes the row is deleted
// and re-onboarding starts from a fresh default config anyway.
if (TenantOffboardFunction.IsOffboardingTombstone(existingConfig))
{
var conflict = req.CreateResponse(HttpStatusCode.Conflict);
await conflict.WriteAsJsonAsync(new
{
success = false,
message = "Tenant offboarding is in progress — the configuration is frozen until the cascade completes."
});
return conflict;
}
// Defense-in-depth: a read-only GlobalReader is served a redacted config (secrets replaced
// with the ***REDACTED*** sentinel). If such a view is ever round-tripped back on a save,
// never persist the placeholder — restore the real secret from the stored config. (Own-tenant
@@ -39,9 +39,9 @@ public static class EmailTemplates
<!-- Header -->
<tr>
<td style=""background:linear-gradient(135deg,#2563eb,#4f46e5); padding:32px 40px; text-align:center;"">
<td style=""background:linear-gradient(135deg,#1e8a4c,#33b161); padding:32px 40px; text-align:center;"">
<h1 style=""color:#ffffff; margin:0; font-size:24px; font-weight:700;"">Autopilot Monitor</h1>
<p style=""color:#c7d2fe; margin:8px 0 0; font-size:14px;"">Windows Autopilot monitoring</p>
<p style=""color:#c9efd8; margin:8px 0 0; font-size:14px;"">Windows Autopilot monitoring</p>
</td>
</tr>
@@ -68,8 +68,8 @@ public static class EmailTemplates
</p>
<ul style=""color:#374151; font-size:14px; line-height:1.8; margin:0 0 24px; padding-left:20px;"">
<li><a href=""https://github.com/okieselbach/AutopilotMonitor/issues"" target=""_blank"" style=""color:#2563eb; text-decoration:underline;"">Open a GitHub Issue</a></li>
<li><a href=""https://www.linkedin.com/in/oliver-kieselbach/"" target=""_blank"" style=""color:#2563eb; text-decoration:underline;"">Connect on LinkedIn</a></li>
<li><a href=""https://github.com/okieselbach/AutopilotMonitor/issues"" target=""_blank"" style=""color:#1e8a4c; text-decoration:underline;"">Open a GitHub Issue</a></li>
<li><a href=""https://www.linkedin.com/in/oliver-kieselbach/"" target=""_blank"" style=""color:#1e8a4c; text-decoration:underline;"">Connect on LinkedIn</a></li>
</ul>
<p style=""color:#374151; font-size:15px; line-height:1.6; margin:0 0 24px;"">
@@ -117,9 +117,9 @@ public static class EmailTemplates
<!-- Header -->
<tr>
<td style=""background:linear-gradient(135deg,#2563eb,#4f46e5); padding:32px 40px; text-align:center;"">
<td style=""background:linear-gradient(135deg,#1e8a4c,#33b161); padding:32px 40px; text-align:center;"">
<h1 style=""color:#ffffff; margin:0; font-size:24px; font-weight:700;"">Autopilot Monitor</h1>
<p style=""color:#c7d2fe; margin:8px 0 0; font-size:14px;"">Windows Autopilot monitoring</p>
<p style=""color:#c9efd8; margin:8px 0 0; font-size:14px;"">Windows Autopilot monitoring</p>
</td>
</tr>
@@ -140,7 +140,7 @@ public static class EmailTemplates
<!-- CTA Button -->
<table cellpadding=""0"" cellspacing=""0"" style=""margin:0 auto 32px;"">
<tr>
<td style=""background-color:#2563eb; border-radius:8px;"">
<td style=""background-color:#1e8a4c; border-radius:8px;"">
<a href=""{Constants.DocsBaseUrl}"" target=""_blank""
style=""display:inline-block; padding:14px 32px; color:#ffffff; font-size:15px; font-weight:600; text-decoration:none;"">
View Documentation
@@ -164,8 +164,8 @@ public static class EmailTemplates
</p>
<ul style=""color:#374151; font-size:14px; line-height:1.8; margin:0 0 24px; padding-left:20px;"">
<li><a href=""https://github.com/okieselbach/AutopilotMonitor/issues"" target=""_blank"" style=""color:#2563eb; text-decoration:underline;"">Open a GitHub Issue</a></li>
<li><a href=""https://www.linkedin.com/in/oliver-kieselbach/"" target=""_blank"" style=""color:#2563eb; text-decoration:underline;"">Connect on LinkedIn</a></li>
<li><a href=""https://github.com/okieselbach/AutopilotMonitor/issues"" target=""_blank"" style=""color:#1e8a4c; text-decoration:underline;"">Open a GitHub Issue</a></li>
<li><a href=""https://www.linkedin.com/in/oliver-kieselbach/"" target=""_blank"" style=""color:#1e8a4c; text-decoration:underline;"">Connect on LinkedIn</a></li>
</ul>
<p style=""color:#6b7280; font-size:14px; line-height:1.6; margin:0;"">
@@ -5,6 +5,7 @@ using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Functions.Admin;
using AutopilotMonitor.Functions.Services.Deletion;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
@@ -167,6 +168,7 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
private readonly ITenantOffboardingEnqueuer _reEnqueuer;
private readonly OpsEventService _opsEvents;
private readonly ITenantCustomsArchiveRepository _customsArchive;
private readonly IConfigRepository _configRepo;
private readonly IOffboardFarewellEmailSender _farewellEmail;
private readonly ILogger<TenantOffboardingHandler> _logger;
@@ -182,6 +184,7 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
ITenantOffboardingEnqueuer reEnqueuer,
OpsEventService opsEvents,
ITenantCustomsArchiveRepository customsArchive,
IConfigRepository configRepo,
IOffboardFarewellEmailSender farewellEmail,
ILogger<TenantOffboardingHandler> logger)
{
@@ -196,6 +199,7 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
_reEnqueuer = reEnqueuer;
_opsEvents = opsEvents;
_customsArchive = customsArchive;
_configRepo = configRepo;
_farewellEmail = farewellEmail;
_logger = logger;
}
@@ -582,6 +586,27 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
private async Task RunPostDrainPhasesAsync(
OffboardingHistoryEntry history, string tenantId, CancellationToken ct)
{
// 2.D-guard — un-offboard race check. The destructive phases below are only
// legal while the TenantConfiguration row is still the Phase-1 offboarding
// tombstone (or already deleted by a previous 2.F-final on this resume path;
// null passes). If an operator re-enabled the tenant mid-cascade — the config
// write paths refuse that, but this is the last line of defense — wiping now
// would destroy data of a live tenant, and sessions registered after the 2.B
// enumeration would survive as orphan rows while their Events/Signals
// partitions get wiped. Fail-closed; the Failed marker + OpsEvent route the
// conflict to an operator instead.
var currentConfig = await _configRepo.GetTenantConfigurationAsync(tenantId);
if (currentConfig != null && !TenantOffboardFunction.IsOffboardingTombstone(currentConfig))
{
var msg =
$"TenantConfiguration is no longer the offboarding tombstone " +
$"(Disabled={currentConfig.Disabled}, Reason={currentConfig.DisabledReason ?? "<null>"}) " +
"— refusing to wipe a re-enabled tenant.";
await FailAsync(history, tenantId, "unoffboard_race", msg, ct);
throw new InvalidOperationException(
$"TenantOffboarding fail-closed for tenant={tenantId} phase=unoffboard_race");
}
// 2.D-archive (PR3.B §3) — Archive then wipe GatherRules / AnalyzeRules /
// ImeLogPatterns. Fail-loud: a row that cannot be archived must NOT be wiped.
// After the third iteration if the source still has rows, throw with
@@ -4,6 +4,7 @@ using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Functions.Admin;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
@@ -185,6 +186,14 @@ namespace AutopilotMonitor.Functions.Services
return PatchOutcome.Fail(PatchFailure.NotFound, $"Tenant {tenantId} has no configuration row.");
var (initial, etag) = read.Value;
// Mid-offboarding freeze: while the row is the offboarding tombstone, the
// cascade owns it. Any patch — a GA lifting Disabled included — would race
// the queued wipe: data written afterwards gets destroyed, and sessions
// registered after enumeration would survive as orphans.
if (TenantOffboardFunction.IsOffboardingTombstone(initial))
return PatchOutcome.Fail(PatchFailure.ValidationFailed,
"Tenant offboarding is in progress — the configuration is frozen until the cascade completes.");
// Patch onto a deep clone; MissingMemberHandling.Error is belt-and-braces on
// top of the key gate above. JSON null = explicit clear; omitted = untouched;
// camelCase input binds case-insensitively to the PascalCase model.
@@ -275,6 +284,12 @@ namespace AutopilotMonitor.Functions.Services
return PatchOutcome.Fail(PatchFailure.NotFound, $"Tenant {tenantId} has no configuration row.");
var (current, etag) = read.Value;
// Same mid-offboarding freeze as ApplyFieldPatchAsync: a revert restores a
// pre-offboarding snapshot with Disabled=false — the exact un-offboard race.
if (TenantOffboardFunction.IsOffboardingTombstone(current))
return PatchOutcome.Fail(PatchFailure.ValidationFailed,
"Tenant offboarding is in progress — the configuration is frozen until the cascade completes.");
var candidate = DeepClone(target);
// Identity is never restored from a snapshot; protected/system-owned fields
// only with the explicit GA opt-in.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

+3 -1
View File
@@ -80,7 +80,9 @@ export const metadata: Metadata = {
icons: {
icon: "/icon.svg",
shortcut: "/icon.svg",
apple: "/icon.svg",
// Safari cannot use SVG for apple-touch-icon; must be an opaque PNG
// (served by the app/apple-icon.png file convention).
apple: "/apple-icon.png",
},
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 161 KiB