From 5586963bcc714e4c1072d905786a48d741f25732 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Tue, 9 Jun 2026 13:15:23 +0200 Subject: [PATCH] feat(autopilot): show "not registered" devices (14-day window) under enrollment validation Adds a tenant-scoped, informational overview of devices that failed enrollment with HTTP 403 because they were not in the tenant's Autopilot / Corporate Identifier registry. Mirrors the Hardware Whitelist rejection insights but aggregates per device (serial number), matching how Autopilot registers individual devices rather than models. Read-only, no webhook. Backend - GetDeviceNotRegisteredFunction: route audit/device-not-registered, MemberRead, filters distress reports on ErrorType == "DeviceNotRegistered", aggregates by serial (case-insensitive; empty serial -> single bucket); 14-day horizon is the existing distress-report retention. - Register route in EndpointAccessPolicyCatalog (fail-closed). - 8 unit tests for BuildAggregatedResult. Frontend - api.distress.deviceNotRegistered(). - NotRegisteredDevicesInsights component (serial/mfr/model/attempts/first/last seen, green empty-state, unverified-data notice). - Wired below AutopilotValidationSection. DeviceNotRegistered (403) covers both Autopilot and Corporate Identifier validation; the agent cannot distinguish which failed, so copy is neutral. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../GetDeviceNotRegisteredFunctionTests.cs | 194 ++++++++++++++++++ .../Reports/GetDeviceNotRegisteredFunction.cs | 95 +++++++++ .../Security/EndpointAccessPolicyCatalog.cs | 1 + .../NotRegisteredDevicesInsights.tsx | 193 +++++++++++++++++ .../sections/SectionAutopilotValidation.tsx | 4 +- src/Web/autopilot-monitor-web/lib/api.ts | 1 + 6 files changed, 487 insertions(+), 1 deletion(-) create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/GetDeviceNotRegisteredFunctionTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/Reports/GetDeviceNotRegisteredFunction.cs create mode 100644 src/Web/autopilot-monitor-web/app/settings/components/NotRegisteredDevicesInsights.tsx diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/GetDeviceNotRegisteredFunctionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/GetDeviceNotRegisteredFunctionTests.cs new file mode 100644 index 00000000..dc7c55eb --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/GetDeviceNotRegisteredFunctionTests.cs @@ -0,0 +1,194 @@ +using AutopilotMonitor.Functions.Functions.Reports; +using AutopilotMonitor.Shared.DataAccess; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Tests for the device-not-registered aggregation logic extracted from +/// GetDeviceNotRegisteredFunction. All tests target the pure static BuildAggregatedResult +/// method — no HTTP or DI needed. Aggregation is by serial number (Autopilot registers +/// individual devices, not models). +/// +public class GetDeviceNotRegisteredFunctionTests +{ + private static readonly DateTime T0 = new(2026, 3, 1, 12, 0, 0, DateTimeKind.Utc); + + // ========================================================================= + // Empty / no matching reports + // ========================================================================= + + [Fact] + public void BuildAggregatedResult_EmptyList_ReturnsEmpty() + { + var (aggregated, totalRawReports) = GetDeviceNotRegisteredFunction.BuildAggregatedResult([]); + + Assert.Empty(aggregated); + Assert.Equal(0, totalRawReports); + } + + [Fact] + public void BuildAggregatedResult_FiltersDeviceNotRegisteredOnly() + { + var reports = new List + { + MakeEntry("DeviceNotRegistered", "Lenovo", "ThinkPad T14", "SN1", T0), + MakeEntry("HardwareNotAllowed", "Dell", "Latitude", "SN2", T0.AddMinutes(1)), + MakeEntry("AuthCertificateRejected", "HP", "EliteBook", "SN3", T0.AddMinutes(2)), + }; + + var (aggregated, totalRawReports) = GetDeviceNotRegisteredFunction.BuildAggregatedResult(reports); + + Assert.Single(aggregated); + Assert.Equal(1, totalRawReports); + } + + // ========================================================================= + // Aggregation by serial number + // ========================================================================= + + [Fact] + public void BuildAggregatedResult_AggregatesBySerialCaseInsensitive() + { + var reports = new List + { + MakeEntry("DeviceNotRegistered", "Lenovo", "ThinkPad T14", "GM18NHV3", T0), + MakeEntry("DeviceNotRegistered", "Lenovo", "ThinkPad T14", "gm18nhv3", T0.AddMinutes(1)), + MakeEntry("DeviceNotRegistered", "Lenovo", "ThinkPad T14", " GM18NHV3 ", T0.AddMinutes(2)), + }; + + var (aggregated, totalRawReports) = GetDeviceNotRegisteredFunction.BuildAggregatedResult(reports); + + Assert.Single(aggregated); + Assert.Equal(3, totalRawReports); + var item = ToDynamic(aggregated[0]); + Assert.Equal(3, (int)item.attemptCount); + } + + [Fact] + public void BuildAggregatedResult_DistinctSerials_ProduceDistinctRows() + { + var reports = new List + { + MakeEntry("DeviceNotRegistered", "Lenovo", "ThinkPad T14", "SN1", T0), + MakeEntry("DeviceNotRegistered", "Lenovo", "ThinkPad T14", "SN2", T0.AddMinutes(1)), + MakeEntry("DeviceNotRegistered", "Lenovo", "ThinkCentre M70q", "SN3", T0.AddMinutes(2)), + }; + + var (aggregated, totalRawReports) = GetDeviceNotRegisteredFunction.BuildAggregatedResult(reports); + + Assert.Equal(3, aggregated.Count); + Assert.Equal(3, totalRawReports); + } + + [Fact] + public void BuildAggregatedResult_UsesMostRecentManufacturerModel() + { + var reports = new List + { + MakeEntry("DeviceNotRegistered", "OldMfr", "OldModel", "SN1", T0), + MakeEntry("DeviceNotRegistered", "Lenovo", "ThinkPad T14", "SN1", T0.AddMinutes(5)), + }; + + var (aggregated, _) = GetDeviceNotRegisteredFunction.BuildAggregatedResult(reports); + + var item = ToDynamic(aggregated[0]); + Assert.Equal("Lenovo", (string)item.manufacturer); + Assert.Equal("ThinkPad T14", (string)item.model); + Assert.Equal("SN1", (string)item.serialNumber); + } + + // ========================================================================= + // Timestamps + // ========================================================================= + + [Fact] + public void BuildAggregatedResult_FirstSeenLastSeen_Correct() + { + var t1 = T0; + var t2 = T0.AddHours(1); + var t3 = T0.AddHours(2); + + var reports = new List + { + MakeEntry("DeviceNotRegistered", "Lenovo", "ThinkPad T14", "SN1", t2), + MakeEntry("DeviceNotRegistered", "Lenovo", "ThinkPad T14", "SN1", t1), + MakeEntry("DeviceNotRegistered", "Lenovo", "ThinkPad T14", "SN1", t3), + }; + + var (aggregated, _) = GetDeviceNotRegisteredFunction.BuildAggregatedResult(reports); + + var item = ToDynamic(aggregated[0]); + Assert.Equal(t1, (DateTime)item.firstSeen); + Assert.Equal(t3, (DateTime)item.lastSeen); + } + + [Fact] + public void BuildAggregatedResult_OrderedByLastSeenDescending() + { + var reports = new List + { + MakeEntry("DeviceNotRegistered", "Lenovo", "ThinkPad T14", "SN1", T0), + MakeEntry("DeviceNotRegistered", "Lenovo", "ThinkPad X13", "SN2", T0.AddHours(2)), + MakeEntry("DeviceNotRegistered", "Lenovo", "ThinkCentre", "SN3", T0.AddHours(1)), + }; + + var (aggregated, _) = GetDeviceNotRegisteredFunction.BuildAggregatedResult(reports); + + Assert.Equal(3, aggregated.Count); + var first = ToDynamic(aggregated[0]); + var second = ToDynamic(aggregated[1]); + var third = ToDynamic(aggregated[2]); + + Assert.True((DateTime)first.lastSeen > (DateTime)second.lastSeen); + Assert.True((DateTime)second.lastSeen > (DateTime)third.lastSeen); + } + + // ========================================================================= + // Null / empty serial number + // ========================================================================= + + [Fact] + public void BuildAggregatedResult_NullOrEmptySerials_GroupedIntoSingleBucket() + { + var reports = new List + { + MakeEntry("DeviceNotRegistered", "Lenovo", "ThinkPad", null, T0), + MakeEntry("DeviceNotRegistered", "Lenovo", "ThinkPad", "", T0.AddMinutes(1)), + MakeEntry("DeviceNotRegistered", "Lenovo", "ThinkPad", " ", T0.AddMinutes(2)), + }; + + var (aggregated, _) = GetDeviceNotRegisteredFunction.BuildAggregatedResult(reports); + + Assert.Single(aggregated); + var item = ToDynamic(aggregated[0]); + Assert.Equal(3, (int)item.attemptCount); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + private static DistressReportEntry MakeEntry( + string errorType, string? manufacturer, string? model, string? serial, DateTime ingestedAt) => + new() + { + TenantId = "test-tenant", + ErrorType = errorType, + Manufacturer = manufacturer, + Model = model, + SerialNumber = serial, + IngestedAt = ingestedAt, + AgentTimestamp = ingestedAt, + }; + + private static dynamic ToDynamic(object obj) + { + var type = obj.GetType(); + var dict = new System.Dynamic.ExpandoObject() as IDictionary; + foreach (var prop in type.GetProperties()) + { + dict[prop.Name] = prop.GetValue(obj); + } + return dict; + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Reports/GetDeviceNotRegisteredFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Reports/GetDeviceNotRegisteredFunction.cs new file mode 100644 index 00000000..1f05c468 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Reports/GetDeviceNotRegisteredFunction.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Shared.DataAccess; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Functions.Reports +{ + /// + /// Tenant-scoped endpoint: returns devices that failed enrollment because they were not + /// registered in the tenant's Autopilot / Corporate Identifier registry (backend 403), + /// aggregated by serial number. Data comes from distress reports with + /// ErrorType == "DeviceNotRegistered". + /// + /// Informational only — surfaces a 14-day window of unregistered devices so admins can + /// sanity-check their enrollment scoping/assignment. The 14-day horizon is enforced by the + /// distress-report retention cleanup, not by this function. + /// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware. + /// + public class GetDeviceNotRegisteredFunction + { + private readonly ILogger _logger; + private readonly IDistressReportRepository _repository; + + public GetDeviceNotRegisteredFunction( + ILogger logger, + IDistressReportRepository repository) + { + _logger = logger; + _repository = repository; + } + + [Function("GetDeviceNotRegistered")] + public async Task Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "audit/device-not-registered")] HttpRequestData req) + { + try + { + string tenantId = TenantHelper.GetTenantId(req); + + var reports = await _repository.GetDistressReportsAsync(tenantId, maxResults: 200); + var (aggregated, totalRawReports) = BuildAggregatedResult(reports); + + return await req.OkAsync(new + { + success = true, + aggregated, + totalRawReports, + dataQualityNotice = "This data is from pre-authentication distress reports and is UNVERIFIED. Devices reported here were rejected with HTTP 403 because they were not found in the tenant's Autopilot or Corporate Identifier registry. Serial number, manufacturer, and model values are self-reported by devices." + }); + } + catch (Exception ex) + { + return await req.InternalServerErrorAsync(_logger, ex, "Get devices not registered"); + } + } + + /// + /// Filters distress reports to DeviceNotRegistered and aggregates by serial number. + /// Reports with no serial number are grouped into a single "unknown serial" bucket. + /// Extracted as public static for testability. + /// + public static (List aggregated, int totalRawReports) BuildAggregatedResult( + List reports) + { + var notRegistered = reports + .Where(r => string.Equals(r.ErrorType, "DeviceNotRegistered", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + var aggregated = notRegistered + .GroupBy(r => (r.SerialNumber ?? "").Trim().ToLowerInvariant()) + .Select(g => + { + var mostRecent = g.OrderByDescending(r => r.IngestedAt).First(); + + return new + { + serialNumber = mostRecent.SerialNumber ?? "", + manufacturer = mostRecent.Manufacturer ?? "", + model = mostRecent.Model ?? "", + attemptCount = g.Count(), + firstSeen = g.Min(r => r.IngestedAt), + lastSeen = g.Max(r => r.IngestedAt) + }; + }) + .OrderByDescending(a => a.lastSeen) + .ToList(); + + return (aggregated, notRegistered.Count); + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs index 69eb588b..ded6294b 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs @@ -159,6 +159,7 @@ public static class EndpointAccessPolicyCatalog new("GET", "metrics/ime-versions", EndpointPolicy.MemberRead), new("GET", "audit/logs", EndpointPolicy.MemberRead), new("GET", "audit/hardware-rejected", EndpointPolicy.MemberRead), + new("GET", "audit/device-not-registered", EndpointPolicy.MemberRead), new("GET", "diagnostics/download-url", EndpointPolicy.MemberRead, TenantScoping.QueryParam), new("GET", "rules/gather", EndpointPolicy.MemberRead), new("GET", "rules/analyze", EndpointPolicy.MemberRead), diff --git a/src/Web/autopilot-monitor-web/app/settings/components/NotRegisteredDevicesInsights.tsx b/src/Web/autopilot-monitor-web/app/settings/components/NotRegisteredDevicesInsights.tsx new file mode 100644 index 00000000..bf851baf --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/settings/components/NotRegisteredDevicesInsights.tsx @@ -0,0 +1,193 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { api } from "@/lib/api"; +import { authenticatedFetch } from "@/lib/authenticatedFetch"; + +interface AggregatedNotRegistered { + serialNumber: string; + manufacturer: string; + model: string; + attemptCount: number; + firstSeen: string; + lastSeen: string; +} + +interface DeviceNotRegisteredResponse { + success: boolean; + aggregated: AggregatedNotRegistered[]; + totalRawReports: number; + dataQualityNotice: string; +} + +interface NotRegisteredDevicesInsightsProps { + getAccessToken: () => Promise; +} + +function formatDate(iso: string): string { + try { + return new Date(iso).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + } catch { + return iso; + } +} + +export default function NotRegisteredDevicesInsights({ + getAccessToken, +}: NotRegisteredDevicesInsightsProps) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchData = useCallback(async () => { + try { + setLoading(true); + setError(null); + const res = await authenticatedFetch(api.distress.deviceNotRegistered(), getAccessToken); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const json: DeviceNotRegisteredResponse = await res.json(); + setData(json); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load"); + } finally { + setLoading(false); + } + }, [getAccessToken]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + // Loading state + if (loading) { + return ( +
+
+
+
+
+
+
+
+
+ ); + } + + // Error state + if (error) { + return ( +
+
+

Failed to load unregistered devices: {error}

+ +
+
+ ); + } + + const hasDevices = data && data.aggregated.length > 0; + + return ( +
+ {/* Header */} +
+
+
+ {hasDevices ? ( + + + + ) : ( + + + + )} +
+

+ {hasDevices ? "Devices Not Registered" : "No devices reported as not registered in the last 14 days"} + {hasDevices && Last 14 days} +

+ {hasDevices && ( +

+ Devices rejected with HTTP 403 because they were not found in this tenant's Autopilot or Corporate Identifier registry +

+ )} +
+
+ {hasDevices && ( + + Unverified + + )} +
+
+ + {/* Empty-state hint */} + {!hasDevices && ( +
+

+ When a device attempts to enroll but is not part of your Autopilot or Corporate Identifier + scope, it appears here. An empty list means every device that reached the backend was in scope. +

+
+ )} + + {/* Data quality notice + table (only when devices exist) */} + {hasDevices && ( + <> +
+
+

+ This data comes from pre-authentication distress signals. Serial number, manufacturer, and + model values are self-reported and unverified. Use it to sanity-check whether your enrollment + scoping/assignment covers these devices. +

+
+
+ +
+
+ + + + + + + + + + + + + {data!.aggregated.map((row, idx) => ( + + + + + + + + + ))} + +
Serial NumberManufacturerModelAttemptsFirst SeenLast Seen
{row.serialNumber || "–"}{row.manufacturer || "–"}{row.model || "–"}{row.attemptCount}{formatDate(row.firstSeen)}{formatDate(row.lastSeen)}
+
+

+ {data!.totalRawReports} total rejection{data!.totalRawReports !== 1 ? "s" : ""} across {data!.aggregated.length} device{data!.aggregated.length !== 1 ? "s" : ""} +

+
+ + )} +
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionAutopilotValidation.tsx b/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionAutopilotValidation.tsx index 719d8607..06ff7d9a 100644 --- a/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionAutopilotValidation.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionAutopilotValidation.tsx @@ -4,6 +4,7 @@ import { useAuth } from "@/contexts/AuthContext"; import { useTenantConfig } from "../../TenantConfigContext"; import { TenantNotifications } from "../../TenantNotifications"; import AutopilotValidationSection from "../../components/AutopilotValidationSection"; +import NotRegisteredDevicesInsights from "../../components/NotRegisteredDevicesInsights"; export function SectionAutopilotValidation() { const { @@ -15,7 +16,7 @@ export function SectionAutopilotValidation() { beginDeviceValidationConsentFlow, } = useTenantConfig(); - const { user } = useAuth(); + const { user, getAccessToken } = useAuth(); // DevPrep "Device association" is in Microsoft Private Preview — surface only to Global Admins // until it ships GA. Backend always rejects writes from non-GA callers regardless of UI. // TODO(devprep-followup): add vitest DOM-render test asserting toggle is hidden for @@ -39,6 +40,7 @@ export function SectionAutopilotValidation() { saving={savingSection === "autopilotValidation"} onBeginConsent={beginDeviceValidationConsentFlow} /> + ); } diff --git a/src/Web/autopilot-monitor-web/lib/api.ts b/src/Web/autopilot-monitor-web/lib/api.ts index be2454c0..a31ed3a9 100644 --- a/src/Web/autopilot-monitor-web/lib/api.ts +++ b/src/Web/autopilot-monitor-web/lib/api.ts @@ -383,6 +383,7 @@ export const api = { // ── Hardware Rejection Insights (tenant-scoped, from distress data) ────── distress: { hardwareRejected: () => `${API_BASE_URL}/api/audit/hardware-rejected`, + deviceNotRegistered: () => `${API_BASE_URL}/api/audit/device-not-registered`, }, // ── Ops Events ───────────────────────────────────────────────────────────