fix(backend): bind device block/kill to the certificate identity and repair session-scoped auto-unblock

CWE-807: the kill switch was keyed only by the caller-declared X-Device-SerialNumber, so omitting or forging the header dodged block and kill. A block now mirrors onto the device's certificate identities (Sessions.OwnerDeviceId) as id:{guid} alias rows in BlockedDevices, resolved at block time; KillSwitchEvaluator checks serial, then identity, then version and stamps the DeviceIdentityBinding request dimension. Alias rows are hidden from listings and exempt from the legacy-key migration; unblock removes them. Session-scoped watchdog blocks were unreachable (no session id ever reached the evaluator): ingest now carries the verdict past the body parse and re-evaluates with the session id, and holds the registered serial against the block list when the header differs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-09-01 23:37:41 +02:00
parent 27bf96f23f
commit fb49dc9ad2
15 changed files with 1043 additions and 112 deletions
@@ -6,6 +6,7 @@ using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
@@ -28,7 +29,7 @@ public class BlockedDeviceServiceCrossInstanceTests
private const string SerialPF55 = "PF55PSKL";
private static BlockedDeviceService CreateService(FakeDeviceSecurityRepository repo) =>
new(repo, NullLogger<BlockedDeviceService>.Instance);
new(repo, Mock.Of<ISessionRepository>(), NullLogger<BlockedDeviceService>.Instance);
[Theory]
[InlineData("Block")] // Pauses uploads; session remains alive
@@ -184,18 +185,24 @@ public class BlockedDeviceServiceCrossInstanceTests
=> Task.FromResult(new List<BlockedDeviceEntry>(_entries.Values));
public Task BlockDeviceAsync(string tenantId, string serialNumber, int durationHours,
string blockedByEmail, string? reason = null, string action = "Block", string? blockedSessionId = null)
string blockedByEmail, string? reason = null, string action = "Block", string? blockedSessionId = null,
IReadOnlyCollection<string>? aliasDeviceIds = null)
{
SetBlock(tenantId, serialNumber, DateTime.UtcNow.AddHours(durationHours), action);
return Task.CompletedTask;
}
public Task UnblockDeviceAsync(string tenantId, string serialNumber)
public Task<IReadOnlyList<string>> UnblockDeviceAsync(string tenantId, string serialNumber)
{
ClearBlock(tenantId, serialNumber);
return Task.CompletedTask;
return Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
}
// Identity leg unused in these tests — no alias rows.
public Task<(bool isBlocked, DateTime? unblockAt, string action, string? blockedSessionIds, string? serialNumber)> IsDeviceIdentityBlockedAsync(
string tenantId, string intuneDeviceId)
=> Task.FromResult<(bool, DateTime?, string, string?, string?)>((false, null, "Block", null, null));
// --- Version block surface is unused in these tests ---
public Task<(bool isBlocked, string action, string? matchedPattern)> IsVersionBlockedAsync(string agentVersion)
=> Task.FromResult<(bool, string, string?)>((false, "Block", null));
@@ -31,7 +31,7 @@ public class BlockedDeviceServiceTests
public async Task NotBlockedDevice_SecondCallWithinWindow_DoesNotHitStorage()
{
var repo = NewRepo(blocked: false);
var service = new BlockedDeviceService(repo.Object, NullLogger<BlockedDeviceService>.Instance);
var service = new BlockedDeviceService(repo.Object, Mock.Of<ISessionRepository>(), NullLogger<BlockedDeviceService>.Instance);
var first = await service.IsBlockedAsync(TenantId, Serial);
var second = await service.IsBlockedAsync(TenantId, Serial);
@@ -51,7 +51,7 @@ public class BlockedDeviceServiceTests
var repo = NewRepo(blocked: false);
// Zero window → every subsequent call revalidates, simulating an expired entry.
var service = new BlockedDeviceService(
repo.Object, NullLogger<BlockedDeviceService>.Instance, TimeSpan.Zero);
repo.Object, Mock.Of<ISessionRepository>(), NullLogger<BlockedDeviceService>.Instance, TimeSpan.Zero);
var first = await service.IsBlockedAsync(TenantId, Serial);
Assert.False(first.isBlocked);
@@ -70,7 +70,7 @@ public class BlockedDeviceServiceTests
public async Task BlockDevice_OverridesCachedNegativeEntry()
{
var repo = NewRepo(blocked: false);
var service = new BlockedDeviceService(repo.Object, NullLogger<BlockedDeviceService>.Instance);
var service = new BlockedDeviceService(repo.Object, Mock.Of<ISessionRepository>(), NullLogger<BlockedDeviceService>.Instance);
// Prime the negative cache.
var before = await service.IsBlockedAsync(TenantId, Serial);
@@ -92,7 +92,7 @@ public class BlockedDeviceServiceTests
// widen to a whole-device block (negative entries carry BlockedSessionIds = null,
// which the merge previously kept — and null means "whole device" downstream).
var repo = NewRepo(blocked: false);
var service = new BlockedDeviceService(repo.Object, NullLogger<BlockedDeviceService>.Instance);
var service = new BlockedDeviceService(repo.Object, Mock.Of<ISessionRepository>(), NullLogger<BlockedDeviceService>.Instance);
// Prime the negative cache, then apply a session-aware auto-block locally.
_ = await service.IsBlockedAsync(TenantId, Serial);
@@ -114,11 +114,128 @@ public class BlockedDeviceServiceTests
public async Task BlockedDevice_IsReportedBlocked_OnCacheMissRefresh()
{
var repo = NewRepo(blocked: true, unblockAt: DateTime.UtcNow.AddHours(2));
var service = new BlockedDeviceService(repo.Object, NullLogger<BlockedDeviceService>.Instance);
var service = new BlockedDeviceService(repo.Object, Mock.Of<ISessionRepository>(), NullLogger<BlockedDeviceService>.Instance);
var result = await service.IsBlockedAsync(TenantId, Serial);
Assert.True(result.isBlocked);
Assert.NotNull(result.unblockAt);
}
// =========================================================================
// Identity aliases: resolved at block time, mirrored in cache, dropped on unblock
// =========================================================================
private const string DeviceId = "0f8fad5b-d9cb-469f-a165-70867728950e";
private static Mock<ISessionRepository> SessionsKnowing(params string[] deviceIds)
{
var sessions = new Mock<ISessionRepository>();
sessions.Setup(s => s.GetOwnerDeviceIdsForSerialAsync(TenantId, Serial, It.IsAny<int>()))
.ReturnsAsync(deviceIds);
return sessions;
}
[Fact]
public async Task BlockDevice_ResolvesAliasesFromSessions_AndIdentityLegAnswersFromCache()
{
var repo = NewRepo(blocked: false);
var service = new BlockedDeviceService(repo.Object, SessionsKnowing(DeviceId).Object, NullLogger<BlockedDeviceService>.Instance);
await service.BlockDeviceAsync(TenantId, Serial, durationHours: 1, blockedByEmail: "alice@contoso.com", action: "Kill");
// Storage got the alias list…
repo.Verify(r => r.BlockDeviceAsync(TenantId, Serial, 1, "alice@contoso.com", null, "Kill", null,
It.Is<IReadOnlyCollection<string>>(ids => ids.Contains(DeviceId))), Times.Once);
// …and the identity leg is served from the cache the block just primed — the repo mock
// would answer "not blocked" if storage were consulted.
var byIdentity = await service.IsIdentityBlockedAsync(TenantId, DeviceId);
Assert.True(byIdentity.isBlocked);
Assert.Equal("Kill", byIdentity.action);
Assert.Equal(Serial.ToUpperInvariant(), byIdentity.serialNumber);
repo.Verify(r => r.IsDeviceIdentityBlockedAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task BlockDevice_NoSessionsKnown_StaysSerialOnly()
{
var repo = NewRepo(blocked: false);
var service = new BlockedDeviceService(repo.Object, SessionsKnowing().Object, NullLogger<BlockedDeviceService>.Instance);
await service.BlockDeviceAsync(TenantId, Serial, durationHours: 1, blockedByEmail: "alice@contoso.com");
repo.Verify(r => r.BlockDeviceAsync(TenantId, Serial, 1, "alice@contoso.com", null, "Block", null,
It.Is<IReadOnlyCollection<string>>(ids => ids.Count == 0)), Times.Once);
var byIdentity = await service.IsIdentityBlockedAsync(TenantId, DeviceId);
Assert.False(byIdentity.isBlocked);
}
[Fact]
public async Task BlockDevice_AliasResolutionFailure_DoesNotFailTheBlock()
{
var repo = NewRepo(blocked: false);
var sessions = new Mock<ISessionRepository>();
sessions.Setup(s => s.GetOwnerDeviceIdsForSerialAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>()))
.ThrowsAsync(new InvalidOperationException("storage down"));
var service = new BlockedDeviceService(repo.Object, sessions.Object, NullLogger<BlockedDeviceService>.Instance);
await service.BlockDeviceAsync(TenantId, Serial, durationHours: 1, blockedByEmail: "alice@contoso.com");
repo.Verify(r => r.BlockDeviceAsync(TenantId, Serial, 1, "alice@contoso.com", null, "Block", null,
It.IsAny<IReadOnlyCollection<string>>()), Times.Once);
Assert.True((await service.IsBlockedAsync(TenantId, Serial)).isBlocked);
}
[Fact]
public async Task UnblockDevice_DropsIdentityEntries()
{
var repo = NewRepo(blocked: false);
repo.Setup(r => r.UnblockDeviceAsync(TenantId, Serial)).ReturnsAsync(new[] { DeviceId });
var service = new BlockedDeviceService(repo.Object, SessionsKnowing(DeviceId).Object, NullLogger<BlockedDeviceService>.Instance);
await service.BlockDeviceAsync(TenantId, Serial, durationHours: 1, blockedByEmail: "alice@contoso.com");
Assert.True((await service.IsIdentityBlockedAsync(TenantId, DeviceId)).isBlocked);
await service.UnblockDeviceAsync(TenantId, Serial);
// The identity key is gone from the cache → falls through to storage (mock: not blocked).
var after = await service.IsIdentityBlockedAsync(TenantId, DeviceId);
Assert.False(after.isBlocked);
repo.Verify(r => r.IsDeviceIdentityBlockedAsync(TenantId, DeviceId), Times.Once);
}
[Fact]
public async Task IdentityLeg_CacheMiss_PointReadsAliasRow()
{
var repo = NewRepo(blocked: false);
repo.Setup(r => r.IsDeviceIdentityBlockedAsync(TenantId, DeviceId))
.ReturnsAsync((true, DateTime.UtcNow.AddHours(1), "Block", (string?)null, Serial.ToUpperInvariant()));
var service = new BlockedDeviceService(repo.Object, Mock.Of<ISessionRepository>(), NullLogger<BlockedDeviceService>.Instance);
var first = await service.IsIdentityBlockedAsync(TenantId, DeviceId);
var second = await service.IsIdentityBlockedAsync(TenantId, DeviceId);
Assert.True(first.isBlocked);
Assert.True(second.isBlocked);
Assert.Equal(Serial.ToUpperInvariant(), second.serialNumber);
repo.Verify(r => r.IsDeviceIdentityBlockedAsync(TenantId, DeviceId), Times.Once);
}
[Fact]
public async Task IdentityLeg_SessionScoped_NewSession_AutoUnblocksThroughPrimarySerial()
{
var repo = NewRepo(blocked: false);
repo.Setup(r => r.UnblockDeviceAsync(TenantId, Serial.ToUpperInvariant())).ReturnsAsync(new[] { DeviceId });
var service = new BlockedDeviceService(repo.Object, SessionsKnowing(DeviceId).Object, NullLogger<BlockedDeviceService>.Instance);
await service.BlockDeviceAsync(TenantId, Serial, durationHours: 1, blockedByEmail: "System.Maintenance",
blockedSessionId: "33333333-3333-3333-3333-333333333333");
var sameSession = await service.IsIdentityBlockedAsync(TenantId, DeviceId, "33333333-3333-3333-3333-333333333333");
var newSession = await service.IsIdentityBlockedAsync(TenantId, DeviceId, "44444444-4444-4444-4444-444444444444");
Assert.True(sameSession.isBlocked);
Assert.False(newSession.isBlocked);
repo.Verify(r => r.UnblockDeviceAsync(TenantId, Serial.ToUpperInvariant()), Times.Once);
}
}
@@ -155,6 +155,195 @@ public class KillSwitchEvaluatorTests
Assert.Equal(2, savedOpsEvents.Count);
}
// =========================================================================
// Certificate-identity leg (CWE-807: block keyed by a caller-declared serial)
// =========================================================================
private const string DeviceId = "0f8fad5b-d9cb-469f-a165-70867728950e";
[Fact]
public async Task Evaluate_SerialHeaderMissing_IdentityAlias_Blocked()
{
// Bypass 1 of the finding: a blocked device that simply omits X-Device-SerialNumber
// used to skip the device check entirely.
var repo = new FakeSecurityRepo();
var unblockAt = DateTime.UtcNow.AddHours(12);
repo.SetDeviceBlock(TenantA, Serial, unblockAt, "Block");
repo.SetIdentityAlias(TenantA, DeviceId, Serial);
var (evaluator, savedOpsEvents) = CreateEvaluator(repo);
var verdict = await evaluator.EvaluateAsync(TenantA, serialNumber: null, "2.0.114", "telemetry", intuneDeviceId: DeviceId);
Assert.True(verdict.IsBlocked);
Assert.False(verdict.IsKill);
Assert.Equal(unblockAt, verdict.UnblockAt);
Assert.Equal(Serial, verdict.BlockedSerial);
Assert.Equal("IdentityBlocked", verdict.IdentityBinding);
Assert.Empty(savedOpsEvents);
}
[Fact]
public async Task Evaluate_ForeignSerialHeader_IdentityAlias_Blocked()
{
// Bypass 2: a foreign (unblocked) serial in the header misses the serial row.
var repo = new FakeSecurityRepo();
repo.SetDeviceBlock(TenantA, Serial, DateTime.UtcNow.AddHours(12), "Block");
repo.SetIdentityAlias(TenantA, DeviceId, Serial);
var (evaluator, _) = CreateEvaluator(repo);
var verdict = await evaluator.EvaluateAsync(TenantA, "SOMEONE-ELSES-SERIAL", "2.0.114", "telemetry", intuneDeviceId: DeviceId);
Assert.True(verdict.IsBlocked);
Assert.Equal(Serial, verdict.BlockedSerial);
Assert.Equal("IdentityBlocked", verdict.IdentityBinding);
}
[Fact]
public async Task Evaluate_IdentityKill_EmitsOneOpsEvent_KeyedOnBlockedSerial()
{
var repo = new FakeSecurityRepo();
repo.SetDeviceBlock(TenantA, Serial, DateTime.UtcNow.AddHours(12), "Kill");
repo.SetIdentityAlias(TenantA, DeviceId, Serial);
var (evaluator, savedOpsEvents) = CreateEvaluator(repo);
var first = await evaluator.EvaluateAsync(TenantA, null, "2.0.114", "config", intuneDeviceId: DeviceId);
var second = await evaluator.EvaluateAsync(TenantA, "FORGED", "2.0.114", "telemetry", intuneDeviceId: DeviceId);
Assert.True(first.IsKill);
Assert.True(second.IsKill);
var evt = Assert.Single(savedOpsEvents);
Assert.Equal("KillSignalDelivered", evt.EventType);
Assert.Contains(Serial, evt.Details);
}
[Fact]
public async Task Evaluate_SerialHit_ShortCircuits_IdentityNeverConsulted()
{
// Cost pin: the honest case (header == blocked serial) must not pay a second lookup.
var repo = new FakeSecurityRepo();
repo.SetDeviceBlock(TenantA, Serial, DateTime.UtcNow.AddHours(12), "Block");
repo.SetIdentityAlias(TenantA, DeviceId, Serial);
var (evaluator, _) = CreateEvaluator(repo);
var verdict = await evaluator.EvaluateAsync(TenantA, Serial, "2.0.114", "telemetry", intuneDeviceId: DeviceId);
Assert.True(verdict.IsBlocked);
Assert.Equal("Match", verdict.IdentityBinding);
Assert.Equal(0, repo.IdentityLookups);
}
[Fact]
public async Task Evaluate_NoIdentity_SerialOnly_UnchangedBehaviour()
{
// Bootstrap-token callers / non-GUID CN: byte-identical to the pre-alias behaviour.
var repo = new FakeSecurityRepo();
repo.SetDeviceBlock(TenantA, Serial, DateTime.UtcNow.AddHours(12), "Block");
repo.SetIdentityAlias(TenantA, DeviceId, Serial);
var (evaluator, _) = CreateEvaluator(repo);
var dodged = await evaluator.EvaluateAsync(TenantA, serialNumber: null, "2.0.114", "telemetry");
var clean = await evaluator.EvaluateAsync(TenantA, "OTHER", "2.0.114", "telemetry");
Assert.False(dodged.IsBlocked);
Assert.False(clean.IsBlocked);
Assert.Equal("NoIdentity", dodged.IdentityBinding);
Assert.Equal(0, repo.IdentityLookups);
}
[Fact]
public async Task Evaluate_IdentityPresent_NotBlocked_BindingMatch()
{
var (evaluator, _) = CreateEvaluator(new FakeSecurityRepo());
var verdict = await evaluator.EvaluateAsync(TenantA, Serial, "2.0.114", "telemetry", intuneDeviceId: DeviceId);
Assert.False(verdict.IsBlocked);
Assert.Equal("Match", verdict.IdentityBinding);
}
// =========================================================================
// Session-scoped blocks (watchdog auto-block): decided once the session is known
// =========================================================================
private const string BlockedSession = "33333333-3333-3333-3333-333333333333";
private const string NewSession = "44444444-4444-4444-4444-444444444444";
[Fact]
public async Task Evaluate_SessionScoped_WithoutSession_BlockedAndCarriesSessionIds()
{
var repo = new FakeSecurityRepo();
repo.SetDeviceBlock(TenantA, Serial, DateTime.UtcNow.AddHours(12), "Block", blockedSessionIds: BlockedSession);
var (evaluator, _) = CreateEvaluator(repo);
var verdict = await evaluator.EvaluateAsync(TenantA, Serial, "2.0.114", "telemetry");
Assert.True(verdict.IsBlocked);
Assert.True(verdict.IsSessionScoped);
Assert.Equal(BlockedSession, verdict.BlockedSessionIds);
}
[Fact]
public async Task Evaluate_SessionScoped_SameSession_StaysBlocked()
{
var repo = new FakeSecurityRepo();
repo.SetDeviceBlock(TenantA, Serial, DateTime.UtcNow.AddHours(12), "Block", blockedSessionIds: BlockedSession);
var (evaluator, _) = CreateEvaluator(repo);
var verdict = await evaluator.EvaluateAsync(TenantA, Serial, "2.0.114", "telemetry", sessionId: BlockedSession);
Assert.True(verdict.IsBlocked);
}
[Fact]
public async Task Evaluate_SessionScoped_NewSession_AutoUnblocks_ThenVersionLegRuns()
{
// A new enrollment on the same device lifts the runaway-session block — and the version
// leg the device hit had short-circuited must still get its turn.
var repo = new FakeSecurityRepo();
repo.SetDeviceBlock(TenantA, Serial, DateTime.UtcNow.AddHours(12), "Block", blockedSessionIds: BlockedSession);
repo.AddVersionRule("1.*", "Block");
var (evaluator, _) = CreateEvaluator(repo);
var lifted = await evaluator.EvaluateAsync(TenantA, Serial, "2.0.114", "telemetry", sessionId: NewSession);
var oldAgent = await evaluator.EvaluateAsync(TenantA, Serial, "1.0.30", "telemetry", sessionId: NewSession);
Assert.False(lifted.IsBlocked);
Assert.True(oldAgent.IsBlocked);
Assert.Contains("1.*", oldAgent.Message);
}
[Fact]
public async Task Evaluate_SessionScoped_ViaIdentity_NewSession_AutoUnblocksPrimary()
{
var repo = new FakeSecurityRepo();
repo.SetDeviceBlock(TenantA, Serial, DateTime.UtcNow.AddHours(12), "Block", blockedSessionIds: BlockedSession);
repo.SetIdentityAlias(TenantA, DeviceId, Serial);
var (evaluator, _) = CreateEvaluator(repo);
var pending = await evaluator.EvaluateAsync(TenantA, null, "2.0.114", "telemetry", intuneDeviceId: DeviceId);
var lifted = await evaluator.EvaluateAsync(TenantA, null, "2.0.114", "telemetry", intuneDeviceId: DeviceId, sessionId: NewSession);
Assert.True(pending.IsSessionScoped);
Assert.False(lifted.IsBlocked);
// The auto-unblock runs through the primary serial, so the serial row is gone too.
var serialLeg = await repo.IsDeviceBlockedAsync(TenantA, Serial);
Assert.False(serialLeg.isBlocked);
}
[Fact]
public async Task Evaluate_Kill_IsNeverSessionScoped()
{
var repo = new FakeSecurityRepo();
repo.SetDeviceBlock(TenantA, Serial, DateTime.UtcNow.AddHours(12), "Kill", blockedSessionIds: BlockedSession);
var (evaluator, _) = CreateEvaluator(repo);
var verdict = await evaluator.EvaluateAsync(TenantA, Serial, "2.0.114", "telemetry", sessionId: NewSession);
Assert.True(verdict.IsBlocked);
Assert.True(verdict.IsKill);
Assert.False(verdict.IsSessionScoped);
}
[Fact]
public void ShouldRecordOpsEvent_SameKeyCaseInsensitive_ClaimedOnce()
{
@@ -185,7 +374,7 @@ public class KillSwitchEvaluatorTests
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
var evaluator = new KillSwitchEvaluator(
new BlockedDeviceService(repo, NullLogger<BlockedDeviceService>.Instance),
new BlockedDeviceService(repo, Mock.Of<ISessionRepository>(), NullLogger<BlockedDeviceService>.Instance),
new BlockedVersionService(repo, NullLogger<BlockedVersionService>.Instance),
opsService,
NullLogger<KillSwitchEvaluator>.Instance);
@@ -202,7 +391,7 @@ public class KillSwitchEvaluatorTests
private readonly ConcurrentDictionary<string, BlockedDeviceEntry> _devices = new(StringComparer.OrdinalIgnoreCase);
private readonly List<BlockedVersionEntry> _versionRules = new();
public void SetDeviceBlock(string tenantId, string serialNumber, DateTime unblockAt, string action)
public void SetDeviceBlock(string tenantId, string serialNumber, DateTime unblockAt, string action, string? blockedSessionIds = null)
{
_devices[$"{tenantId}|{serialNumber.ToUpperInvariant()}"] = new BlockedDeviceEntry
{
@@ -211,9 +400,13 @@ public class KillSwitchEvaluatorTests
BlockedAt = DateTime.UtcNow,
UnblockAt = unblockAt,
Action = action,
BlockedSessionIds = blockedSessionIds,
};
}
/// <summary>Storage reads of the identity leg — pins that a serial hit never pays a second lookup.</summary>
public int IdentityLookups;
public void AddVersionRule(string pattern, string action)
{
lock (_versionRules)
@@ -226,11 +419,30 @@ public class KillSwitchEvaluatorTests
if (_devices.TryGetValue($"{tenantId}|{serialNumber.ToUpperInvariant()}", out var entry) &&
entry.UnblockAt is { } uat && DateTime.UtcNow < uat)
{
return Task.FromResult<(bool, DateTime?, string, string?)>((true, uat, entry.Action, null));
return Task.FromResult<(bool, DateTime?, string, string?)>((true, uat, entry.Action, entry.BlockedSessionIds));
}
return Task.FromResult<(bool, DateTime?, string, string?)>((false, null, "Block", null));
}
/// <summary>Alias row: the identity leg answers with the primary's verdict + serial.</summary>
public void SetIdentityAlias(string tenantId, string intuneDeviceId, string serialNumber)
{
_aliases[$"{tenantId}|id:{intuneDeviceId.ToLowerInvariant()}"] = serialNumber;
}
// alias key → primary serial (mirrors the id:{guid} rows)
private readonly ConcurrentDictionary<string, string> _aliases = new(StringComparer.OrdinalIgnoreCase);
public async Task<(bool isBlocked, DateTime? unblockAt, string action, string? blockedSessionIds, string? serialNumber)> IsDeviceIdentityBlockedAsync(
string tenantId, string intuneDeviceId)
{
System.Threading.Interlocked.Increment(ref IdentityLookups);
if (!_aliases.TryGetValue($"{tenantId}|id:{intuneDeviceId.ToLowerInvariant()}", out var serial))
return (false, null, "Block", null, null);
var (isBlocked, unblockAt, action, ids) = await IsDeviceBlockedAsync(tenantId, serial);
return (isBlocked, unblockAt, action, ids, isBlocked ? serial.ToUpperInvariant() : null);
}
public Task<List<BlockedDeviceEntry>> GetBlockedDevicesAsync(string tenantId)
=> Task.FromResult(_devices.Values.Where(d => string.Equals(d.TenantId, tenantId, StringComparison.OrdinalIgnoreCase)).ToList());
@@ -238,16 +450,28 @@ public class KillSwitchEvaluatorTests
=> Task.FromResult(_devices.Values.ToList());
public Task BlockDeviceAsync(string tenantId, string serialNumber, int durationHours,
string blockedByEmail, string? reason = null, string action = "Block", string? blockedSessionId = null)
string blockedByEmail, string? reason = null, string action = "Block", string? blockedSessionId = null,
IReadOnlyCollection<string>? aliasDeviceIds = null)
{
SetDeviceBlock(tenantId, serialNumber, DateTime.UtcNow.AddHours(durationHours), action);
foreach (var id in aliasDeviceIds ?? Array.Empty<string>())
SetIdentityAlias(tenantId, id, serialNumber);
return Task.CompletedTask;
}
public Task UnblockDeviceAsync(string tenantId, string serialNumber)
public Task<IReadOnlyList<string>> UnblockDeviceAsync(string tenantId, string serialNumber)
{
_devices.TryRemove($"{tenantId}|{serialNumber.ToUpperInvariant()}", out _);
return Task.CompletedTask;
var removed = new List<string>();
foreach (var kv in _aliases)
{
if (!kv.Key.StartsWith($"{tenantId}|id:", StringComparison.OrdinalIgnoreCase)
|| !string.Equals(kv.Value, serialNumber, StringComparison.OrdinalIgnoreCase))
continue;
if (_aliases.TryRemove(kv.Key, out _))
removed.Add(kv.Key.Substring(kv.Key.IndexOf("id:", StringComparison.Ordinal) + 3));
}
return Task.FromResult<IReadOnlyList<string>>(removed);
}
public Task<(bool isBlocked, string action, string? matchedPattern)> IsVersionBlockedAsync(string agentVersion)
@@ -36,4 +36,40 @@ public class TableDeviceSecurityRepositoryKeyTests
[Fact]
public void LegacyDeviceRowKey_IsVerbatimKey_WhenCaseDiffers()
=> Assert.Equal("abc123", TableDeviceSecurityRepository.LegacyDeviceRowKey("abc123"));
// --- Identity alias rows ---
[Theory]
[InlineData("0F8FAD5B-D9CB-469F-A165-70867728950E")]
[InlineData(" 0f8fad5b-d9cb-469f-a165-70867728950e ")]
[InlineData("{0f8fad5b-d9cb-469f-a165-70867728950e}")]
public void IdentityRowKey_NormalizesGuidForm(string input)
=> Assert.Equal("id:0f8fad5b-d9cb-469f-a165-70867728950e", TableDeviceSecurityRepository.IdentityRowKey(input));
[Fact]
public void IdentityRowKey_NeverCollidesWithASerialKey()
{
// A serial that happens to look like an alias key is escaped (':' → %3A) and upper-cased.
var serialKey = TableDeviceSecurityRepository.DeviceRowKey("id:0f8fad5b-d9cb-469f-a165-70867728950e");
Assert.False(TableDeviceSecurityRepository.IsIdentityRowKey(serialKey));
Assert.True(TableDeviceSecurityRepository.IsIdentityRowKey("id:0f8fad5b-d9cb-469f-a165-70867728950e"));
Assert.False(TableDeviceSecurityRepository.IsIdentityRowKey(null));
}
[Fact]
public void ParseAliasDeviceIds_DedupesAndDropsNonGuids()
{
var parsed = TableDeviceSecurityRepository.ParseAliasDeviceIds(
"0F8FAD5B-D9CB-469F-A165-70867728950E,junk,,0f8fad5b-d9cb-469f-a165-70867728950e, 7c9e6679-7425-40de-944b-e07fc1f90ae7");
Assert.Equal(new[] { "0f8fad5b-d9cb-469f-a165-70867728950e", "7c9e6679-7425-40de-944b-e07fc1f90ae7" }, parsed);
}
[Fact]
public void MergeAliasDeviceIds_KeepsStoredFirst_AppendsNew_IgnoresDuplicates()
{
var merged = TableDeviceSecurityRepository.MergeAliasDeviceIds(
new List<string> { "0f8fad5b-d9cb-469f-a165-70867728950e" },
new[] { "0F8FAD5B-D9CB-469F-A165-70867728950E", "7c9e6679-7425-40de-944b-e07fc1f90ae7", "not-a-guid" });
Assert.Equal(new[] { "0f8fad5b-d9cb-469f-a165-70867728950e", "7c9e6679-7425-40de-944b-e07fc1f90ae7" }, merged);
}
}
@@ -56,6 +56,24 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
return (true, unblockAt, action, blockedSessionIds);
}
public async Task<(bool isBlocked, DateTime? unblockAt, string action, string? blockedSessionIds, string? serialNumber)> IsDeviceIdentityBlockedAsync(
string tenantId, string intuneDeviceId)
{
if (string.IsNullOrEmpty(tenantId) || string.IsNullOrWhiteSpace(intuneDeviceId))
return (false, null, "Block", null, null);
var entity = await TryGetEntityAsync(tenantId, IdentityRowKey(intuneDeviceId));
if (entity == null)
return (false, null, "Block", null, null);
var unblockAt = entity.GetDateTimeOffset("UnblockAt")?.UtcDateTime ?? DateTime.MinValue;
if (DateTime.UtcNow >= unblockAt)
return (false, null, "Block", null, null);
return (true, unblockAt, entity.GetString("Action") ?? "Block",
entity.GetString("BlockedSessionIds"), entity.GetString("SerialNumber"));
}
/// <summary>
/// Point-reads the block row for a serial under its canonical key, falling back to the
/// legacy verbatim-case key for rows written before serials were canonicalized.
@@ -100,6 +118,11 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
continue;
}
// Alias rows mirror a primary row (whose AliasDeviceIds names them) — listing them
// would show the same serial twice. Expired aliases are still swept above.
if (IsIdentityRowKey(entity.RowKey))
continue;
result.Add(MapToBlockedDeviceEntry(entity, tenantId, now));
await MigrateLegacyRowKeyAsync(entity);
}
@@ -126,6 +149,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
continue;
}
if (IsIdentityRowKey(entity.RowKey))
continue;
result.Add(MapToBlockedDeviceEntry(entity, entity.PartitionKey, now));
await MigrateLegacyRowKeyAsync(entity);
}
@@ -141,17 +167,21 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
}
public async Task BlockDeviceAsync(string tenantId, string serialNumber, int durationHours,
string blockedByEmail, string? reason = null, string action = "Block", string? blockedSessionId = null)
string blockedByEmail, string? reason = null, string action = "Block", string? blockedSessionId = null,
IReadOnlyCollection<string>? aliasDeviceIds = null)
{
var now = DateTime.UtcNow;
var unblockAt = now.AddHours(durationHours);
var canonicalSerial = CanonicalizeSerial(serialNumber);
// One read of the current row: merges session scope and keeps aliases an earlier
// block already resolved (a re-block never drops an identity the device was seen with).
var existing = await GetDeviceBlockEntityAsync(tenantId, serialNumber);
// If a session-aware block already exists, merge session IDs
string? blockedSessionIds = blockedSessionId;
if (!string.IsNullOrEmpty(blockedSessionId))
{
var existing = await GetDeviceBlockEntityAsync(tenantId, serialNumber);
var existingSessionIds = existing?.GetString("BlockedSessionIds");
if (existingSessionIds == null)
@@ -167,6 +197,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
}
}
var aliases = MergeAliasDeviceIds(ParseAliasDeviceIds(existing?.GetString("AliasDeviceIds")), aliasDeviceIds);
var entity = new TableEntity(tenantId, DeviceRowKey(serialNumber))
{
["SerialNumber"] = canonicalSerial,
@@ -180,8 +212,27 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
if (blockedSessionIds != null)
entity["BlockedSessionIds"] = blockedSessionIds;
if (aliases.Count > 0)
entity["AliasDeviceIds"] = string.Join(",", aliases);
await _blockedDevicesTable.UpsertEntityAsync(entity);
// Alias rows: same verdict fields under the device's certificate identity, so the kill
// switch matches the device even when it omits or forges X-Device-SerialNumber
// (CWE-807). Never listed (IsAlias) — the primary row is the one operators see.
foreach (var deviceId in aliases)
{
var alias = new TableEntity(tenantId, IdentityRowKey(deviceId));
foreach (var kv in entity)
{
if (kv.Key is "PartitionKey" or "RowKey" or "Timestamp" or "odata.etag" or "ETag" or "AliasDeviceIds")
continue;
alias[kv.Key] = kv.Value;
}
alias["IsAlias"] = true;
await _blockedDevicesTable.UpsertEntityAsync(alias);
}
await DeleteLegacyRowAsync(tenantId, serialNumber);
await _publisher.PublishAsync("device.blocked", new { tenantId, serialNumber = canonicalSerial, action, durationHours }, tenantId);
}
@@ -204,14 +255,54 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
return false;
}
public async Task UnblockDeviceAsync(string tenantId, string serialNumber)
public async Task<IReadOnlyList<string>> UnblockDeviceAsync(string tenantId, string serialNumber)
{
// Aliases first, then the primary: a crash in between leaves a listed row that a retry
// (or expiry) cleans up, never an invisible alias that keeps the device blocked.
var existing = await GetDeviceBlockEntityAsync(tenantId, serialNumber);
var aliases = ParseAliasDeviceIds(existing?.GetString("AliasDeviceIds"));
foreach (var deviceId in aliases)
await DeleteIgnoringNotFoundAsync(tenantId, IdentityRowKey(deviceId));
await DeleteIgnoringNotFoundAsync(tenantId, DeviceRowKey(serialNumber));
await DeleteLegacyRowAsync(tenantId, serialNumber);
await _publisher.PublishAsync("device.unblocked", new { tenantId, serialNumber = CanonicalizeSerial(serialNumber) }, tenantId);
return aliases;
}
/// <summary>Alias ids as stored on the primary row (comma-separated, lower-case GUIDs).</summary>
internal static List<string> ParseAliasDeviceIds(string? stored)
{
var result = new List<string>();
if (string.IsNullOrWhiteSpace(stored)) return result;
foreach (var raw in stored.Split(',', StringSplitOptions.RemoveEmptyEntries))
{
var id = NormalizeDeviceId(raw);
if (id != null && !result.Contains(id, StringComparer.OrdinalIgnoreCase))
result.Add(id);
}
return result;
}
/// <summary>Union of stored and newly resolved alias ids, order preserved (stored first).</summary>
internal static List<string> MergeAliasDeviceIds(List<string> stored, IReadOnlyCollection<string>? resolved)
{
var result = new List<string>(stored);
if (resolved == null) return result;
foreach (var raw in resolved)
{
var id = NormalizeDeviceId(raw);
if (id != null && !result.Contains(id, StringComparer.OrdinalIgnoreCase))
result.Add(id);
}
return result;
}
/// <summary>Canonical (lower-case "D") GUID form, null when the value is not a GUID.</summary>
internal static string? NormalizeDeviceId(string? raw)
=> Guid.TryParse(raw?.Trim(), out var parsed) ? parsed.ToString("D") : null;
// -----------------------------------------------------------------------
// Blocked Versions
// -----------------------------------------------------------------------
@@ -395,6 +486,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
Reason = entity.GetString("Reason"),
Action = entity.GetString("Action") ?? "Block",
BlockedSessionIds = entity.GetString("BlockedSessionIds")
// AliasDeviceIds stays storage-internal: BlockedDeviceEntry is the listing wire
// shape (admin UI, MCP) and the identity leg point-reads alias rows on demand.
};
}
@@ -419,6 +512,19 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
internal static string DeviceRowKey(string? serialNumber)
=> EncodeRowKey(CanonicalizeSerial(serialNumber));
/// <summary>
/// RowKey prefix of alias rows keyed by certificate identity. Cannot collide with a serial
/// key: serial keys pass through <see cref="EncodeRowKey"/>, which turns ':' into "%3A".
/// </summary>
internal const string IdentityRowKeyPrefix = "id:";
/// <summary>Alias RowKey for an Intune device id (lower-case GUID; non-GUIDs are keyed verbatim-trimmed).</summary>
internal static string IdentityRowKey(string intuneDeviceId)
=> IdentityRowKeyPrefix + (NormalizeDeviceId(intuneDeviceId) ?? intuneDeviceId.Trim().ToLowerInvariant());
internal static bool IsIdentityRowKey(string? rowKey)
=> rowKey != null && rowKey.StartsWith(IdentityRowKeyPrefix, StringComparison.Ordinal);
/// <summary>
/// RowKey a pre-canonicalization row would have been written under (verbatim case),
/// or null when it is identical to the canonical key.
@@ -435,6 +541,11 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
/// </summary>
private async Task MigrateLegacyRowKeyAsync(TableEntity entity)
{
// An alias row carries the primary's SerialNumber by design — re-keying it onto the
// serial key would clobber/duplicate the primary and delete the alias.
if (IsIdentityRowKey(entity.RowKey))
return;
var serial = entity.GetString("SerialNumber") ?? DecodeRowKey(entity.RowKey);
var canonicalKey = DeviceRowKey(serial);
if (string.Equals(entity.RowKey, canonicalKey, StringComparison.Ordinal))
@@ -39,6 +39,15 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public Task<List<SessionSummary>> GetOpenSessionsForDeviceAsync(string tenantId, string serialNumber)
=> _storage.GetOpenSessionsForDeviceAsync(tenantId, serialNumber);
public Task<IReadOnlyList<string>> GetOwnerDeviceIdsForSerialAsync(string tenantId, string serialNumber, int max = 5)
=> _storage.GetOwnerDeviceIdsForSerialAsync(tenantId, serialNumber, max);
public Task<string?> FindNewestSessionIdBySerialAsync(string tenantId, string serialNumber)
=> _storage.FindNewestSessionIdBySerialAsync(tenantId, serialNumber);
public Task<string?> FindNewestSessionIdByDeviceNameAsync(string tenantId, string deviceName)
=> _storage.FindNewestSessionIdByDeviceNameAsync(tenantId, deviceName);
public Task<List<SessionSummary>> GetSessionsAsync(string tenantId, int? days = null)
=> _storage.GetSessionsAsync(tenantId, days);
@@ -100,7 +100,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
return errorResponse;
}
return await ProcessGetConfigAsync(req, tenantId);
return await ProcessGetConfigAsync(req, tenantId, validation.IntuneDeviceId);
}
catch (Exception ex)
{
@@ -218,8 +218,10 @@ namespace AutopilotMonitor.Functions.Functions.Config
/// <summary>
/// Core config logic: fetch tenant + admin config, gather rules, IME patterns.
/// Called by both the cert-auth Run() method and the bootstrap wrapper.
/// <paramref name="intuneDeviceId"/> is the certificate identity from the cert Subject CN
/// (cert-auth callers); the bootstrap wrapper has none and leaves it null.
/// </summary>
internal async Task<HttpResponseData> ProcessGetConfigAsync(HttpRequestData req, string tenantId)
internal async Task<HttpResponseData> ProcessGetConfigAsync(HttpRequestData req, string tenantId, string? intuneDeviceId = null)
{
_logger.LogInformation($"GetAgentConfig: Fetching config for tenant {tenantId}");
@@ -229,7 +231,10 @@ namespace AutopilotMonitor.Functions.Functions.Config
// block, empty spool, kill-blind old binary). The full config is still returned —
// agents that predate the flags keep working unchanged; newer agents terminate on
// DeviceKillSignal. Shares KillSwitchEvaluator with ingest (incl. the throttled
// KillSignalDelivered ops event).
// KillSignalDelivered ops event and the certificate-identity leg). This channel has
// no session, so a session-scoped block reports as blocked here — the agent only logs
// DeviceBlocked on config and acts on DeviceKillSignal; the new session it then
// registers lifts a session-scoped block on the telemetry channel.
var serialNumberHeader = req.Headers.Contains("X-Device-SerialNumber")
? req.Headers.GetValues("X-Device-SerialNumber").FirstOrDefault()
: null;
@@ -237,7 +242,9 @@ namespace AutopilotMonitor.Functions.Functions.Config
? req.Headers.GetValues("X-Agent-Version").FirstOrDefault()
: null;
var killVerdict = await _killSwitchEvaluator.EvaluateAsync(
tenantId, serialNumberHeader, agentVersionHeader, channel: "config");
tenantId, serialNumberHeader, agentVersionHeader, channel: "config",
intuneDeviceId: intuneDeviceId);
DeviceIdentityBinding.Stamp(req, killVerdict.IdentityBinding);
// Get tenant configuration
var tenantConfig = await _configService.GetConfigurationAsync(tenantId);
@@ -127,7 +127,12 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
if (errorResponse != null) return AsOutput(errorResponse);
// Device + version kill-switches: short-circuit before parsing the body.
// Device + version kill-switches: short-circuit before parsing the body. Serial
// leg (caller-declared header) AND certificate-identity leg (cert Subject CN — the
// one an agent that omits or forges its serial cannot dodge). A session-scoped
// block (watchdog auto-block) cannot be decided yet — the session id lives in the
// body — so it is re-evaluated below once parsed. Evaluation + kill ops-event live
// in KillSwitchEvaluator, shared with the agent-config channel.
var serialNumberHeader = req.Headers.Contains("X-Device-SerialNumber")
? req.Headers.GetValues("X-Device-SerialNumber").FirstOrDefault()
: null;
@@ -135,9 +140,12 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
? req.Headers.GetValues("X-Agent-Version").FirstOrDefault()
: null;
var killResponse = await CheckKillSwitchesAsync(
req, tenantIdHeader, serialNumberHeader, agentVersionHeader);
if (killResponse != null) return AsOutput(killResponse);
var preBodyVerdict = await _killSwitchEvaluator.EvaluateAsync(
tenantIdHeader, serialNumberHeader, agentVersionHeader, channel: "telemetry",
intuneDeviceId: validation.IntuneDeviceId);
DeviceIdentityBinding.Stamp(req, preBodyVerdict.IdentityBinding);
if (preBodyVerdict.IsBlocked && !preBodyVerdict.IsSessionScoped)
return AsOutput(await WriteDeviceBlockedAsync(req, preBodyVerdict));
// Defense in depth: cap decompressed body size before buffering it all into
// memory + deserialising (same tenant-config knob the removed V1 NDJSON path
@@ -205,6 +213,19 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
"All telemetry items in a batch must share the same PartitionKey"));
}
// Session-scoped block, session now known: either this is one of the blocked
// sessions (stop) or a new enrollment on the same device (auto-unblock — the
// watchdog blocked a runaway session, not the device). The version leg runs
// again on purpose: the device leg short-circuited it before the body was parsed.
if (preBodyVerdict.IsSessionScoped)
{
var sessionVerdict = await _killSwitchEvaluator.EvaluateAsync(
bodyTenantId, serialNumberHeader, agentVersionHeader, channel: "telemetry",
intuneDeviceId: validation.IntuneDeviceId, sessionId: sessionId);
if (sessionVerdict.IsBlocked)
return AsOutput(await WriteDeviceBlockedAsync(req, sessionVerdict));
}
// Cascade-delete guard: refuse the batch with 410 Gone when a V2 cascade owns the
// Sessions row (states Preparing/Queued/Running/Poisoned). Without this check, the
// hot-path writers below (StoreEventsBatchAsync, signal/transition StoreBatchAsync,
@@ -224,6 +245,21 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
return AsOutput(await WriteSessionLockedAsync(req, locked));
}
// The header serial is caller-declared; the Sessions row carries the serial the
// session was registered under. When they differ, the ROW's serial is held against
// the block list too — a mid-session header change must not dodge a block placed
// on the registered serial. Cached like every other lookup (negative entries for
// healthy devices), so the common case costs nothing.
var rowSerial = guardSessionRow?.GetString("SerialNumber");
if (!string.IsNullOrWhiteSpace(rowSerial)
&& !string.Equals(rowSerial.Trim(), serialNumberHeader?.Trim(), StringComparison.OrdinalIgnoreCase))
{
var rowSerialVerdict = await _killSwitchEvaluator.EvaluateAsync(
bodyTenantId, rowSerial, agentVersion: null, channel: "telemetry", sessionId: sessionId);
if (rowSerialVerdict.IsBlocked)
return AsOutput(await WriteDeviceBlockedAsync(req, rowSerialVerdict));
}
// Reuse the guard's full-row read: its Status feeds the stall-heal check inside
// EventIngestProcessor, saving one Sessions point-read per batch.
var preFetchedStatus = TryReadSessionStatus(guardSessionRow);
@@ -286,35 +322,19 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
};
/// <summary>
/// Runs device-serial and agent-version kill-switch checks (session-scoped blocks still
/// use the tenant/serial blanket check — tighter scoping lands once the body is parsed,
/// M5.b.2 pipeline share). Returns a 200 response with <c>DeviceBlocked=true</c> (and
/// optional <c>DeviceKillSignal=true</c>) if the caller should stop — or null if the
/// request may proceed. Evaluation + kill ops-event live in <see cref="KillSwitchEvaluator"/>,
/// shared with the agent-config channel.
/// 200 with <c>DeviceBlocked=true</c> (and <c>DeviceKillSignal=true</c> for a kill): the
/// agent pauses its upload loop until <c>UnblockAt</c>, or self-destructs on a kill.
/// </summary>
private async Task<HttpResponseData?> CheckKillSwitchesAsync(
HttpRequestData req, string tenantId, string? serialNumber, string? agentVersion)
{
var verdict = await _killSwitchEvaluator.EvaluateAsync(
tenantId, serialNumber, agentVersion, channel: "telemetry");
return verdict.IsBlocked
? await WriteDeviceBlockedAsync(req, verdict.IsKill, verdict.UnblockAt, verdict.Message)
: null;
}
private async Task<HttpResponseData> WriteDeviceBlockedAsync(
HttpRequestData req, bool isKill, DateTime? unblockAt, string message)
private static async Task<HttpResponseData> WriteDeviceBlockedAsync(HttpRequestData req, KillSwitchVerdict verdict)
{
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new IngestEventsResponse
{
Success = false,
DeviceBlocked = true,
DeviceKillSignal = isKill,
UnblockAt = unblockAt,
Message = message,
DeviceKillSignal = verdict.IsKill,
UnblockAt = verdict.UnblockAt,
Message = verdict.Message,
ProcessedAt = DateTime.UtcNow,
});
return response;
@@ -128,6 +128,10 @@ public class RequestTelemetryMiddleware : IFunctionsWorkerMiddleware
if (context.Items.TryGetValue(SessionOwnershipPolicy.RequestItemKey, out var ownerBinding) && ownerBinding is string ownerOutcome)
requestTelemetry.Properties["SessionOwnerBinding"] = ownerOutcome;
// DEVICE-IDENTITY-BLOCK-BINDING — set by the kill-switch call sites (telemetry / config).
if (context.Items.TryGetValue(DeviceIdentityBinding.RequestItemKey, out var identityBinding) && identityBinding is string identityOutcome)
requestTelemetry.Properties[DeviceIdentityBinding.RequestItemKey] = identityOutcome;
var reqCtx = context.GetRequestContext();
var tenantId = reqCtx.TenantId;
if (string.IsNullOrEmpty(tenantId))
@@ -0,0 +1,32 @@
using Microsoft.Azure.Functions.Worker.Http;
namespace AutopilotMonitor.Functions.Security
{
/// <summary>
/// DEVICE-IDENTITY-BLOCK-BINDING — request-row dimension for the identity leg of the kill
/// switch. Carried on the (unsampled) request row via <c>FunctionContext.Items</c>, same
/// carrier and reasoning as <see cref="CertTenantBinding"/>: worker-side LogInformation never
/// reaches App Insights, so the bulk outcome needs a per-request property to be a denominator.
/// </summary>
public static class DeviceIdentityBinding
{
public const string RequestItemKey = "DeviceIdentityBinding";
public static class Outcome
{
/// <summary>No certificate identity on the request (bootstrap token, non-GUID CN) — serial-only enforcement.</summary>
public const string NoIdentity = "NoIdentity";
/// <summary>Identity present; no block keyed by it.</summary>
public const string Match = "Match";
/// <summary>Identity present and blocked via its alias row (the serial leg did not match — omitted/forged header).</summary>
public const string IdentityBlocked = "IdentityBlocked";
}
public static void Stamp(HttpRequestData req, string outcome)
{
var items = req.FunctionContext?.Items;
if (items != null)
items[RequestItemKey] = outcome;
}
}
}
@@ -1,6 +1,7 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Shared.DataAccess;
@@ -12,10 +13,18 @@ namespace AutopilotMonitor.Functions.Services
/// Manages temporarily blocked devices (e.g. rogue devices sending excessive data).
/// Uses IDeviceSecurityRepository for persistence, with a
/// ConcurrentDictionary in-memory cache for fast lookups at ingest time.
/// <para>
/// A block is keyed by serial number (what admins and the watchdog know) and additionally
/// mirrored onto every certificate identity (Intune device id from the client-cert CN) the
/// device has registered sessions under — resolved once, at block time, from the Sessions
/// rows. The kill switch checks both legs, so a device that omits or forges its serial header
/// (CWE-807) still meets its block/kill.
/// </para>
/// </summary>
public class BlockedDeviceService
{
private readonly IDeviceSecurityRepository _securityRepo;
private readonly ISessionRepository _sessionRepo;
private readonly ILogger<BlockedDeviceService> _logger;
// Cache entries (positive AND negative) are re-validated against storage after this
@@ -25,33 +34,40 @@ namespace AutopilotMonitor.Functions.Services
// (never-blocked) device paid one BlockedDevices point-read on the response path.
private static readonly TimeSpan DefaultEntryRevalidateAfter = TimeSpan.FromSeconds(30);
/// <summary>How many certificate identities a block is mirrored onto (newest sessions first).</summary>
internal const int MaxAliasDeviceIds = 5;
private readonly TimeSpan _entryRevalidateAfter;
// Cache key: "tenantId|serialNumber" (upper-cased serial number for case-insensitive matching)
// Cache value: BlockCacheEntry with UnblockAt, Action, BlockedSessionIds, LastCheckedUtc.
// Expired entries (UnblockAt past) are treated as unblocked.
private readonly ConcurrentDictionary<string, BlockCacheEntry> _cache = new(StringComparer.OrdinalIgnoreCase);
// Cache key: "tenantId|SERIAL" (upper-cased serial) for the serial leg and
// "tenantId|id:<guid>" for the identity leg. Both hold a BlockCacheEntry with UnblockAt,
// Action, BlockedSessionIds, LastCheckedUtc; identity entries also carry the primary
// serial, serial entries the alias ids. Expired entries (UnblockAt past) are treated as
// unblocked. Namespaces cannot collide: serial keys are upper-cased, the identity prefix
// is lower-case.
private readonly ConcurrentDictionary<string, BlockCacheEntry> _cache = new(StringComparer.Ordinal);
// Tracks which tenants have had their block list loaded into the cache.
// Lazy loading: populated on first lookup per tenant.
private readonly ConcurrentDictionary<string, bool> _loadedTenants = new(StringComparer.OrdinalIgnoreCase);
public BlockedDeviceService(IDeviceSecurityRepository securityRepo, ILogger<BlockedDeviceService> logger)
: this(securityRepo, logger, DefaultEntryRevalidateAfter)
public BlockedDeviceService(IDeviceSecurityRepository securityRepo, ISessionRepository sessionRepo, ILogger<BlockedDeviceService> logger)
: this(securityRepo, sessionRepo, logger, DefaultEntryRevalidateAfter)
{
}
/// <summary>Test seam: lets tests shrink the revalidation window without a clock abstraction.</summary>
internal BlockedDeviceService(
IDeviceSecurityRepository securityRepo, ILogger<BlockedDeviceService> logger, TimeSpan entryRevalidateAfter)
IDeviceSecurityRepository securityRepo, ISessionRepository sessionRepo, ILogger<BlockedDeviceService> logger, TimeSpan entryRevalidateAfter)
{
_securityRepo = securityRepo;
_sessionRepo = sessionRepo;
_logger = logger;
_entryRevalidateAfter = entryRevalidateAfter;
}
/// <summary>
/// Checks whether a device is currently blocked.
/// Checks whether a device is currently blocked by its serial number.
/// Fast path: in-memory cache (loaded lazily per tenant from storage).
/// Returns the action type ("Block" or "Kill") so callers can differentiate.
/// When <paramref name="currentSessionId"/> is provided and the block is session-aware
@@ -78,38 +94,79 @@ namespace AutopilotMonitor.Functions.Services
if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(serialNumber))
return (false, null, "Block", null);
var entry = await ResolveEntryAsync(
tenantId, BuildCacheKey(tenantId, serialNumber),
() => RefreshSerialEntryFromStorageAsync(tenantId, serialNumber));
if (entry == null)
return (false, null, "Block", null);
var verdict = Evaluate(entry, BuildCacheKey(tenantId, serialNumber), tenantId, serialNumber, currentSessionId);
return (verdict.isBlocked, verdict.unblockAt, verdict.action, verdict.blockedSessionIds);
}
/// <summary>
/// Identity leg of the kill switch: is the device behind this certificate identity (Intune
/// device id from the CN) blocked via an alias row? Same cache, same revalidation and the
/// same session-scope semantics as <see cref="IsBlockedAsync"/>; the returned serial is the
/// one the block was placed under (for logging and the auto-unblock, which always runs
/// through the primary serial row so aliases go with it).
/// </summary>
public async Task<(bool isBlocked, DateTime? unblockAt, string action, string? blockedSessionIds, string? serialNumber)> IsIdentityBlockedAsync(
string tenantId, string intuneDeviceId, string? currentSessionId = null)
{
if (string.IsNullOrEmpty(tenantId) || string.IsNullOrWhiteSpace(intuneDeviceId))
return (false, null, "Block", null, null);
var cacheKey = BuildIdentityCacheKey(tenantId, intuneDeviceId);
var entry = await ResolveEntryAsync(
tenantId, cacheKey,
() => RefreshIdentityEntryFromStorageAsync(tenantId, intuneDeviceId, cacheKey));
if (entry == null)
return (false, null, "Block", null, null);
var verdict = Evaluate(entry, cacheKey, tenantId, entry.SerialNumber, currentSessionId);
return (verdict.isBlocked, verdict.unblockAt, verdict.action, verdict.blockedSessionIds,
verdict.isBlocked ? entry.SerialNumber : null);
}
/// <summary>
/// Lazy tenant load, then cache hit (revalidated when stale) or miss (storage point-read,
/// answer promoted positive or negative). Null = not blocked.
/// </summary>
private async Task<BlockCacheEntry?> ResolveEntryAsync(
string tenantId, string cacheKey, Func<Task<BlockCacheEntry?>> refreshFromStorage)
{
// Lazy-load block list for this tenant if not yet done
if (!_loadedTenants.ContainsKey(tenantId))
{
await LoadTenantBlockListAsync(tenantId);
}
var cacheKey = BuildCacheKey(tenantId, serialNumber);
BlockCacheEntry? entry;
if (_cache.TryGetValue(cacheKey, out entry))
if (_cache.TryGetValue(cacheKey, out var entry))
{
// Stale entry (positive or negative) → revalidate against storage so cross-instance
// mutations (new block, manual unblock, Block→Kill upgrade, UnblockAt change) propagate.
if (DateTime.UtcNow - entry.LastCheckedUtc > _entryRevalidateAfter)
{
entry = await RefreshCacheEntryFromStorageAsync(tenantId, serialNumber, cacheKey);
if (entry == null)
return (false, null, "Block", null);
}
}
else
{
// Cache miss after tenant was loaded — another instance may have added a block
// since our LoadTenantBlockListAsync ran. Fall through to storage for one point-read
// so we don't blindly return "not blocked" for the lifetime of this instance.
// The refresh caches the negative answer, so the next requests within the
// revalidation window skip storage entirely.
entry = await RefreshCacheEntryFromStorageAsync(tenantId, serialNumber, cacheKey);
if (entry == null)
return (false, null, "Block", null);
return await refreshFromStorage();
return entry;
}
// Cache miss after tenant was loaded — another instance may have added a block
// since our LoadTenantBlockListAsync ran. Fall through to storage for one point-read
// so we don't blindly return "not blocked" for the lifetime of this instance.
// The refresh caches the negative answer, so the next requests within the
// revalidation window skip storage entirely.
return await refreshFromStorage();
}
/// <summary>
/// The verdict ladder shared by both legs: negative entry → expiry → Kill (never
/// auto-unblocked) → whole-device → session-aware (blocked list returned when the caller
/// has no session yet; different session ⇒ auto-unblock through the primary serial).
/// </summary>
private (bool isBlocked, DateTime? unblockAt, string action, string? blockedSessionIds) Evaluate(
BlockCacheEntry entry, string cacheKey, string tenantId, string? primarySerial, string? currentSessionId)
{
// Fresh negative entry — device known not-blocked, no storage round-trip needed.
// Must run before the UnblockAt check below (negative entries carry no UnblockAt).
if (!entry.IsBlocked)
@@ -143,10 +200,14 @@ namespace AutopilotMonitor.Functions.Services
_logger.LogWarning(
"Auto-unblocked device (new session): TenantId={TenantId}, SerialNumber={SerialNumber}, " +
"BlockedSessionIds={BlockedSessionIds}, NewSessionId={NewSessionId}",
tenantId, serialNumber, entry.BlockedSessionIds, currentSessionId);
tenantId, primarySerial, entry.BlockedSessionIds, currentSessionId);
// Remove block from storage and cache
_ = UnblockDeviceAsync(tenantId, serialNumber);
// Remove block (primary + aliases) from storage and cache. An alias entry without a
// known primary serial can only drop itself.
if (!string.IsNullOrEmpty(primarySerial))
_ = UnblockDeviceAsync(tenantId, primarySerial);
else
_cache.TryRemove(cacheKey, out _);
return (false, null, "Block", null);
}
@@ -156,12 +217,27 @@ namespace AutopilotMonitor.Functions.Services
/// blocked. Used for both cache-miss fallback and stale-positive revalidation so a
/// single instance can never get out of sync with storage by more than one read.
/// </summary>
private async Task<BlockCacheEntry?> RefreshCacheEntryFromStorageAsync(
string tenantId, string serialNumber, string cacheKey)
private async Task<BlockCacheEntry?> RefreshSerialEntryFromStorageAsync(string tenantId, string serialNumber)
{
var cacheKey = BuildCacheKey(tenantId, serialNumber);
var (isBlocked, unblockAt, action, blockedSessionIds) =
await _securityRepo.IsDeviceBlockedAsync(tenantId, serialNumber);
return PromoteRefreshedEntry(cacheKey, isBlocked, unblockAt, action, blockedSessionIds,
serialNumber: TableDeviceSecurityRepository.CanonicalizeSerial(serialNumber));
}
private async Task<BlockCacheEntry?> RefreshIdentityEntryFromStorageAsync(string tenantId, string intuneDeviceId, string cacheKey)
{
var (isBlocked, unblockAt, action, blockedSessionIds, serialNumber) =
await _securityRepo.IsDeviceIdentityBlockedAsync(tenantId, intuneDeviceId);
return PromoteRefreshedEntry(cacheKey, isBlocked, unblockAt, action, blockedSessionIds, serialNumber);
}
private BlockCacheEntry? PromoteRefreshedEntry(
string cacheKey, bool isBlocked, DateTime? unblockAt, string? action, string? blockedSessionIds, string? serialNumber)
{
if (!isBlocked || unblockAt == null)
{
// Cache the negative answer so healthy devices don't pay a point-read per request.
@@ -180,6 +256,7 @@ namespace AutopilotMonitor.Functions.Services
UnblockAt = unblockAt.Value,
Action = action ?? "Block",
BlockedSessionIds = blockedSessionIds,
SerialNumber = serialNumber,
LastCheckedUtc = DateTime.UtcNow,
};
_cache[cacheKey] = refreshed;
@@ -189,22 +266,29 @@ namespace AutopilotMonitor.Functions.Services
/// <summary>
/// Blocks a device for the specified duration. Updates both storage and the in-memory cache.
/// <paramref name="action"/> is "Block" (stop uploads) or "Kill" (remote self-destruct).
/// Resolves the device's certificate identities from its sessions and mirrors the block
/// onto them (alias rows) — callers stay serial-keyed.
/// </summary>
public async Task BlockDeviceAsync(string tenantId, string serialNumber, int durationHours,
string blockedByEmail, string? reason = null, string action = "Block", string? blockedSessionId = null)
{
await _securityRepo.BlockDeviceAsync(tenantId, serialNumber, durationHours, blockedByEmail, reason, action, blockedSessionId);
var aliases = await ResolveAliasDeviceIdsAsync(tenantId, serialNumber);
await _securityRepo.BlockDeviceAsync(tenantId, serialNumber, durationHours, blockedByEmail, reason, action, blockedSessionId, aliases);
// Update cache immediately — merge session IDs if needed
var unblockAt = DateTime.UtcNow.AddHours(durationHours);
var canonicalSerial = TableDeviceSecurityRepository.CanonicalizeSerial(serialNumber);
var cacheKey = BuildCacheKey(tenantId, serialNumber);
_cache.AddOrUpdate(cacheKey,
var primary = _cache.AddOrUpdate(cacheKey,
_ => new BlockCacheEntry
{
UnblockAt = unblockAt,
Action = action ?? "Block",
BlockedSessionIds = blockedSessionId,
SerialNumber = canonicalSerial,
AliasDeviceIds = aliases.ToList(),
LastCheckedUtc = DateTime.UtcNow,
},
(_, existing) =>
@@ -217,6 +301,7 @@ namespace AutopilotMonitor.Functions.Services
existing.IsBlocked = true;
existing.UnblockAt = unblockAt;
existing.Action = action ?? "Block";
existing.SerialNumber = canonicalSerial;
// Merge session IDs; a positive whole-device block (null) takes precedence
if (wasNegative)
existing.BlockedSessionIds = blockedSessionId; // take the new block's scope verbatim
@@ -225,25 +310,63 @@ namespace AutopilotMonitor.Functions.Services
else if (blockedSessionId == null)
existing.BlockedSessionIds = null; // Manual/whole-device block overrides session-aware
// else: existing is a positive whole-device block — keep it null
existing.AliasDeviceIds = wasNegative
? aliases.ToList()
: TableDeviceSecurityRepository.MergeAliasDeviceIds(existing.AliasDeviceIds, aliases);
existing.LastCheckedUtc = DateTime.UtcNow;
return existing;
});
// Identity entries mirror the primary's (merged) verdict — same values, own key.
foreach (var deviceId in primary.AliasDeviceIds)
{
_cache[BuildIdentityCacheKey(tenantId, deviceId)] = new BlockCacheEntry
{
UnblockAt = primary.UnblockAt,
Action = primary.Action,
BlockedSessionIds = primary.BlockedSessionIds,
SerialNumber = canonicalSerial,
LastCheckedUtc = DateTime.UtcNow,
};
}
_logger.LogWarning(
"Device {Action}: TenantId={TenantId}, SerialNumber={SerialNumber}, BlockedBy={BlockedBy}, Until={UnblockAt}, Reason={Reason}",
action, tenantId, serialNumber, blockedByEmail, unblockAt, reason);
"Device {Action}: TenantId={TenantId}, SerialNumber={SerialNumber}, BlockedBy={BlockedBy}, Until={UnblockAt}, Reason={Reason}, IdentityAliases={AliasCount}",
action, tenantId, serialNumber, blockedByEmail, unblockAt, reason, primary.AliasDeviceIds.Count);
}
/// <summary>
/// Removes a device block immediately. Updates both storage and the in-memory cache.
/// Certificate identities the device has registered sessions under. Fail-soft: an empty
/// list degrades to today's serial-only block, never fails the block itself.
/// </summary>
private async Task<IReadOnlyList<string>> ResolveAliasDeviceIdsAsync(string tenantId, string serialNumber)
{
try
{
return await _sessionRepo.GetOwnerDeviceIdsForSerialAsync(tenantId, serialNumber, MaxAliasDeviceIds)
?? Array.Empty<string>();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Alias resolution failed for tenant {TenantId} — block stays serial-only", tenantId);
return Array.Empty<string>();
}
}
/// <summary>
/// Removes a device block immediately — primary row plus its identity aliases. Updates
/// both storage and the in-memory cache.
/// </summary>
public async Task UnblockDeviceAsync(string tenantId, string serialNumber)
{
await _securityRepo.UnblockDeviceAsync(tenantId, serialNumber);
var removedAliases = await _securityRepo.UnblockDeviceAsync(tenantId, serialNumber);
// Remove from cache immediately
// Remove from cache immediately: the serial entry, every alias storage reported and
// every alias the local entry knew (covers an alias cached before storage was read).
var cacheKey = BuildCacheKey(tenantId, serialNumber);
_cache.TryRemove(cacheKey, out _);
_cache.TryRemove(cacheKey, out var removedEntry);
foreach (var deviceId in removedAliases.Concat(removedEntry?.AliasDeviceIds ?? new List<string>()))
_cache.TryRemove(BuildIdentityCacheKey(tenantId, deviceId), out _);
_logger.LogInformation("Device unblocked: TenantId={TenantId}, SerialNumber={SerialNumber}", tenantId, serialNumber);
}
@@ -281,11 +404,15 @@ namespace AutopilotMonitor.Functions.Services
{
if (entry.UnblockAt == null || entry.UnblockAt <= now) continue;
// Serial keys only: the listing hides alias rows (its DTO is the admin/MCP wire
// shape), so identity keys are not seeded here — the identity leg point-reads
// its alias row on the first miss and caches the answer like the serial leg.
_cache[BuildCacheKey(tenantId, entry.SerialNumber)] = new BlockCacheEntry
{
UnblockAt = entry.UnblockAt.Value,
Action = entry.Action,
BlockedSessionIds = entry.BlockedSessionIds,
SerialNumber = TableDeviceSecurityRepository.CanonicalizeSerial(entry.SerialNumber),
LastCheckedUtc = now,
};
}
@@ -310,6 +437,10 @@ namespace AutopilotMonitor.Functions.Services
public DateTime UnblockAt { get; set; }
public string Action { get; set; } = "Block";
public string? BlockedSessionIds { get; set; }
/// <summary>Canonical serial the block is keyed under (identity entries: the primary they mirror).</summary>
public string? SerialNumber { get; set; }
/// <summary>Serial entries only: identity keys mirroring this block.</summary>
public List<string> AliasDeviceIds { get; set; } = new();
/// <summary>
/// Last time this entry was either loaded from storage or re-validated against it.
/// Drives the EntryRevalidateAfter window so cross-instance mutations propagate.
@@ -319,6 +450,9 @@ namespace AutopilotMonitor.Functions.Services
private static string BuildCacheKey(string tenantId, string serialNumber)
=> $"{tenantId}|{serialNumber.ToUpperInvariant()}";
private static string BuildIdentityCacheKey(string tenantId, string intuneDeviceId)
=> $"{tenantId}|{TableDeviceSecurityRepository.IdentityRowKey(intuneDeviceId)}";
}
// Note: BlockedDeviceEntry is now defined in AutopilotMonitor.Shared.DataAccess.IDeviceSecurityRepository
@@ -1,6 +1,7 @@
using System;
using System.Collections.Concurrent;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Security;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services
@@ -12,8 +13,9 @@ namespace AutopilotMonitor.Functions.Services
/// delivery a single observability point: every served Kill emits a
/// <c>KillSignalDelivered</c> ops event (throttled — an old agent binary that does not
/// understand the kill field keeps calling every few seconds and must not flood OpsEvents).
/// Check order mirrors the original ingest path: device block first (short-circuits),
/// then version block.
/// Check order: device block by serial (short-circuits) → device block by certificate
/// identity (the leg a serial-less or serial-forging agent cannot dodge, CWE-807) → version
/// block.
/// </summary>
public class KillSwitchEvaluator
{
@@ -43,19 +45,28 @@ namespace AutopilotMonitor.Functions.Services
}
/// <summary>
/// Runs the device-serial check, then the agent-version check. Returns a non-blocked
/// verdict when neither matches. <paramref name="channel"/> names the delivery channel
/// for logging + the ops event ("telemetry" or "config").
/// Runs the device-serial check, the device-identity check, then the agent-version check.
/// Returns a non-blocked verdict when none matches. <paramref name="channel"/> names the
/// delivery channel for logging + the ops event ("telemetry" or "config").
/// <paramref name="intuneDeviceId"/> is the certificate identity (null = no identity on
/// the request: bootstrap token, non-GUID CN — serial-only behaviour). <paramref name="sessionId"/>,
/// when the caller already knows it, lets a session-scoped block auto-unblock on a new
/// session; without it a session-scoped block answers blocked and carries its session list
/// in <see cref="KillSwitchVerdict.BlockedSessionIds"/> so the caller can re-evaluate once
/// the body is parsed.
/// </summary>
public async Task<KillSwitchVerdict> EvaluateAsync(
string tenantId, string? serialNumber, string? agentVersion, string channel)
string tenantId, string? serialNumber, string? agentVersion, string channel,
string? intuneDeviceId = null, string? sessionId = null)
{
var identityBinding = string.IsNullOrWhiteSpace(intuneDeviceId)
? DeviceIdentityBinding.Outcome.NoIdentity
: DeviceIdentityBinding.Outcome.Match;
if (!string.IsNullOrEmpty(serialNumber))
{
// Session-aware block: without body-parse we can't discriminate on SessionId,
// so we use the tenant/serial blanket check.
var (isBlocked, unblockAt, blockAction, _) =
await _blockedDeviceService.IsBlockedAsync(tenantId, serialNumber);
var (isBlocked, unblockAt, blockAction, blockedSessionIds) =
await _blockedDeviceService.IsBlockedAsync(tenantId, serialNumber, sessionId);
if (isBlocked)
{
var isKill = string.Equals(blockAction, "Kill", StringComparison.OrdinalIgnoreCase);
@@ -66,11 +77,28 @@ namespace AutopilotMonitor.Functions.Services
if (isKill)
await TryRecordOpsEventAsync(tenantId, serialNumber, agentVersion, null, "device", channel);
return new KillSwitchVerdict(
isBlocked: true, isKill: isKill, unblockAt: unblockAt,
message: isKill
? "Device has been issued a remote kill signal."
: "Device is temporarily blocked by an administrator.");
return DeviceVerdict(isKill, unblockAt, blockedSessionIds, serialNumber, identityBinding);
}
}
if (!string.IsNullOrWhiteSpace(intuneDeviceId))
{
var (isBlocked, unblockAt, blockAction, blockedSessionIds, rowSerial) =
await _blockedDeviceService.IsIdentityBlockedAsync(tenantId, intuneDeviceId, sessionId);
if (isBlocked)
{
var isKill = string.Equals(blockAction, "Kill", StringComparison.OrdinalIgnoreCase);
// The serial leg missed, so the header was absent or differs from the serial
// the block was placed under — both serials in the line, that IS the finding.
_logger.LogWarning(
"KillSwitch[{Channel}]: {Action} device by identity tenant={Tenant} headerSerial={HeaderSerial} blockedSerial={BlockedSerial} unblockAt={UnblockAt}",
channel, isKill ? "KILL" : "Block", tenantId, serialNumber ?? "n/a", rowSerial ?? "n/a", unblockAt);
if (isKill)
await TryRecordOpsEventAsync(tenantId, rowSerial ?? serialNumber, agentVersion, null, "device", channel);
return DeviceVerdict(isKill, unblockAt, blockedSessionIds, rowSerial ?? serialNumber,
DeviceIdentityBinding.Outcome.IdentityBlocked);
}
}
@@ -92,13 +120,25 @@ namespace AutopilotMonitor.Functions.Services
isBlocked: true, isKill: isKill, unblockAt: null,
message: isKill
? $"Agent version {agentVersion} has been issued a remote kill signal (pattern: {matchedPattern})."
: $"Agent version {agentVersion} is blocked by administrator (pattern: {matchedPattern}).");
: $"Agent version {agentVersion} is blocked by administrator (pattern: {matchedPattern}).",
blockedSessionIds: null, blockedSerial: null, identityBinding: identityBinding);
}
}
return KillSwitchVerdict.NotBlocked;
return KillSwitchVerdict.NotBlocked(identityBinding);
}
private static KillSwitchVerdict DeviceVerdict(
bool isKill, DateTime? unblockAt, string? blockedSessionIds, string? blockedSerial, string identityBinding)
=> new(
isBlocked: true, isKill: isKill, unblockAt: unblockAt,
message: isKill
? "Device has been issued a remote kill signal."
: "Device is temporarily blocked by an administrator.",
blockedSessionIds: isKill ? null : blockedSessionIds,
blockedSerial: blockedSerial,
identityBinding: identityBinding);
private async Task TryRecordOpsEventAsync(
string tenantId, string? serialNumber, string? agentVersion, string? matchedPattern,
string trigger, string channel)
@@ -136,26 +176,50 @@ namespace AutopilotMonitor.Functions.Services
}
/// <summary>
/// Outcome of a kill-switch evaluation. <see cref="NotBlocked"/> means the request may
/// Outcome of a kill-switch evaluation. <see cref="IsBlocked"/> false means the request may
/// proceed; otherwise the caller relays Block/Kill to the agent on its channel's wire
/// shape (ingest: DeviceBlocked response body; config: flags on AgentConfigResponse).
/// </summary>
public sealed class KillSwitchVerdict
{
public static readonly KillSwitchVerdict NotBlocked =
new(isBlocked: false, isKill: false, unblockAt: null, message: string.Empty);
public static KillSwitchVerdict NotBlocked(string identityBinding = DeviceIdentityBinding.Outcome.NoIdentity) =>
new(isBlocked: false, isKill: false, unblockAt: null, message: string.Empty,
blockedSessionIds: null, blockedSerial: null, identityBinding: identityBinding);
public bool IsBlocked { get; }
public bool IsKill { get; }
public DateTime? UnblockAt { get; }
public string Message { get; }
public KillSwitchVerdict(bool isBlocked, bool isKill, DateTime? unblockAt, string message)
/// <summary>
/// Session-scoped device block (watchdog auto-block): the comma-separated sessions it
/// applies to. Null for whole-device blocks, kills and version blocks. A caller that
/// evaluated without a session id should re-evaluate with it — a different session
/// auto-unblocks.
/// </summary>
public string? BlockedSessionIds { get; }
/// <summary>True when the block is session-scoped and the caller has not yet supplied a session id.</summary>
public bool IsSessionScoped => IsBlocked && !IsKill && !string.IsNullOrEmpty(BlockedSessionIds);
/// <summary>Serial the matching device block is keyed under (identity hits: the row's serial, not the header's).</summary>
public string? BlockedSerial { get; }
/// <summary>One of <see cref="DeviceIdentityBinding.Outcome"/> — the request-row dimension the caller stamps.</summary>
public string IdentityBinding { get; }
public KillSwitchVerdict(
bool isBlocked, bool isKill, DateTime? unblockAt, string message,
string? blockedSessionIds = null, string? blockedSerial = null,
string identityBinding = DeviceIdentityBinding.Outcome.NoIdentity)
{
IsBlocked = isBlocked;
IsKill = isKill;
UnblockAt = unblockAt;
Message = message;
BlockedSessionIds = blockedSessionIds;
BlockedSerial = blockedSerial;
IdentityBinding = identityBinding;
}
}
}
@@ -0,0 +1,124 @@
using Azure.Data.Tables;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services
{
/// <summary>
/// Partial: device-keyed session lookups — serial / device name → sessions. Serial and device
/// name are non-key columns, so every lookup here is a server-side filtered scan of ONE tenant
/// partition with a narrow projection; they run on rare admin/portal paths (device block,
/// progress-portal lookup), never on the agent hot path.
/// </summary>
public partial class TableStorageService
{
/// <summary>
/// OData clause matching the value as the agent announced it (stored verbatim on the
/// row, trimmed) and its upper-case form — Table Storage compares strings ordinally, and
/// admins/users type serials and device names in either case.
/// </summary>
internal static string ExactOrUpperClause(string column, string value)
{
var trimmed = value.Trim();
var upper = trimmed.ToUpperInvariant();
var clause = $"{column} eq '{ODataSanitizer.EscapeValue(trimmed)}'";
if (!string.Equals(upper, trimmed, StringComparison.Ordinal))
clause = $"({clause} or {column} eq '{ODataSanitizer.EscapeValue(upper)}')";
return clause;
}
public Task<string?> FindNewestSessionIdBySerialAsync(string tenantId, string serialNumber)
=> FindNewestSessionIdByColumnAsync(tenantId, "SerialNumber", serialNumber);
public Task<string?> FindNewestSessionIdByDeviceNameAsync(string tenantId, string deviceName)
=> FindNewestSessionIdByColumnAsync(tenantId, "DeviceName", deviceName);
/// <summary>
/// SessionsIndex partition query on one always-projected column. The index RowKey is
/// <c>{InvertedTicks(StartedAt)}_{sessionId}</c>, so the ordinally smallest RowKey among the
/// matches is the newest session. Rows written before the column was projected are not
/// matched by <c>eq</c> (same caveat as the other pushed-down search filters) — for the
/// progress portal's "current enrollment" question that is immaterial.
/// </summary>
private async Task<string?> FindNewestSessionIdByColumnAsync(string tenantId, string column, string value)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
if (string.IsNullOrWhiteSpace(value))
return null;
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.SessionsIndex);
var filter = $"PartitionKey eq '{tenantId}' and {ExactOrUpperClause(column, value)}";
var select = new[] { "PartitionKey", "RowKey", "SessionId" };
string? bestRowKey = null;
string? bestSessionId = null;
await foreach (var entity in tableClient.QueryAsync<TableEntity>(filter: filter, select: select))
{
if (bestRowKey != null && string.CompareOrdinal(entity.RowKey, bestRowKey) >= 0)
continue;
var sessionId = entity.GetString("SessionId");
if (string.IsNullOrEmpty(sessionId))
{
var sep = entity.RowKey.IndexOf('_');
sessionId = sep >= 0 ? entity.RowKey.Substring(sep + 1) : null;
}
if (string.IsNullOrEmpty(sessionId))
continue;
bestRowKey = entity.RowKey;
bestSessionId = sessionId;
}
return bestSessionId;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Session lookup by {Column} failed in tenant {TenantId} (fail-soft: not found)", column, tenantId);
return null;
}
}
/// <summary>
/// Certificate identities (<c>OwnerDeviceId</c>) the device with this serial has registered
/// sessions under — newest session first, distinct, capped. Read from the primary Sessions
/// row (the owner columns are deliberately never mirrored into SessionsIndex). Fail-soft:
/// an empty list means "no alias", the serial-keyed block still applies.
/// </summary>
public async Task<IReadOnlyList<string>> GetOwnerDeviceIdsForSerialAsync(string tenantId, string serialNumber, int max = 5)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
if (string.IsNullOrWhiteSpace(serialNumber) || max <= 0)
return Array.Empty<string>();
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
var filter = $"PartitionKey eq '{tenantId}' and {ExactOrUpperClause("SerialNumber", serialNumber)}";
var select = new[] { "PartitionKey", "RowKey", "OwnerDeviceId", "StartedAt" };
var seen = new List<(DateTimeOffset startedAt, string deviceId)>();
await foreach (var entity in tableClient.QueryAsync<TableEntity>(filter: filter, select: select))
{
var deviceId = entity.GetString("OwnerDeviceId");
if (string.IsNullOrWhiteSpace(deviceId) || !Guid.TryParse(deviceId, out var parsed))
continue;
var startedAt = entity.GetDateTimeOffset("StartedAt") ?? DateTimeOffset.MinValue;
seen.Add((startedAt, parsed.ToString("D")));
}
return seen
.OrderByDescending(s => s.startedAt)
.Select(s => s.deviceId)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(max)
.ToList();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Owner-device-id lookup failed for a serial in tenant {TenantId} (fail-soft: no alias)", tenantId);
return Array.Empty<string>();
}
}
}
}
@@ -12,11 +12,34 @@ namespace AutopilotMonitor.Shared.DataAccess
{
// --- Blocked Devices ---
Task<(bool isBlocked, DateTime? unblockAt, string action, string? blockedSessionIds)> IsDeviceBlockedAsync(string tenantId, string serialNumber);
/// <summary>
/// Point-reads the alias row keyed by the device's certificate identity (Intune device id
/// from the client-certificate Subject CN). Same verdict shape as
/// <see cref="IsDeviceBlockedAsync"/>; <c>serialNumber</c> is the canonical serial of the
/// primary row the alias mirrors, so the caller can act (log, auto-unblock) on the serial
/// the block was placed under.
/// </summary>
Task<(bool isBlocked, DateTime? unblockAt, string action, string? blockedSessionIds, string? serialNumber)> IsDeviceIdentityBlockedAsync(
string tenantId, string intuneDeviceId);
Task<List<BlockedDeviceEntry>> GetBlockedDevicesAsync(string tenantId);
Task<List<BlockedDeviceEntry>> GetAllBlockedDevicesAsync();
/// <summary>
/// Writes the serial-keyed block row plus one alias row per <paramref name="aliasDeviceIds"/>
/// entry (certificate identities the device was seen with). Aliases carry the same block
/// fields so the kill switch can match a device that omits or forges its serial header.
/// </summary>
Task BlockDeviceAsync(string tenantId, string serialNumber, int durationHours,
string blockedByEmail, string? reason = null, string action = "Block", string? blockedSessionId = null);
Task UnblockDeviceAsync(string tenantId, string serialNumber);
string blockedByEmail, string? reason = null, string action = "Block", string? blockedSessionId = null,
IReadOnlyCollection<string>? aliasDeviceIds = null);
/// <summary>
/// Removes the serial-keyed row and every alias row it references. Returns the alias
/// device ids that were removed so callers can drop their cached identity entries.
/// </summary>
Task<IReadOnlyList<string>> UnblockDeviceAsync(string tenantId, string serialNumber);
// --- Blocked Versions ---
Task<(bool isBlocked, string action, string? matchedPattern)> IsVersionBlockedAsync(string agentVersion);
@@ -202,6 +202,25 @@ namespace AutopilotMonitor.Shared.DataAccess
/// </summary>
Task<List<SessionSummary>> GetOpenSessionsForDeviceAsync(string tenantId, string serialNumber);
/// <summary>
/// Certificate identities (Sessions.OwnerDeviceId, lower-case Intune device id GUIDs) the
/// device with this serial has registered sessions under, newest session first, distinct,
/// capped at <paramref name="max"/>. Serial matched case-insensitively (as stored + upper
/// variant). Feeds the block alias rows so a block placed by serial also matches the
/// device's certificate identity. Fail-soft: empty list on storage errors.
/// </summary>
Task<IReadOnlyList<string>> GetOwnerDeviceIdsForSerialAsync(string tenantId, string serialNumber, int max = 5);
/// <summary>
/// Newest session (by StartedAt) whose serial equals <paramref name="serialNumber"/>
/// (as stored, or its upper-case form). Server-side filtered SessionsIndex partition query
/// — no page horizon, unlike the newest-N listing. Null when none. Fail-soft.
/// </summary>
Task<string?> FindNewestSessionIdBySerialAsync(string tenantId, string serialNumber);
/// <summary>Same as <see cref="FindNewestSessionIdBySerialAsync"/> keyed on DeviceName.</summary>
Task<string?> FindNewestSessionIdByDeviceNameAsync(string tenantId, string deviceName);
// --- IME Version History ---
/// <summary>