diff --git a/docs/log.md b/docs/log.md index 69d6f248..9963383a 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,8 @@ ## 2026-08-24 +* **Change**: `DeviceAssociationValidator` / `SecurityValidator` — the DevPrep association shadow now logs one line per real Graph lookup instead of one per request (`DeviceAssociationResult.ServedFromCache`, `AsCacheHit()` copies rather than marking the shared cached instance), the same gate the cert-device-binding shadow uses. Measured: one enrollment produced 263 identical lines off a handful of calls. Status of that preview, for the record: the `/beta/deviceManagement/tenantAssociatedDevices` endpoint answers (6 calls, all HTTP 200, `transient=False`), but every result so far is `matched=False` from a single Autopilot **v1** device — which is the correct answer for a device that was never part of a Device Preparation policy. The matching itself is therefore still unproven, and the endpoint remains undocumented in both v1.0 and beta Graph reference, so it is not a basis for enforcement. + * **Update**: `backend/client-cert-tenant-binding.md` — stage 3 verified live (`ea6a375`, gktatooine): 136 requests, all `Match`, device object ~72 s older than the session start, so no enrollment race in that run. Measured Graph cost: **1 call for 136 requests** (30 min positive cache), versus 25 for the per-request Autopilot validator — the positive TTL needs no change. The shadow log is now gated on `ServedFromCache` (one line per real lookup instead of per request); never-cached outcomes still log every time. * **Update**: `backend/client-cert-tenant-binding.md` — stage 3, the cert-to-device binding: new `IntuneDeviceBindingValidator` resolves the Subject-CN managedDevice id against the tenant's own `deviceManagement/managedDevices` (point-GET, no filter literal), modelled on `CloudPcDeviceValidator` and wired fire-and-forget like the DevPrep shadow so a cold Graph round-trip never enters the agent request path. New GA-only tenant toggle `ValidateIntuneDeviceBinding` (shadow — never touches `deviceValidated`), new optional Graph feature `IntuneDeviceBinding` → `DeviceManagementManagedDevices.Read.All` in `GraphFeatureCatalog` + the customer grant script, and `enrolledAgeSeconds` on every log line so the enrollment race can be measured before this could ever become a gate. Grep marker: `CERT-DEVICE-BINDING-SHADOW`. diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/DeviceAssociationValidatorTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/DeviceAssociationValidatorTests.cs index 4cc5fa35..2c712634 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/DeviceAssociationValidatorTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/DeviceAssociationValidatorTests.cs @@ -127,4 +127,47 @@ public class DeviceAssociationValidatorTests Assert.True(result.IsValid); } + [Fact] + public void AsCacheHit_FlagsTheCopyAndLeavesTheCachedInstanceAlone() + { + // The cached instance is shared across every request for that serial. Marking it in place + // would make the first (real) lookup look cached too, and the shadow log gate would then + // drop the one line per lookup that carries the finding. + var cached = new DeviceAssociationResult + { + IsValid = true, + SerialNumber = Serial, + AssociationState = "preassociated", + DevicePreparationPolicyId = "11111111-2222-3333-4444-555555555555", + AssignedToUserPrincipalName = "someone@example.invalid", + ManagedDeviceId = "66666666-7777-8888-9999-000000000000", + }; + + var hit = cached.AsCacheHit(); + + Assert.True(hit.ServedFromCache); + Assert.False(cached.ServedFromCache); + Assert.NotSame(cached, hit); + + // Every field the shadow log reports must survive the copy. + Assert.Equal(cached.IsValid, hit.IsValid); + Assert.Equal(cached.IsTransient, hit.IsTransient); + Assert.Equal(cached.SerialNumber, hit.SerialNumber); + Assert.Equal(cached.AssociationState, hit.AssociationState); + Assert.Equal(cached.DevicePreparationPolicyId, hit.DevicePreparationPolicyId); + Assert.Equal(cached.AssignedToUserPrincipalName, hit.AssignedToUserPrincipalName); + Assert.Equal(cached.ManagedDeviceId, hit.ManagedDeviceId); + } + + [Fact] + public void FreshlyParsedResult_IsNotFlaggedAsCached() + { + // A result straight from a Graph response must be loggable — that is the one line per lookup. + var body = $@"{{""value"":[{{""serialNumber"":""{Serial}"",""associationState"":""preassociated""}}]}}"; + + var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(body, Serial); + + Assert.False(result.ServedFromCache); + } + } diff --git a/src/Backend/AutopilotMonitor.Functions/Security/DeviceAssociationResult.cs b/src/Backend/AutopilotMonitor.Functions/Security/DeviceAssociationResult.cs index 379e53fa..554b550a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/DeviceAssociationResult.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/DeviceAssociationResult.cs @@ -1,4 +1,4 @@ -using System; +using System; namespace AutopilotMonitor.Functions.Security { @@ -55,5 +55,32 @@ namespace AutopilotMonitor.Functions.Security public string? ManagedDeviceId { get; set; } public string? ErrorMessage { get; set; } + + /// + /// True when this result came from the in-memory cache rather than a fresh Graph lookup. + /// The shadow log uses it to emit one line per real lookup instead of one per request: + /// a single enrollment produced 263 identical lines off a handful of Graph calls. + /// + public bool ServedFromCache { get; set; } + + /// + /// Copy of this result marked as a cache hit, so the shared cached instance is never + /// mutated by a caller. + /// + internal DeviceAssociationResult AsCacheHit() => new() + { + IsValid = IsValid, + IsTransient = IsTransient, + SerialNumber = SerialNumber, + AssociationState = AssociationState, + DevicePreparationPolicyId = DevicePreparationPolicyId, + PreAssociatedByUserPrincipalName = PreAssociatedByUserPrincipalName, + AssignedToUserPrincipalName = AssignedToUserPrincipalName, + PreAssociationDateTime = PreAssociationDateTime, + AssociationDateTime = AssociationDateTime, + ManagedDeviceId = ManagedDeviceId, + ErrorMessage = ErrorMessage, + ServedFromCache = true + }; } } diff --git a/src/Backend/AutopilotMonitor.Functions/Security/DeviceAssociationValidator.cs b/src/Backend/AutopilotMonitor.Functions/Security/DeviceAssociationValidator.cs index 2b2c6866..d609d7f2 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/DeviceAssociationValidator.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/DeviceAssociationValidator.cs @@ -1,4 +1,4 @@ -using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared; using System; using System.Linq; using System.Net.Http; @@ -63,7 +63,7 @@ namespace AutopilotMonitor.Functions.Security if (_cache.TryGetValue(cacheKey, out DeviceAssociationResult? cached) && cached != null) { - return cached; + return cached.AsCacheHit(); } const int maxAttempts = 2; diff --git a/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs b/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs index a849f4c9..581cce6d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs @@ -453,6 +453,14 @@ namespace AutopilotMonitor.Functions.Security try { var devPrepResult = await validator.LookupAsync(capturedTenant, capturedSerial, capturedSession); + + // One line per real Graph lookup, not per request — the same gate the + // cert-device-binding shadow uses. Measured 2026-08-24: a single enrollment + // produced 263 identical lines because the cache answered nearly every + // request. Uncached outcomes (transient failures) still log every time. + if (devPrepResult.ServedFromCache) + return; + logger.LogWarning( "DevPrep association lookup (shadow) for tenant {TenantId}, session {SessionId}, serial {SerialNumber}: matched={Matched}, transient={Transient}, state={State}, policy={PolicyId}", capturedTenant, capturedSession ?? "", capturedSerial,