Session-Aware Auto-Unblock for new session and blocked devices

This commit is contained in:
Oliver Kieselbach
2026-04-07 00:11:15 +02:00
parent 8b5149fd9d
commit 257a8f7b52
7 changed files with 175 additions and 42 deletions
@@ -1 +1 @@
879
880
@@ -118,11 +118,38 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
}
public async Task BlockDeviceAsync(string tenantId, string serialNumber, int durationHours,
string blockedByEmail, string? reason = null, string action = "Block")
string blockedByEmail, string? reason = null, string action = "Block", string? blockedSessionId = null)
{
var now = DateTime.UtcNow;
var unblockAt = now.AddHours(durationHours);
// If a session-aware block already exists, merge session IDs
string? blockedSessionIds = blockedSessionId;
if (!string.IsNullOrEmpty(blockedSessionId))
{
try
{
var existing = await _blockedDevicesTable.GetEntityAsync<TableEntity>(tenantId, EncodeRowKey(serialNumber));
var existingSessionIds = existing?.Value?.GetString("BlockedSessionIds");
if (existingSessionIds == null)
{
// Existing whole-device block takes precedence — don't downgrade to session-aware
if (existing?.Value != null)
blockedSessionIds = null;
}
else
{
// Merge: append new session ID if not already present
blockedSessionIds = MergeSessionId(existingSessionIds, blockedSessionId);
}
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
// No existing record — use single session ID
}
}
var entity = new TableEntity(tenantId, EncodeRowKey(serialNumber))
{
["SerialNumber"] = serialNumber,
@@ -134,10 +161,31 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
["Action"] = action ?? "Block"
};
if (blockedSessionIds != null)
entity["BlockedSessionIds"] = blockedSessionIds;
await _blockedDevicesTable.UpsertEntityAsync(entity);
await _publisher.PublishAsync("device.blocked", new { tenantId, serialNumber, action, durationHours }, tenantId);
}
internal static string MergeSessionId(string? existingList, string newSessionId)
{
if (string.IsNullOrEmpty(existingList)) return newSessionId;
if (SessionIdListContains(existingList, newSessionId)) return existingList;
return $"{existingList},{newSessionId}";
}
internal static bool SessionIdListContains(string? sessionIdList, string sessionId)
{
if (string.IsNullOrEmpty(sessionIdList)) return false;
foreach (var id in sessionIdList.Split(','))
{
if (string.Equals(id.Trim(), sessionId, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
public async Task UnblockDeviceAsync(string tenantId, string serialNumber)
{
try
@@ -333,7 +381,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
BlockedByEmail = entity.GetString("BlockedByEmail"),
DurationHours = entity.GetInt32("DurationHours") ?? 12,
Reason = entity.GetString("Reason"),
Action = entity.GetString("Action") ?? "Block"
Action = entity.GetString("Action") ?? "Block",
BlockedSessionIds = entity.GetString("BlockedSessionIds")
};
}
@@ -30,38 +30,64 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
// Check if this device has been administratively blocked (e.g. rogue device sending excessive data).
// We read the serial number from the header (same header used by AutopilotDeviceValidator).
// Using HTTP 200 with DeviceBlocked=true so the agent does not trigger its auth-failure circuit breaker.
//
// Session-aware blocking: maintenance auto-blocks store BlockedSessionIds. If the current
// ingest is from a DIFFERENT session (new enrollment), the device is auto-unblocked.
// Kill actions and manual (whole-device) blocks are never auto-unblocked.
var serialNumberHeader = req.Headers.Contains("X-Device-SerialNumber")
? req.Headers.GetValues("X-Device-SerialNumber").FirstOrDefault()
: null;
IngestEventsRequest? earlyParsedRequest = null;
if (!string.IsNullOrEmpty(serialNumberHeader))
{
var (isBlocked, unblockAt, blockAction) = await _blockedDeviceService.IsBlockedAsync(tenantId, serialNumberHeader);
var (isBlocked, unblockAt, blockAction, blockedSessionIds) = await _blockedDeviceService.IsBlockedAsync(tenantId, serialNumberHeader);
if (isBlocked)
{
var isKill = string.Equals(blockAction, "Kill", StringComparison.OrdinalIgnoreCase);
_logger.LogWarning(
"{Action} ingest from device: TenantId={TenantId}, SerialNumber={SerialNumber}, UnblockAt={UnblockAt}",
isKill ? "KILL signal for" : "Rejected", tenantId, serialNumberHeader, unblockAt);
var blockedHttpResponse = req.CreateResponse(System.Net.HttpStatusCode.OK);
await blockedHttpResponse.WriteAsJsonAsync(new IngestEventsResponse
// Session-aware block: parse body to check if this is a different (new) session
if (!string.IsNullOrEmpty(blockedSessionIds))
{
Success = false,
DeviceBlocked = true,
DeviceKillSignal = isKill,
UnblockAt = unblockAt,
Message = isKill
? "Device has been issued a remote kill signal."
: "Device is temporarily blocked by an administrator.",
ProcessedAt = DateTime.UtcNow
});
return new IngestEventsOutput
earlyParsedRequest = await ParseNdjsonGzipRequest(req.Body, tenantId);
if (earlyParsedRequest != null && !string.IsNullOrEmpty(earlyParsedRequest.SessionId))
{
var (stillBlocked, _, _, _) = await _blockedDeviceService.IsBlockedAsync(
tenantId, serialNumberHeader, earlyParsedRequest.SessionId);
if (!stillBlocked)
{
// Auto-unblocked — fall through to normal processing with already-parsed request
isBlocked = false;
}
}
}
if (isBlocked)
{
HttpResponse = blockedHttpResponse,
SignalRMessages = Array.Empty<SignalRMessageAction>()
};
var isKill = string.Equals(blockAction, "Kill", StringComparison.OrdinalIgnoreCase);
_logger.LogWarning(
"{Action} ingest from device: TenantId={TenantId}, SerialNumber={SerialNumber}, UnblockAt={UnblockAt}",
isKill ? "KILL signal for" : "Rejected", tenantId, serialNumberHeader, unblockAt);
var blockedHttpResponse = req.CreateResponse(System.Net.HttpStatusCode.OK);
await blockedHttpResponse.WriteAsJsonAsync(new IngestEventsResponse
{
Success = false,
DeviceBlocked = true,
DeviceKillSignal = isKill,
UnblockAt = unblockAt,
Message = isKill
? "Device has been issued a remote kill signal."
: "Device is temporarily blocked by an administrator.",
ProcessedAt = DateTime.UtcNow
});
return new IngestEventsOutput
{
HttpResponse = blockedHttpResponse,
SignalRMessages = Array.Empty<SignalRMessageAction>()
};
}
}
}
@@ -101,7 +127,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
}
// --- Parse NDJSON+gzip request body (only after security is cleared) ---
var request = await ParseNdjsonGzipRequest(req.Body, tenantId);
// If session-aware block check already parsed the body, reuse that result (stream is consumed)
var request = earlyParsedRequest ?? await ParseNdjsonGzipRequest(req.Body, tenantId);
if (request?.Events == null || request.Events.Count == 0)
{
@@ -2,6 +2,7 @@ using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Shared.DataAccess;
using Microsoft.Extensions.Logging;
@@ -35,11 +36,15 @@ namespace AutopilotMonitor.Functions.Services
/// Checks whether a device is currently blocked.
/// 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
/// (BlockedSessionIds is set), auto-unblocks if the session is different.
/// Kill actions are never auto-unblocked.
/// </summary>
public async Task<(bool isBlocked, DateTime? unblockAt, string action)> IsBlockedAsync(string tenantId, string serialNumber)
public async Task<(bool isBlocked, DateTime? unblockAt, string action, string? blockedSessionIds)> IsBlockedAsync(
string tenantId, string serialNumber, string? currentSessionId = null)
{
if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(serialNumber))
return (false, null, "Block");
return (false, null, "Block", null);
// Lazy-load block list for this tenant if not yet done
if (!_loadedTenants.ContainsKey(tenantId))
@@ -50,28 +55,71 @@ namespace AutopilotMonitor.Functions.Services
var cacheKey = BuildCacheKey(tenantId, serialNumber);
if (_cache.TryGetValue(cacheKey, out var entry))
{
if (DateTime.UtcNow < entry.UnblockAt)
return (true, entry.UnblockAt, entry.Action);
if (DateTime.UtcNow >= entry.UnblockAt)
{
// Block has expired — remove from cache
_cache.TryRemove(cacheKey, out _);
return (false, null, "Block", null);
}
// Block has expired — remove from cache
_cache.TryRemove(cacheKey, out _);
// Kill actions are never auto-unblocked
if (string.Equals(entry.Action, "Kill", StringComparison.OrdinalIgnoreCase))
return (true, entry.UnblockAt, entry.Action, null);
// Whole-device block (no session IDs) — always blocked
if (string.IsNullOrEmpty(entry.BlockedSessionIds))
return (true, entry.UnblockAt, entry.Action, null);
// Session-aware block: caller hasn't provided session ID yet — return blocked with session IDs
// so the caller can parse the body and call again with the actual session ID
if (string.IsNullOrEmpty(currentSessionId))
return (true, entry.UnblockAt, entry.Action, entry.BlockedSessionIds);
// Session-aware block: check if the current session is one of the blocked ones
if (TableDeviceSecurityRepository.SessionIdListContains(entry.BlockedSessionIds, currentSessionId))
return (true, entry.UnblockAt, entry.Action, entry.BlockedSessionIds);
// Different session — auto-unblock: new enrollment on same device
_logger.LogWarning(
"Auto-unblocked device (new session): TenantId={TenantId}, SerialNumber={SerialNumber}, " +
"BlockedSessionIds={BlockedSessionIds}, NewSessionId={NewSessionId}",
tenantId, serialNumber, entry.BlockedSessionIds, currentSessionId);
// Remove block from storage and cache
_ = UnblockDeviceAsync(tenantId, serialNumber);
return (false, null, "Block", null);
}
return (false, null, "Block");
return (false, null, "Block", null);
}
/// <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).
/// </summary>
public async Task BlockDeviceAsync(string tenantId, string serialNumber, int durationHours, string blockedByEmail, string? reason = null, string action = "Block")
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);
await _securityRepo.BlockDeviceAsync(tenantId, serialNumber, durationHours, blockedByEmail, reason, action, blockedSessionId);
// Update cache immediately
// Update cache immediately — merge session IDs if needed
var unblockAt = DateTime.UtcNow.AddHours(durationHours);
var cacheKey = BuildCacheKey(tenantId, serialNumber);
_cache[cacheKey] = new BlockCacheEntry { UnblockAt = unblockAt, Action = action ?? "Block" };
_cache.AddOrUpdate(cacheKey,
_ => new BlockCacheEntry { UnblockAt = unblockAt, Action = action ?? "Block", BlockedSessionIds = blockedSessionId },
(_, existing) =>
{
existing.UnblockAt = unblockAt;
existing.Action = action ?? "Block";
// Merge session IDs; whole-device block (null) takes precedence
if (blockedSessionId != null && existing.BlockedSessionIds != null)
existing.BlockedSessionIds = TableDeviceSecurityRepository.MergeSessionId(existing.BlockedSessionIds, blockedSessionId);
else if (blockedSessionId == null)
existing.BlockedSessionIds = null; // Manual/whole-device block overrides session-aware
// else: existing is null (whole-device) — keep it null
return existing;
});
_logger.LogWarning(
"Device {Action}: TenantId={TenantId}, SerialNumber={SerialNumber}, BlockedBy={BlockedBy}, Until={UnblockAt}, Reason={Reason}",
@@ -128,7 +176,8 @@ namespace AutopilotMonitor.Functions.Services
_cache[BuildCacheKey(tenantId, entry.SerialNumber)] = new BlockCacheEntry
{
UnblockAt = entry.UnblockAt.Value,
Action = entry.Action
Action = entry.Action,
BlockedSessionIds = entry.BlockedSessionIds
};
}
@@ -146,6 +195,7 @@ namespace AutopilotMonitor.Functions.Services
{
public DateTime UnblockAt { get; set; }
public string Action { get; set; } = "Block";
public string? BlockedSessionIds { get; set; }
}
private static string BuildCacheKey(string tenantId, string serialNumber)
@@ -307,7 +307,7 @@ namespace AutopilotMonitor.Functions.Services
if (string.IsNullOrEmpty(session.SerialNumber))
continue;
var (isBlocked, _, _) = await _blockedDeviceService.IsBlockedAsync(tenantId, session.SerialNumber);
var (isBlocked, _, _, _) = await _blockedDeviceService.IsBlockedAsync(tenantId, session.SerialNumber);
if (isBlocked)
continue;
@@ -320,7 +320,8 @@ namespace AutopilotMonitor.Functions.Services
session.SerialNumber,
durationHours: blockDurationHours,
blockedByEmail: "System.Maintenance",
reason: reason);
reason: reason,
blockedSessionId: session.SessionId);
blockedCount++;
}
@@ -15,7 +15,7 @@ namespace AutopilotMonitor.Shared.DataAccess
Task<List<BlockedDeviceEntry>> GetBlockedDevicesAsync(string tenantId);
Task<List<BlockedDeviceEntry>> GetAllBlockedDevicesAsync();
Task BlockDeviceAsync(string tenantId, string serialNumber, int durationHours,
string blockedByEmail, string? reason = null, string action = "Block");
string blockedByEmail, string? reason = null, string action = "Block", string? blockedSessionId = null);
Task UnblockDeviceAsync(string tenantId, string serialNumber);
// --- Blocked Versions ---
@@ -35,6 +35,12 @@ namespace AutopilotMonitor.Shared.DataAccess
public int DurationHours { get; set; }
public string? Reason { get; set; }
public string Action { get; set; } = "Block";
/// <summary>
/// Comma-separated session IDs that triggered this block (maintenance auto-block).
/// Null = whole-device block (manual or legacy). When set, only these specific sessions are blocked;
/// a new session on the same device will auto-unblock.
/// </summary>
public string? BlockedSessionIds { get; set; }
}
public class BlockedVersionEntry
File diff suppressed because one or more lines are too long