Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsFunction.cs
T
Oliver Kieselbach 1c5f69a9fe w365: CloudPc device validator — cert-CN-bound fallback for Cloud PCs (#127)
* 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>
2026-08-07 12:31:08 +02:00

233 lines
11 KiB
C#

using System.Net;
using System.Web;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Pagination;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Pagination;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Sessions;
public class SearchSessionsFunction
{
private readonly ILogger<SearchSessionsFunction> _logger;
private readonly ISessionRepository _sessionRepo;
public SearchSessionsFunction(ILogger<SearchSessionsFunction> logger, ISessionRepository sessionRepo)
{
_logger = logger;
_sessionRepo = sessionRepo;
}
[Function("SearchSessions")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "search/sessions")] HttpRequestData req)
=> await HandleAsync(req, isTenantScoped: true);
[Function("SearchSessionsGlobal")]
public async Task<HttpResponseData> RunGlobal(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/search/sessions")] HttpRequestData req)
=> await HandleAsync(req, isTenantScoped: false);
private async Task<HttpResponseData> HandleAsync(HttpRequestData req, bool isTenantScoped)
{
try
{
var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
// The endpoint binding tenantId — for tenant-scoped routes the JWT
// is authoritative. For global routes the JWT just identifies the
// caller; tenantId in the query is a filter, not authorization.
string? tenantId;
string callerTenantId = TenantHelper.GetTenantId(req);
string? filterTenantId = null;
string scope;
string basePath;
if (isTenantScoped)
{
tenantId = callerTenantId;
scope = "search:tenant";
basePath = "/api/search/sessions";
}
else
{
filterTenantId = query["tenantId"];
tenantId = filterTenantId;
scope = "search:global";
basePath = "/api/global/search/sessions";
}
var filter = BuildFilter(query);
var pagination = SearchSessionsPagination.ParsePagination(query);
if (pagination.Error != null)
{
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
await bad.WriteAsJsonAsync(new { success = false, message = pagination.Error });
return bad;
}
string? azureToken = null;
if (pagination.Continuation != null)
{
if (!SearchSessionsPagination.TryAcceptContinuation(
pagination.Continuation, scope, callerTenantId, filterTenantId, filter,
out azureToken, out var rejectReason))
{
_logger.LogWarning("SearchSessions: continuation rejected ({Reason})", rejectReason);
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
await bad.WriteAsJsonAsync(new
{
success = false,
message = $"Invalid continuation token ({rejectReason}). Restart pagination from the first page.",
});
return bad;
}
}
var page = await _sessionRepo.SearchSessionsPageAsync(
tenantId, filter, pagination.PageSize, azureToken);
// Optional field projection — when the LLM only needs a few columns
// (e.g. for counting / aggregation), this prunes the wire payload by
// 5-20x and avoids the client-side response cap that fat
// SessionSummary objects routinely trip.
var fieldsParam = query["fields"];
object sessionsPayload = !string.IsNullOrEmpty(fieldsParam)
? ProjectSessionFields(page.Items, fieldsParam!)
: page.Items;
string? nextLink = null;
if (!string.IsNullOrEmpty(page.NextRawToken))
{
var fp = SearchSessionsPagination.Fingerprint(scope, callerTenantId, filterTenantId, filter);
var wireToken = ContinuationToken.Encode(page.NextRawToken!, callerTenantId, fp);
nextLink = SearchSessionsPagination.BuildNextLink(
basePath, pagination.PageSize, wireToken, query);
}
return await req.OkAsync(new
{
success = true,
count = page.Items.Count,
sessions = sessionsPayload,
nextLink,
});
}
catch (Exception ex)
{
return await req.InternalServerErrorAsync(_logger, ex, "Search sessions");
}
}
private static SessionSearchFilter BuildFilter(System.Collections.Specialized.NameValueCollection query)
{
var filter = new SessionSearchFilter
{
Status = query["status"],
SerialNumber = query["serialNumber"],
DeviceName = query["deviceName"],
Manufacturer = query["manufacturer"],
Model = query["model"],
OsBuild = query["osBuild"],
EnrollmentType = query["enrollmentType"],
GeoCountry = query["geoCountry"],
AgentVersion = query["agentVersion"],
ImeAgentVersion = query["imeAgentVersion"],
AgentVersionPrefix = query["agentVersionPrefix"],
ImeAgentVersionPrefix = query["imeAgentVersionPrefix"],
ConnectionType = query["connectionType"],
// Limit field is a no-op in the paged path — pagination drives count.
};
if (bool.TryParse(query["isPreProvisioned"], out var ipp)) filter.IsPreProvisioned = ipp;
if (bool.TryParse(query["isHybridJoin"], out var ihj)) filter.IsHybridJoin = ihj;
if (bool.TryParse(query["isSelfDeployingProfile"], out var isdp)) filter.IsSelfDeployingProfile = isdp;
if (bool.TryParse(query["isCloudPc"], out var icp)) filter.IsCloudPc = icp;
if (DateTime.TryParse(query["startedAfter"], out var sa)) filter.StartedAfter = sa;
if (DateTime.TryParse(query["startedBefore"], out var sb)) filter.StartedBefore = sb;
if (int.TryParse(query["rebootCountMin"], out var rcMin)) filter.RebootCountMin = rcMin;
if (int.TryParse(query["rebootCountMax"], out var rcMax)) filter.RebootCountMax = rcMax;
// Dynamic device property filters: any query param starting with "prop."
var deviceProperties = new Dictionary<string, string>();
foreach (string? key in query.AllKeys)
{
if (key != null && key.StartsWith("prop.", StringComparison.OrdinalIgnoreCase))
{
var propName = key.Substring(5);
var value = query[key];
if (!string.IsNullOrEmpty(value))
deviceProperties[propName] = value;
}
}
if (deviceProperties.Count > 0)
filter.DeviceProperties = deviceProperties;
return filter;
}
/// <summary>
/// Projects SessionSummary objects down to the comma-separated field set.
/// Mirrors the projection table from QueryRawSessionsFunction — kept in sync
/// so the same field names work on both endpoints. If no field name matches,
/// returns a sensible default subset so callers passing typos still get
/// useful data instead of empty objects.
/// </summary>
private static List<Dictionary<string, object?>> ProjectSessionFields(
IReadOnlyList<SessionSummary> sessions, string fieldsParam)
{
var fields = new HashSet<string>(
fieldsParam.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
StringComparer.OrdinalIgnoreCase);
var projected = new List<Dictionary<string, object?>>(sessions.Count);
foreach (var s in sessions)
{
var dict = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
if (fields.Contains("sessionId")) dict["sessionId"] = s.SessionId;
if (fields.Contains("tenantId")) dict["tenantId"] = s.TenantId;
if (fields.Contains("status")) dict["status"] = s.Status.ToString();
if (fields.Contains("serialNumber")) dict["serialNumber"] = s.SerialNumber;
if (fields.Contains("manufacturer")) dict["manufacturer"] = s.Manufacturer;
if (fields.Contains("model")) dict["model"] = s.Model;
if (fields.Contains("deviceName")) dict["deviceName"] = s.DeviceName;
if (fields.Contains("osBuild")) dict["osBuild"] = s.OsBuild;
if (fields.Contains("osName")) dict["osName"] = s.OsName;
if (fields.Contains("startedAt")) dict["startedAt"] = s.StartedAt;
if (fields.Contains("completedAt")) dict["completedAt"] = s.CompletedAt;
if (fields.Contains("durationSeconds")) dict["durationSeconds"] = s.DurationSeconds;
if (fields.Contains("currentPhase")) dict["currentPhase"] = s.CurrentPhase;
if (fields.Contains("failureReason")) dict["failureReason"] = s.FailureReason;
if (fields.Contains("eventCount")) dict["eventCount"] = s.EventCount;
if (fields.Contains("enrollmentType")) dict["enrollmentType"] = s.EnrollmentType;
if (fields.Contains("isPreProvisioned")) dict["isPreProvisioned"] = s.IsPreProvisioned;
if (fields.Contains("isUserDriven")) dict["isUserDriven"] = s.IsUserDriven;
if (fields.Contains("isHybridJoin")) dict["isHybridJoin"] = s.IsHybridJoin;
if (fields.Contains("isSelfDeployingProfile")) dict["isSelfDeployingProfile"] = s.IsSelfDeployingProfile;
if (fields.Contains("isCloudPc")) dict["isCloudPc"] = s.IsCloudPc;
if (fields.Contains("agentVersion")) dict["agentVersion"] = s.AgentVersion;
if (fields.Contains("imeAgentVersion")) dict["imeAgentVersion"] = s.ImeAgentVersion;
if (fields.Contains("geoCountry")) dict["geoCountry"] = s.GeoCountry;
if (fields.Contains("rebootCount")) dict["rebootCount"] = s.RebootCount;
if (fields.Contains("avgApiLatencyMs")) dict["avgApiLatencyMs"] = s.AvgApiLatencyMs;
if (fields.Contains("apiRequestCount")) dict["apiRequestCount"] = s.ApiRequestCount;
if (fields.Contains("connectionType")) dict["connectionType"] = s.ConnectionType;
if (dict.Count == 0)
{
// No field name matched — fall back to a lean default set.
dict["sessionId"] = s.SessionId;
dict["tenantId"] = s.TenantId;
dict["status"] = s.Status.ToString();
dict["startedAt"] = s.StartedAt;
}
projected.Add(dict);
}
return projected;
}
}