mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
1c5f69a9fe
* w365: CloudPc device validator — cert-CN-bound fallback for Cloud PCs Windows 365 Cloud PCs are never Autopilot-registered, so the serial lookup (and the Corporate Identifier fallback) structurally reject them and Cloud PC first-connect enrollment could not be monitored. New stage-3 fallback in the device-validation chain: extract the Intune device id from the chain-validated MDM client certificate's Subject CN (field-verified: CN == managedDevice id == cloudPC.managedDeviceId) and require a matching cloudPC object in the claimed tenant (Graph v1.0 virtualEndpoint/cloudPCs, $filter=managedDeviceId eq). Only service-provisioned Cloud PCs have such an object — a regular enrolled non-Autopilot device still ends in the 403, and local W365 markers are never consulted for auth. - Shared: ValidatorType.CloudPc = 5, TenantConfiguration.ValidateCloudPcDevice (default false, tenant-admin writable), GraphAppPermissions.CloudPCReadAll, GraphFeatureCatalog feature W365CloudPcValidation -> CloudPC.Read.All - Backend: CloudPcDeviceValidator (AutopilotDeviceValidator sibling contract: 30/5 min cache, 2 attempts, transient->503; GUID gate before the OData filter; exact-match guard; Graph 403 = definitive "grant missing", not transient), SecurityValidator CN extraction + fallback stage + hard gate, wiring through the 5 agent endpoints, TableConfigRepository persistence - Consent script: W365CloudPcValidation in catalog/ValidateSet/help; NEW GraphFeatureCatalogSyncTests pins C# catalog <-> grant script (previously comment-only lock-step) - Bootstrap dev script v2.3-dev.3: guard 4 (uptime window) exempted while the Cloud-PC relax is active — Cloud PCs run headless for days before first connect; the fresh-profile window is the time anchor there (+ Pester tests) - Web: validation toggle + context plumbing, ValidatedBy "CloudPc" label, privacy-page validation claim generalized (Last updated bumped) - Docs: OKF concept doc backend/cloudpc-device-validation.md + index + log Verified: web tsc clean, ESLint 0 errors (99 pre-existing warnings = ratchet cap, none added). .NET build/tests and Pester run in CI — SDK download and pwsh are unavailable in this sandbox's network policy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RqEFrcAHhWgCvgJgHGzMuv * w365: agent-side IsCloudPc flag + engine expectation "no Device-ESP phase" Companion to the CloudPc validator: the agent now classifies Windows 365 Cloud PCs locally with the bootstrap's field-verified marker AND (HKLM\SOFTWARE\Microsoft\Windows365 key + installed CloudManagedDesktopExtension service, probed via its Services registry key so net48 needs no System.ServiceProcess). SKIP-safe: any error resolves false. Never an auth input - backend admission stays exclusively the cert-CN-bound Graph lookup (ValidatedBy=CloudPc); the two signals are independent and may disagree. Two transport rails: - Session metadata: SessionRegistration.IsCloudPc -> Sessions table (sticky-true OR across re-registrations, IsSelfDeployingProfile pattern) -> SessionsIndex full mirror -> SessionSummary -> new isCloudPc search filter end to end (SearchSessions + QueryRawSessions + pagination fingerprint + all three in-memory predicates; MCP search_sessions / query_raw_sessions params + summary projection; web type, Cloud PC pill, SessionInfoCard enrollment label, CSV export appended column). - Decision engine: isCloudPc rides the EnrollmentFactsObserved payload, recorded set-once for BOTH values in EnrollmentScenarioObservations.CloudPc and annotates the scenario profile reason cloud_pc_first_connect:no_device_esp_expected - the engine-recorded expectation that Device-ESP already ran headless at provisioning and the session starts at Account Setup. Deliberately reason+observation only: no completion arm depends on DeviceSetup, Mode stays AccountSetup/IME- classified (Classic), EspConfig keeps configured-FirstSync semantics. Positive marker surfaces as cloud_pc_marker in the audit-trail census. Updater idempotency: the cloudPcAnnotates gate keeps the generic v1 reason branch from re-claiming the slot on repeat posts (would bump EvidenceOrdinal every post). Additive-nullable observation - no state schema bump (v4 convention), serializer roundtrip pinned. Tests: CloudPcDetector resolver (marker AND, throwing probe), registration body, facts-signal payload shape, updater annotation/idempotency/ self-deploying interplay, reducer observation both values + census fold, serializer roundtrip, index projection superset, sticky-true both directions. Suites: DecisionCore 500, Functions 3941, Agent V2 2154, vitest 632 - all green; tsc web+MCP clean; ESLint 0 errors (cap 99). Docs: cloudpc-device-validation.md follow-up section replaced with the shipped mechanism + log entry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
80 lines
3.3 KiB
C#
80 lines
3.3 KiB
C#
using System.Text.RegularExpressions;
|
|
using AutopilotMonitor.Shared.Models.Graph;
|
|
|
|
namespace AutopilotMonitor.Functions.Tests;
|
|
|
|
/// <summary>
|
|
/// Guard: the customer-side grant script (scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1)
|
|
/// duplicates the feature→permission catalog from <see cref="GraphFeatureCatalog"/> — in its
|
|
/// <c>$FeatureCatalog</c> hashtable AND in the <c>[ValidateSet(...)]</c> of <c>-Features</c>.
|
|
/// The sync was previously comment-only ("Keep in lock-step with …") and had no enforcement;
|
|
/// this test fails the build when either side drifts.
|
|
/// </summary>
|
|
public class GraphFeatureCatalogSyncTests
|
|
{
|
|
[Fact]
|
|
public void GrantScript_FeatureCatalog_MatchesSharedCatalog()
|
|
{
|
|
var script = ReadGrantScript();
|
|
|
|
// Parse: $FeatureCatalog = @{ 'Name' = @('Perm', ...) ... }
|
|
var blockMatch = Regex.Match(script, @"\$FeatureCatalog\s*=\s*@\{(?<body>[^}]*)\}", RegexOptions.Singleline);
|
|
Assert.True(blockMatch.Success, "Could not locate the $FeatureCatalog hashtable in the grant script.");
|
|
|
|
var scriptCatalog = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (Match entry in Regex.Matches(blockMatch.Groups["body"].Value, @"'(?<feature>[^']+)'\s*=\s*@\((?<perms>[^)]*)\)"))
|
|
{
|
|
var perms = Regex.Matches(entry.Groups["perms"].Value, @"'(?<p>[^']+)'")
|
|
.Select(m => m.Groups["p"].Value)
|
|
.ToArray();
|
|
scriptCatalog[entry.Groups["feature"].Value] = perms;
|
|
}
|
|
|
|
Assert.Equal(
|
|
GraphFeatureCatalog.Features.OrderBy(f => f, StringComparer.OrdinalIgnoreCase),
|
|
scriptCatalog.Keys.OrderBy(f => f, StringComparer.OrdinalIgnoreCase));
|
|
|
|
foreach (var feature in GraphFeatureCatalog.Features)
|
|
{
|
|
Assert.Equal(
|
|
GraphFeatureCatalog.RequiredPermissions(feature).OrderBy(p => p, StringComparer.OrdinalIgnoreCase),
|
|
scriptCatalog[feature].OrderBy(p => p, StringComparer.OrdinalIgnoreCase));
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void GrantScript_ValidateSet_MatchesSharedCatalog()
|
|
{
|
|
var script = ReadGrantScript();
|
|
|
|
var vsMatch = Regex.Match(script, @"\[ValidateSet\((?<body>[^\)]*)\)\]");
|
|
Assert.True(vsMatch.Success, "Could not locate the [ValidateSet(...)] of -Features in the grant script.");
|
|
|
|
var scriptFeatures = Regex.Matches(vsMatch.Groups["body"].Value, @"'(?<f>[^']+)'")
|
|
.Select(m => m.Groups["f"].Value)
|
|
.ToArray();
|
|
|
|
Assert.Equal(
|
|
GraphFeatureCatalog.Features.OrderBy(f => f, StringComparer.OrdinalIgnoreCase),
|
|
scriptFeatures.OrderBy(f => f, StringComparer.OrdinalIgnoreCase));
|
|
}
|
|
|
|
private static string ReadGrantScript()
|
|
{
|
|
var path = Path.Combine(FindRepoRoot(), "scripts", "CustomerSetup", "Grant-AutopilotMonitorAddOn.ps1");
|
|
Assert.True(File.Exists(path), $"Grant script not found at {path}");
|
|
return File.ReadAllText(path);
|
|
}
|
|
|
|
private static string FindRepoRoot()
|
|
{
|
|
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
|
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
|
|
{
|
|
dir = dir.Parent;
|
|
}
|
|
Assert.NotNull(dir);
|
|
return dir!.FullName;
|
|
}
|
|
}
|