From fb49dc9ad2f2c36f466b51c303746afa24f19bf0 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Tue, 1 Sep 2026 23:37:41 +0200 Subject: [PATCH] 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 --- .../BlockedDeviceServiceCrossInstanceTests.cs | 15 +- .../BlockedDeviceServiceTests.cs | 127 +++++++++- .../KillSwitchEvaluatorTests.cs | 236 +++++++++++++++++- .../TableDeviceSecurityRepositoryKeyTests.cs | 36 +++ .../TableDeviceSecurityRepository.cs | 117 ++++++++- .../TableStorage/TableSessionRepository.cs | 9 + .../Config/GetAgentConfigFunction.cs | 15 +- .../Ingest/IngestTelemetryFunction.cs | 72 ++++-- .../Middleware/RequestTelemetryMiddleware.cs | 4 + .../Security/DeviceIdentityBinding.cs | 32 +++ .../Services/BlockedDeviceService.cs | 216 +++++++++++++--- .../Services/KillSwitchEvaluator.cs | 106 ++++++-- .../TableStorageService.DeviceLookup.cs | 124 +++++++++ .../DataAccess/IDeviceSecurityRepository.cs | 27 +- .../DataAccess/ISessionRepository.cs | 19 ++ 15 files changed, 1043 insertions(+), 112 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions/Security/DeviceIdentityBinding.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.DeviceLookup.cs diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/BlockedDeviceServiceCrossInstanceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/BlockedDeviceServiceCrossInstanceTests.cs index ee089615..de274d16 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/BlockedDeviceServiceCrossInstanceTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/BlockedDeviceServiceCrossInstanceTests.cs @@ -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.Instance); + new(repo, Mock.Of(), NullLogger.Instance); [Theory] [InlineData("Block")] // Pauses uploads; session remains alive @@ -184,18 +185,24 @@ public class BlockedDeviceServiceCrossInstanceTests => Task.FromResult(new List(_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? aliasDeviceIds = null) { SetBlock(tenantId, serialNumber, DateTime.UtcNow.AddHours(durationHours), action); return Task.CompletedTask; } - public Task UnblockDeviceAsync(string tenantId, string serialNumber) + public Task> UnblockDeviceAsync(string tenantId, string serialNumber) { ClearBlock(tenantId, serialNumber); - return Task.CompletedTask; + return Task.FromResult>(Array.Empty()); } + // 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)); diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/BlockedDeviceServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/BlockedDeviceServiceTests.cs index bfe7cc2a..07c7174b 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/BlockedDeviceServiceTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/BlockedDeviceServiceTests.cs @@ -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.Instance); + var service = new BlockedDeviceService(repo.Object, Mock.Of(), NullLogger.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.Instance, TimeSpan.Zero); + repo.Object, Mock.Of(), NullLogger.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.Instance); + var service = new BlockedDeviceService(repo.Object, Mock.Of(), NullLogger.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.Instance); + var service = new BlockedDeviceService(repo.Object, Mock.Of(), NullLogger.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.Instance); + var service = new BlockedDeviceService(repo.Object, Mock.Of(), NullLogger.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 SessionsKnowing(params string[] deviceIds) + { + var sessions = new Mock(); + sessions.Setup(s => s.GetOwnerDeviceIdsForSerialAsync(TenantId, Serial, It.IsAny())) + .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.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>(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(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task BlockDevice_NoSessionsKnown_StaysSerialOnly() + { + var repo = NewRepo(blocked: false); + var service = new BlockedDeviceService(repo.Object, SessionsKnowing().Object, NullLogger.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>(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(); + sessions.Setup(s => s.GetOwnerDeviceIdsForSerialAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("storage down")); + var service = new BlockedDeviceService(repo.Object, sessions.Object, NullLogger.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>()), 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.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(), NullLogger.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.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); + } } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/KillSwitchEvaluatorTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/KillSwitchEvaluatorTests.cs index 484dbc15..d3c23c86 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/KillSwitchEvaluatorTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/KillSwitchEvaluatorTests.cs @@ -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.Instance, alertDispatch); var evaluator = new KillSwitchEvaluator( - new BlockedDeviceService(repo, NullLogger.Instance), + new BlockedDeviceService(repo, Mock.Of(), NullLogger.Instance), new BlockedVersionService(repo, NullLogger.Instance), opsService, NullLogger.Instance); @@ -202,7 +391,7 @@ public class KillSwitchEvaluatorTests private readonly ConcurrentDictionary _devices = new(StringComparer.OrdinalIgnoreCase); private readonly List _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, }; } + /// Storage reads of the identity leg — pins that a serial hit never pays a second lookup. + 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)); } + /// Alias row: the identity leg answers with the primary's verdict + serial. + 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 _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> 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? aliasDeviceIds = null) { SetDeviceBlock(tenantId, serialNumber, DateTime.UtcNow.AddHours(durationHours), action); + foreach (var id in aliasDeviceIds ?? Array.Empty()) + SetIdentityAlias(tenantId, id, serialNumber); return Task.CompletedTask; } - public Task UnblockDeviceAsync(string tenantId, string serialNumber) + public Task> UnblockDeviceAsync(string tenantId, string serialNumber) { _devices.TryRemove($"{tenantId}|{serialNumber.ToUpperInvariant()}", out _); - return Task.CompletedTask; + var removed = new List(); + 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>(removed); } public Task<(bool isBlocked, string action, string? matchedPattern)> IsVersionBlockedAsync(string agentVersion) diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TableDeviceSecurityRepositoryKeyTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TableDeviceSecurityRepositoryKeyTests.cs index 649146e2..b07d0ab4 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/TableDeviceSecurityRepositoryKeyTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TableDeviceSecurityRepositoryKeyTests.cs @@ -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 { "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); + } } diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableDeviceSecurityRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableDeviceSecurityRepository.cs index df00cfde..bd6bae5c 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableDeviceSecurityRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableDeviceSecurityRepository.cs @@ -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")); + } + /// /// 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? 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> 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; } + /// Alias ids as stored on the primary row (comma-separated, lower-case GUIDs). + internal static List ParseAliasDeviceIds(string? stored) + { + var result = new List(); + 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; + } + + /// Union of stored and newly resolved alias ids, order preserved (stored first). + internal static List MergeAliasDeviceIds(List stored, IReadOnlyCollection? resolved) + { + var result = new List(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; + } + + /// Canonical (lower-case "D") GUID form, null when the value is not a GUID. + 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)); + /// + /// RowKey prefix of alias rows keyed by certificate identity. Cannot collide with a serial + /// key: serial keys pass through , which turns ':' into "%3A". + /// + internal const string IdentityRowKeyPrefix = "id:"; + + /// Alias RowKey for an Intune device id (lower-case GUID; non-GUIDs are keyed verbatim-trimmed). + 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); + /// /// 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 /// 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)) diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs index 6c942f79..f7151245 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs @@ -39,6 +39,15 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage public Task> GetOpenSessionsForDeviceAsync(string tenantId, string serialNumber) => _storage.GetOpenSessionsForDeviceAsync(tenantId, serialNumber); + public Task> GetOwnerDeviceIdsForSerialAsync(string tenantId, string serialNumber, int max = 5) + => _storage.GetOwnerDeviceIdsForSerialAsync(tenantId, serialNumber, max); + + public Task FindNewestSessionIdBySerialAsync(string tenantId, string serialNumber) + => _storage.FindNewestSessionIdBySerialAsync(tenantId, serialNumber); + + public Task FindNewestSessionIdByDeviceNameAsync(string tenantId, string deviceName) + => _storage.FindNewestSessionIdByDeviceNameAsync(tenantId, deviceName); + public Task> GetSessionsAsync(string tenantId, int? days = null) => _storage.GetSessionsAsync(tenantId, days); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs index 7570a5b9..e121b6fc 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs @@ -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 /// /// Core config logic: fetch tenant + admin config, gather rules, IME patterns. /// Called by both the cert-auth Run() method and the bootstrap wrapper. + /// is the certificate identity from the cert Subject CN + /// (cert-auth callers); the bootstrap wrapper has none and leaves it null. /// - internal async Task ProcessGetConfigAsync(HttpRequestData req, string tenantId) + internal async Task 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); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestTelemetryFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestTelemetryFunction.cs index 469ffe5c..5ec9a17a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestTelemetryFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestTelemetryFunction.cs @@ -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 }; /// - /// 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 DeviceBlocked=true (and - /// optional DeviceKillSignal=true) if the caller should stop — or null if the - /// request may proceed. Evaluation + kill ops-event live in , - /// shared with the agent-config channel. + /// 200 with DeviceBlocked=true (and DeviceKillSignal=true for a kill): the + /// agent pauses its upload loop until UnblockAt, or self-destructs on a kill. /// - private async Task 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 WriteDeviceBlockedAsync( - HttpRequestData req, bool isKill, DateTime? unblockAt, string message) + private static async Task 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; diff --git a/src/Backend/AutopilotMonitor.Functions/Middleware/RequestTelemetryMiddleware.cs b/src/Backend/AutopilotMonitor.Functions/Middleware/RequestTelemetryMiddleware.cs index bc8f8dc0..5b9fea3c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Middleware/RequestTelemetryMiddleware.cs +++ b/src/Backend/AutopilotMonitor.Functions/Middleware/RequestTelemetryMiddleware.cs @@ -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)) diff --git a/src/Backend/AutopilotMonitor.Functions/Security/DeviceIdentityBinding.cs b/src/Backend/AutopilotMonitor.Functions/Security/DeviceIdentityBinding.cs new file mode 100644 index 00000000..bc4babbf --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Security/DeviceIdentityBinding.cs @@ -0,0 +1,32 @@ +using Microsoft.Azure.Functions.Worker.Http; + +namespace AutopilotMonitor.Functions.Security +{ + /// + /// DEVICE-IDENTITY-BLOCK-BINDING — request-row dimension for the identity leg of the kill + /// switch. Carried on the (unsampled) request row via FunctionContext.Items, same + /// carrier and reasoning as : worker-side LogInformation never + /// reaches App Insights, so the bulk outcome needs a per-request property to be a denominator. + /// + public static class DeviceIdentityBinding + { + public const string RequestItemKey = "DeviceIdentityBinding"; + + public static class Outcome + { + /// No certificate identity on the request (bootstrap token, non-GUID CN) — serial-only enforcement. + public const string NoIdentity = "NoIdentity"; + /// Identity present; no block keyed by it. + public const string Match = "Match"; + /// Identity present and blocked via its alias row (the serial leg did not match — omitted/forged header). + public const string IdentityBlocked = "IdentityBlocked"; + } + + public static void Stamp(HttpRequestData req, string outcome) + { + var items = req.FunctionContext?.Items; + if (items != null) + items[RequestItemKey] = outcome; + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/BlockedDeviceService.cs b/src/Backend/AutopilotMonitor.Functions/Services/BlockedDeviceService.cs index d4eb66a8..358b1874 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/BlockedDeviceService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/BlockedDeviceService.cs @@ -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. + /// + /// 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. + /// /// public class BlockedDeviceService { private readonly IDeviceSecurityRepository _securityRepo; + private readonly ISessionRepository _sessionRepo; private readonly ILogger _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); + /// How many certificate identities a block is mirrored onto (newest sessions first). + 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 _cache = new(StringComparer.OrdinalIgnoreCase); + // Cache key: "tenantId|SERIAL" (upper-cased serial) for the serial leg and + // "tenantId|id:" 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 _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 _loadedTenants = new(StringComparer.OrdinalIgnoreCase); - public BlockedDeviceService(IDeviceSecurityRepository securityRepo, ILogger logger) - : this(securityRepo, logger, DefaultEntryRevalidateAfter) + public BlockedDeviceService(IDeviceSecurityRepository securityRepo, ISessionRepository sessionRepo, ILogger logger) + : this(securityRepo, sessionRepo, logger, DefaultEntryRevalidateAfter) { } /// Test seam: lets tests shrink the revalidation window without a clock abstraction. internal BlockedDeviceService( - IDeviceSecurityRepository securityRepo, ILogger logger, TimeSpan entryRevalidateAfter) + IDeviceSecurityRepository securityRepo, ISessionRepository sessionRepo, ILogger logger, TimeSpan entryRevalidateAfter) { _securityRepo = securityRepo; + _sessionRepo = sessionRepo; _logger = logger; _entryRevalidateAfter = entryRevalidateAfter; } /// - /// 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 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); + } + + /// + /// 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 ; 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). + /// + 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); + } + + /// + /// Lazy tenant load, then cache hit (revalidated when stale) or miss (storage point-read, + /// answer promoted positive or negative). Null = not blocked. + /// + private async Task ResolveEntryAsync( + string tenantId, string cacheKey, Func> 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(); + } + + /// + /// 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). + /// + 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. /// - private async Task RefreshCacheEntryFromStorageAsync( - string tenantId, string serialNumber, string cacheKey) + private async Task 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 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 /// /// Blocks a device for the specified duration. Updates both storage and the in-memory cache. /// 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. /// 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); } /// - /// 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. + /// + private async Task> ResolveAliasDeviceIdsAsync(string tenantId, string serialNumber) + { + try + { + return await _sessionRepo.GetOwnerDeviceIdsForSerialAsync(tenantId, serialNumber, MaxAliasDeviceIds) + ?? Array.Empty(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Alias resolution failed for tenant {TenantId} — block stays serial-only", tenantId); + return Array.Empty(); + } + } + + /// + /// Removes a device block immediately — primary row plus its identity aliases. Updates + /// both storage and the in-memory cache. /// 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())) + _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; } + /// Canonical serial the block is keyed under (identity entries: the primary they mirror). + public string? SerialNumber { get; set; } + /// Serial entries only: identity keys mirroring this block. + public List AliasDeviceIds { get; set; } = new(); /// /// 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 diff --git a/src/Backend/AutopilotMonitor.Functions/Services/KillSwitchEvaluator.cs b/src/Backend/AutopilotMonitor.Functions/Services/KillSwitchEvaluator.cs index 754255c9..4f6ed60b 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/KillSwitchEvaluator.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/KillSwitchEvaluator.cs @@ -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 /// KillSignalDelivered 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. /// public class KillSwitchEvaluator { @@ -43,19 +45,28 @@ namespace AutopilotMonitor.Functions.Services } /// - /// Runs the device-serial check, then the agent-version check. Returns a non-blocked - /// verdict when neither matches. 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. names the + /// delivery channel for logging + the ops event ("telemetry" or "config"). + /// is the certificate identity (null = no identity on + /// the request: bootstrap token, non-GUID CN — serial-only behaviour). , + /// 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 so the caller can re-evaluate once + /// the body is parsed. /// public async Task 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 } /// - /// Outcome of a kill-switch evaluation. means the request may + /// Outcome of a kill-switch evaluation. 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). /// 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) + /// + /// 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. + /// + public string? BlockedSessionIds { get; } + + /// True when the block is session-scoped and the caller has not yet supplied a session id. + public bool IsSessionScoped => IsBlocked && !IsKill && !string.IsNullOrEmpty(BlockedSessionIds); + + /// Serial the matching device block is keyed under (identity hits: the row's serial, not the header's). + public string? BlockedSerial { get; } + + /// One of — the request-row dimension the caller stamps. + 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; } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.DeviceLookup.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.DeviceLookup.cs new file mode 100644 index 00000000..d6f6c247 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.DeviceLookup.cs @@ -0,0 +1,124 @@ +using Azure.Data.Tables; +using AutopilotMonitor.Functions.Security; +using AutopilotMonitor.Shared; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Services +{ + /// + /// 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. + /// + public partial class TableStorageService + { + /// + /// 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. + /// + 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 FindNewestSessionIdBySerialAsync(string tenantId, string serialNumber) + => FindNewestSessionIdByColumnAsync(tenantId, "SerialNumber", serialNumber); + + public Task FindNewestSessionIdByDeviceNameAsync(string tenantId, string deviceName) + => FindNewestSessionIdByColumnAsync(tenantId, "DeviceName", deviceName); + + /// + /// SessionsIndex partition query on one always-projected column. The index RowKey is + /// {InvertedTicks(StartedAt)}_{sessionId}, so the ordinally smallest RowKey among the + /// matches is the newest session. Rows written before the column was projected are not + /// matched by eq (same caveat as the other pushed-down search filters) — for the + /// progress portal's "current enrollment" question that is immaterial. + /// + private async Task 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(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; + } + } + + /// + /// Certificate identities (OwnerDeviceId) 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. + /// + public async Task> GetOwnerDeviceIdsForSerialAsync(string tenantId, string serialNumber, int max = 5) + { + SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); + if (string.IsNullOrWhiteSpace(serialNumber) || max <= 0) + return Array.Empty(); + + 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(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(); + } + } + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IDeviceSecurityRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IDeviceSecurityRepository.cs index c6fe1340..088eb074 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/IDeviceSecurityRepository.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IDeviceSecurityRepository.cs @@ -12,11 +12,34 @@ namespace AutopilotMonitor.Shared.DataAccess { // --- Blocked Devices --- Task<(bool isBlocked, DateTime? unblockAt, string action, string? blockedSessionIds)> IsDeviceBlockedAsync(string tenantId, string serialNumber); + + /// + /// 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 + /// ; serialNumber 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. + /// + Task<(bool isBlocked, DateTime? unblockAt, string action, string? blockedSessionIds, string? serialNumber)> IsDeviceIdentityBlockedAsync( + string tenantId, string intuneDeviceId); + Task> GetBlockedDevicesAsync(string tenantId); Task> GetAllBlockedDevicesAsync(); + + /// + /// Writes the serial-keyed block row plus one alias row per + /// 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. + /// 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? aliasDeviceIds = null); + + /// + /// 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. + /// + Task> UnblockDeviceAsync(string tenantId, string serialNumber); // --- Blocked Versions --- Task<(bool isBlocked, string action, string? matchedPattern)> IsVersionBlockedAsync(string agentVersion); diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs index 00533bce..0e3cf5c2 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs @@ -202,6 +202,25 @@ namespace AutopilotMonitor.Shared.DataAccess /// Task> GetOpenSessionsForDeviceAsync(string tenantId, string serialNumber); + /// + /// 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 . 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. + /// + Task> GetOwnerDeviceIdsForSerialAsync(string tenantId, string serialNumber, int max = 5); + + /// + /// Newest session (by StartedAt) whose serial equals + /// (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. + /// + Task FindNewestSessionIdBySerialAsync(string tenantId, string serialNumber); + + /// Same as keyed on DeviceName. + Task FindNewestSessionIdByDeviceNameAsync(string tenantId, string deviceName); + // --- IME Version History --- ///