mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
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:
+11
-4
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+114
-3
@@ -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))
|
||||
|
||||
+9
@@ -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);
|
||||
|
||||
+46
-26
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user