mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
bf6850dedc
Same gate the cert-device-binding shadow got in 35a3ed0c, applied to its
sibling. Measured on the live enrollment: 263 identical shadow lines for one
device off a handful of Graph calls, because the cache answered nearly every
request and the log sat outside it.
DeviceAssociationResult.AsCacheHit() copies rather than marking the shared
cached instance, so the first real lookup is never mistaken for a cache hit
and dropped. Transient outcomes are not cached and keep logging every time.
Backend 4633/4633, 0 warnings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
766 lines
38 KiB
C#
766 lines
38 KiB
C#
using System;
|
||
using System.Linq;
|
||
using System.Net;
|
||
using System.Text.RegularExpressions;
|
||
using System.Threading.Tasks;
|
||
using AutopilotMonitor.Functions.Services;
|
||
using AutopilotMonitor.Shared.Models;
|
||
using Microsoft.Azure.Functions.Worker.Http;
|
||
using Microsoft.Extensions.Logging;
|
||
|
||
namespace AutopilotMonitor.Functions.Security
|
||
{
|
||
/// <summary>
|
||
/// Centralized security validation for all API requests
|
||
/// Performs certificate validation, rate limiting, and hardware whitelisting
|
||
/// </summary>
|
||
public class SecurityValidator
|
||
{
|
||
private static readonly Regex GuidRegex = new Regex(
|
||
@"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
|
||
RegexOptions.Compiled);
|
||
|
||
/// <summary>
|
||
/// Validates that a string is a valid GUID format.
|
||
/// Use this to prevent OData filter injection in Table Storage queries.
|
||
/// </summary>
|
||
public static bool IsValidGuid(string? value)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(value))
|
||
return false;
|
||
|
||
return Guid.TryParse(value, out _) && GuidRegex.IsMatch(value);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Validates that a value is a valid GUID and throws if not.
|
||
/// </summary>
|
||
public static void EnsureValidGuid(string? value, string parameterName)
|
||
{
|
||
if (!IsValidGuid(value))
|
||
throw new ArgumentException($"Invalid {parameterName} format. Expected a valid GUID.", parameterName);
|
||
}
|
||
|
||
private readonly TenantConfigurationService _configService;
|
||
private readonly AdminConfigurationService _adminConfigService;
|
||
private readonly RateLimitService _rateLimitService;
|
||
private readonly AutopilotDeviceValidator _autopilotDeviceValidator;
|
||
private readonly CorporateIdentifierValidator _corporateIdentifierValidator;
|
||
private readonly DeviceAssociationValidator? _deviceAssociationValidator;
|
||
private readonly CloudPcDeviceValidator? _cloudPcDeviceValidator;
|
||
private readonly IntuneDeviceBindingValidator? _intuneDeviceBindingValidator;
|
||
private readonly BootstrapSessionService? _bootstrapSessionService;
|
||
private readonly ILogger _logger;
|
||
|
||
public SecurityValidator(
|
||
TenantConfigurationService configService,
|
||
AdminConfigurationService adminConfigService,
|
||
RateLimitService rateLimitService,
|
||
AutopilotDeviceValidator autopilotDeviceValidator,
|
||
CorporateIdentifierValidator corporateIdentifierValidator,
|
||
ILogger logger,
|
||
BootstrapSessionService? bootstrapSessionService = null,
|
||
DeviceAssociationValidator? deviceAssociationValidator = null,
|
||
CloudPcDeviceValidator? cloudPcDeviceValidator = null,
|
||
IntuneDeviceBindingValidator? intuneDeviceBindingValidator = null)
|
||
{
|
||
_configService = configService;
|
||
_adminConfigService = adminConfigService;
|
||
_rateLimitService = rateLimitService;
|
||
_autopilotDeviceValidator = autopilotDeviceValidator;
|
||
_corporateIdentifierValidator = corporateIdentifierValidator;
|
||
_deviceAssociationValidator = deviceAssociationValidator;
|
||
_cloudPcDeviceValidator = cloudPcDeviceValidator;
|
||
_intuneDeviceBindingValidator = intuneDeviceBindingValidator;
|
||
_bootstrapSessionService = bootstrapSessionService;
|
||
_logger = logger;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Resolves the effective device (agent/cert) rate limit for a tenant:
|
||
/// the per-tenant override if set, otherwise the global default raised to the tenant
|
||
/// edition's entitlement floor (Pro: 150/min — see FeatureEntitlementCatalog).
|
||
/// The global AdminConfiguration read is served from a 5-minute in-memory cache.
|
||
/// </summary>
|
||
private async Task<int> ResolveDeviceRateLimitAsync(TenantConfiguration config)
|
||
{
|
||
// The global read is served from a 5-minute in-memory cache (O(1) dictionary hit on the
|
||
// hot path), so we always take it rather than branching on the override.
|
||
var adminConfig = await _adminConfigService.GetConfigurationAsync();
|
||
// Edition resolves purely from the config already in hand — no extra I/O on the
|
||
// agent hot path. Fail-closed by construction (unknown tier → Community → null floor).
|
||
var entitlementFloor = FeatureEntitlementCatalog
|
||
.Get(Services.TenantEntitlementService.ResolveEdition(config, DateTime.UtcNow))
|
||
.DeviceRateLimitPerMinute;
|
||
return RateLimitResolver.ResolveDeviceLimit(
|
||
config.CustomRateLimitRequestsPerMinute,
|
||
adminConfig.GlobalRateLimitRequestsPerMinute,
|
||
entitlementFloor);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Validates request security (certificate, rate limit, hardware whitelist)
|
||
/// </summary>
|
||
/// <param name="req">HTTP request</param>
|
||
/// <param name="tenantId">Tenant ID for configuration lookup</param>
|
||
/// <returns>Security validation result</returns>
|
||
public async Task<SecurityValidationResult> ValidateRequestAsync(HttpRequestData req, string tenantId, string? sessionId = null)
|
||
{
|
||
// 0. Verify tenant is known and not suspended (cheapest gate — runs before cert/rate/hardware)
|
||
var (config, tenantExists) = await _configService.TryGetConfigurationAsync(tenantId);
|
||
|
||
if (!tenantExists)
|
||
{
|
||
_logger.LogWarning("Rejected agent request: unknown tenant {TenantId}", tenantId);
|
||
return new SecurityValidationResult
|
||
{
|
||
IsValid = false,
|
||
StatusCode = HttpStatusCode.Forbidden,
|
||
ErrorMessage = "Tenant not registered",
|
||
Details = "This tenant ID is not registered with the platform."
|
||
};
|
||
}
|
||
|
||
if (config.IsCurrentlyDisabled())
|
||
{
|
||
_logger.LogWarning("Rejected agent request: suspended tenant {TenantId} (reason: {Reason})",
|
||
tenantId, config.DisabledReason);
|
||
return new SecurityValidationResult
|
||
{
|
||
IsValid = false,
|
||
StatusCode = HttpStatusCode.Forbidden,
|
||
ErrorMessage = "Tenant is suspended",
|
||
Details = config.DisabledReason ?? "This tenant has been suspended. Contact support."
|
||
};
|
||
}
|
||
|
||
// 0.5 Bootstrap token gate (pre-MDM auth for OOBE bootstrapped agents)
|
||
// If the agent sends an X-Bootstrap-Token header, validate it and skip all other checks
|
||
// (cert, rate limit, hardware, device validation). This enables agents started before
|
||
// Intune enrollment to authenticate using a time-limited token generated by the admin.
|
||
var bootstrapTokenHeader = req.Headers.Contains("X-Bootstrap-Token")
|
||
? req.Headers.GetValues("X-Bootstrap-Token").FirstOrDefault()
|
||
: null;
|
||
|
||
if (!string.IsNullOrEmpty(bootstrapTokenHeader) && _bootstrapSessionService != null)
|
||
{
|
||
// SECURITY: Bootstrap tokens are always GUIDs. Reject non-GUID values
|
||
// to prevent OData filter injection in the token lookup query.
|
||
if (!IsValidGuid(bootstrapTokenHeader))
|
||
{
|
||
_logger.LogWarning("Rejected agent request: bootstrap token is not a valid GUID format");
|
||
return new SecurityValidationResult
|
||
{
|
||
IsValid = false,
|
||
StatusCode = HttpStatusCode.Unauthorized,
|
||
ErrorMessage = "Invalid bootstrap token format",
|
||
Details = "Bootstrap token must be a valid GUID."
|
||
};
|
||
}
|
||
|
||
var bootstrapSession = await _bootstrapSessionService.ValidateTokenAsync(bootstrapTokenHeader);
|
||
if (bootstrapSession == null)
|
||
{
|
||
_logger.LogWarning("Rejected agent request: invalid or expired bootstrap token");
|
||
return new SecurityValidationResult
|
||
{
|
||
IsValid = false,
|
||
StatusCode = HttpStatusCode.Unauthorized,
|
||
ErrorMessage = "Invalid or expired bootstrap token",
|
||
Details = "Bootstrap token not found, expired, or revoked."
|
||
};
|
||
}
|
||
|
||
// Verify the bootstrap feature is enabled for this tenant (Pro plan or GA flag)
|
||
if (!Services.TenantEntitlementService.IsBootstrapEnabled(config, DateTime.UtcNow))
|
||
{
|
||
_logger.LogWarning("Rejected bootstrap token: feature disabled for tenant {TenantId}", tenantId);
|
||
return new SecurityValidationResult
|
||
{
|
||
IsValid = false,
|
||
StatusCode = HttpStatusCode.Forbidden,
|
||
ErrorMessage = "Bootstrap token feature is not enabled for this tenant"
|
||
};
|
||
}
|
||
|
||
// Verify the token's tenant matches the request tenant
|
||
if (!string.Equals(bootstrapSession.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
_logger.LogWarning("Bootstrap token tenant mismatch: token={TokenTenant}, request={RequestTenant}",
|
||
bootstrapSession.TenantId, tenantId);
|
||
return new SecurityValidationResult
|
||
{
|
||
IsValid = false,
|
||
StatusCode = HttpStatusCode.Forbidden,
|
||
ErrorMessage = "Bootstrap token does not match tenant"
|
||
};
|
||
}
|
||
|
||
// Read hardware headers (informational, not enforced for bootstrap auth)
|
||
var bsManufacturer = req.Headers.Contains("X-Device-Manufacturer")
|
||
? req.Headers.GetValues("X-Device-Manufacturer").FirstOrDefault() : null;
|
||
var bsModel = req.Headers.Contains("X-Device-Model")
|
||
? req.Headers.GetValues("X-Device-Model").FirstOrDefault() : null;
|
||
var bsSerial = req.Headers.Contains("X-Device-SerialNumber")
|
||
? req.Headers.GetValues("X-Device-SerialNumber").FirstOrDefault() : null;
|
||
|
||
_logger.LogInformation("Bootstrap token auth accepted for tenant {TenantId} (code {ShortCode})",
|
||
tenantId, bootstrapSession.ShortCode);
|
||
|
||
// Rate limit check for bootstrap auth (DoS protection)
|
||
var bsRateLimitValue = await ResolveDeviceRateLimitAsync(config);
|
||
var bsRateLimitResult = _rateLimitService.CheckRateLimit(
|
||
$"bootstrap:{bootstrapTokenHeader}",
|
||
bsRateLimitValue
|
||
);
|
||
|
||
if (!bsRateLimitResult.IsAllowed)
|
||
{
|
||
return new SecurityValidationResult
|
||
{
|
||
IsValid = false,
|
||
StatusCode = HttpStatusCode.TooManyRequests,
|
||
ErrorMessage = "Rate limit exceeded",
|
||
RateLimitResult = bsRateLimitResult
|
||
};
|
||
}
|
||
|
||
return new SecurityValidationResult
|
||
{
|
||
IsValid = true,
|
||
IsBootstrapAuth = true,
|
||
BootstrapShortCode = bootstrapSession.ShortCode,
|
||
Manufacturer = bsManufacturer,
|
||
Model = bsModel,
|
||
SerialNumber = bsSerial,
|
||
RateLimitResult = bsRateLimitResult,
|
||
ValidatedBy = ValidatorType.Bootstrap
|
||
};
|
||
}
|
||
|
||
// Security validation is always enforced (no longer configurable per tenant)
|
||
// Hard gate: tenant must enable at least one device validation method before agent traffic is accepted.
|
||
// Global Admins can set AllowInsecureAgentRequests=true in the config row for test tenants.
|
||
if (!config.ValidateAutopilotDevice && !config.ValidateCorporateIdentifier && !config.ValidateCloudPcDevice && !config.AllowInsecureAgentRequests)
|
||
{
|
||
return new SecurityValidationResult
|
||
{
|
||
IsValid = false,
|
||
StatusCode = HttpStatusCode.Forbidden,
|
||
ErrorMessage = "Device validation is required",
|
||
Details = "Enable 'Autopilot Device Validation', 'Corporate Identifier Validation' or 'Windows 365 Cloud PC Validation' in Configuration before using the agent ingestion endpoints."
|
||
};
|
||
}
|
||
|
||
// 1. Validate client certificate
|
||
// Production: Azure App Service mTLS forwards client cert in X-ARR-ClientCert header.
|
||
// Local dev fallback: X-Client-Certificate header for testing with Azure Functions Core Tools
|
||
// (which does not support mTLS). This fallback never triggers in production because
|
||
// requests without a TLS client cert are rejected by Azure before reaching the function.
|
||
var certHeader = req.Headers.Contains("X-ARR-ClientCert")
|
||
? req.Headers.GetValues("X-ARR-ClientCert").FirstOrDefault()
|
||
: req.Headers.Contains("X-Client-Certificate")
|
||
? req.Headers.GetValues("X-Client-Certificate").FirstOrDefault()
|
||
: null;
|
||
|
||
var certValidation = CertificateValidator.ValidateCertificate(certHeader, _logger);
|
||
if (!certValidation.IsValid)
|
||
{
|
||
LogRequestRejection("cert", tenantId, req, sessionId,
|
||
extraReason: certValidation.ErrorMessage,
|
||
thumbprint: certValidation.Thumbprint);
|
||
return new SecurityValidationResult
|
||
{
|
||
IsValid = false,
|
||
StatusCode = HttpStatusCode.Unauthorized,
|
||
ErrorMessage = "Invalid or missing client certificate",
|
||
Details = certValidation.ErrorMessage
|
||
};
|
||
}
|
||
|
||
// 1b. CERT-TENANT-BINDING-SHADOW — observe only, never decide.
|
||
// The cert chain is pinned to Microsoft's Intune roots, which every Intune tenant shares,
|
||
// so a valid cert alone does not prove the caller belongs to THIS tenant. Stage 1 measures
|
||
// how reliably the certificate's own tenant stamp matches before stage 2 enforces it.
|
||
ObserveCertTenantBinding(certValidation, tenantId, req, sessionId);
|
||
|
||
// 2. Check rate limit (DoS protection)
|
||
// Effective limit = per-tenant override if set, otherwise the global default.
|
||
var rateLimitValue = await ResolveDeviceRateLimitAsync(config);
|
||
|
||
var rateLimitResult = _rateLimitService.CheckRateLimit(
|
||
certValidation.Thumbprint!,
|
||
rateLimitValue
|
||
);
|
||
|
||
if (!rateLimitResult.IsAllowed)
|
||
{
|
||
return new SecurityValidationResult
|
||
{
|
||
IsValid = false,
|
||
StatusCode = HttpStatusCode.TooManyRequests,
|
||
ErrorMessage = "Rate limit exceeded",
|
||
RateLimitResult = rateLimitResult
|
||
};
|
||
}
|
||
|
||
// 3. Validate hardware whitelist
|
||
var manufacturer = req.Headers.Contains("X-Device-Manufacturer")
|
||
? req.Headers.GetValues("X-Device-Manufacturer").FirstOrDefault()
|
||
: null;
|
||
|
||
var model = req.Headers.Contains("X-Device-Model")
|
||
? req.Headers.GetValues("X-Device-Model").FirstOrDefault()
|
||
: null;
|
||
|
||
var hardwareValidation = HardwareWhitelistValidator.ValidateHardware(
|
||
manufacturer,
|
||
model,
|
||
config.GetManufacturerWhitelist(),
|
||
config.GetModelWhitelist(),
|
||
_logger
|
||
);
|
||
|
||
if (!hardwareValidation.IsValid)
|
||
{
|
||
LogRequestRejection("hardware", tenantId, req, sessionId,
|
||
extraReason: hardwareValidation.ErrorMessage,
|
||
thumbprint: certValidation.Thumbprint);
|
||
return new SecurityValidationResult
|
||
{
|
||
IsValid = false,
|
||
StatusCode = HttpStatusCode.Forbidden,
|
||
ErrorMessage = "Hardware not authorized",
|
||
Details = hardwareValidation.ErrorMessage
|
||
};
|
||
}
|
||
|
||
// 4. Validate device registration (Autopilot and/or Corporate Identifier)
|
||
string? serialNumber = req.Headers.Contains("X-Device-SerialNumber")
|
||
? req.Headers.GetValues("X-Device-SerialNumber").FirstOrDefault()
|
||
: null;
|
||
string? autopilotDeviceId = null;
|
||
bool deviceValidated = false;
|
||
bool deviceValidationTransient = false;
|
||
string? deviceValidationError = null;
|
||
ValidatorType validatedBy = ValidatorType.Unknown;
|
||
|
||
if (config.ValidateAutopilotDevice)
|
||
{
|
||
var autopilotResult = await _autopilotDeviceValidator.ValidateAutopilotDeviceAsync(tenantId, serialNumber, sessionId);
|
||
if (autopilotResult.IsValid)
|
||
{
|
||
deviceValidated = true;
|
||
autopilotDeviceId = autopilotResult.AutopilotDeviceId;
|
||
validatedBy = ValidatorType.AutopilotV1;
|
||
}
|
||
else
|
||
{
|
||
deviceValidationError = autopilotResult.ErrorMessage;
|
||
deviceValidationTransient = autopilotResult.IsTransient;
|
||
}
|
||
}
|
||
|
||
if (!deviceValidated && config.ValidateCorporateIdentifier)
|
||
{
|
||
var corpResult = await _corporateIdentifierValidator.ValidateAsync(tenantId, manufacturer, model, serialNumber, sessionId);
|
||
if (corpResult.IsValid)
|
||
{
|
||
deviceValidated = true;
|
||
validatedBy = ValidatorType.CorporateIdentifier;
|
||
}
|
||
else
|
||
{
|
||
// Accumulate instead of overwrite: with several validators enabled, only the
|
||
// LAST failure used to surface in the 403 Details / rejection log, hiding why
|
||
// the earlier (usually more relevant) lookups missed. Transient is sticky-OR:
|
||
// if ANY validator failed transiently the device might actually be authorized,
|
||
// so the caller must return 503 Retry-After rather than a definitive 403.
|
||
deviceValidationError = CombineValidationErrors(deviceValidationError, corpResult.ErrorMessage);
|
||
deviceValidationTransient |= corpResult.IsTransient;
|
||
}
|
||
}
|
||
|
||
// W365 fallback: Cloud PCs are structurally never Autopilot-registered, so the serial
|
||
// lookups above always miss for them. Identity here comes from the chain-validated
|
||
// client certificate, NOT from spoofable headers: the Subject CN carries the Intune
|
||
// device id, and only a Windows-365-service-provisioned machine has a cloudPC object
|
||
// with that managedDeviceId — a regular Intune-enrolled (non-Autopilot) device still
|
||
// ends in the 403 below.
|
||
if (!deviceValidated && config.ValidateCloudPcDevice && _cloudPcDeviceValidator != null)
|
||
{
|
||
TryGetIntuneDeviceIdFromCertSubject(certValidation.Subject, out var intuneDeviceId);
|
||
var cloudPcResult = await _cloudPcDeviceValidator.ValidateCloudPcAsync(tenantId, intuneDeviceId, sessionId);
|
||
if (cloudPcResult.IsValid)
|
||
{
|
||
deviceValidated = true;
|
||
validatedBy = ValidatorType.CloudPc;
|
||
}
|
||
else
|
||
{
|
||
deviceValidationError = CombineValidationErrors(deviceValidationError, cloudPcResult.ErrorMessage);
|
||
deviceValidationTransient |= cloudPcResult.IsTransient;
|
||
}
|
||
}
|
||
|
||
if ((config.ValidateAutopilotDevice || config.ValidateCorporateIdentifier || config.ValidateCloudPcDevice) && !deviceValidated)
|
||
{
|
||
// Transient failures (Graph API down, token issues) → 503 Retry-After so agent retries
|
||
// Definitive failures (device not registered) → 403 Forbidden
|
||
if (deviceValidationTransient)
|
||
{
|
||
_logger.LogWarning(
|
||
"Device validation transient failure for tenant {TenantId}, serial {SerialNumber}. Returning 503 Retry-After.",
|
||
tenantId, serialNumber);
|
||
|
||
return new SecurityValidationResult
|
||
{
|
||
IsValid = false,
|
||
StatusCode = HttpStatusCode.ServiceUnavailable,
|
||
ErrorMessage = "Device validation temporarily unavailable",
|
||
Details = deviceValidationError,
|
||
RetryAfterSeconds = 30
|
||
};
|
||
}
|
||
|
||
LogRequestRejection("device", tenantId, req, sessionId,
|
||
extraReason: deviceValidationError,
|
||
thumbprint: certValidation.Thumbprint);
|
||
return new SecurityValidationResult
|
||
{
|
||
IsValid = false,
|
||
StatusCode = HttpStatusCode.Forbidden,
|
||
ErrorMessage = "Device not registered",
|
||
Details = deviceValidationError
|
||
};
|
||
}
|
||
|
||
// 5. DevPrep "Device association" lookup (shadow mode — does NOT gate enrollment).
|
||
// Fire-and-forget: kicked off as a detached Task so it never adds latency to the
|
||
// RegisterSession/Ingest response. Graph round-trips for tenantAssociatedDevices can
|
||
// take 200ms–30s (504 timeouts observed), and shadow mode must never block the agent.
|
||
// Result is logged at Warning level so it surfaces in App Insights traces.
|
||
if (config.ValidateDeviceAssociation && _deviceAssociationValidator != null && !string.IsNullOrEmpty(serialNumber))
|
||
{
|
||
var validator = _deviceAssociationValidator;
|
||
var capturedTenant = tenantId;
|
||
var capturedSerial = serialNumber;
|
||
var capturedSession = sessionId;
|
||
var logger = _logger;
|
||
|
||
_ = Task.Run(async () =>
|
||
{
|
||
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 ?? "<none>", capturedSerial,
|
||
devPrepResult.IsValid, devPrepResult.IsTransient,
|
||
devPrepResult.AssociationState ?? "<none>", devPrepResult.DevicePreparationPolicyId ?? "<none>");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
logger.LogWarning(ex, "DevPrep association lookup (shadow) failed for tenant {TenantId}, serial {SerialNumber} — ignored.", capturedTenant, capturedSerial);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 6. CERT-DEVICE-BINDING-SHADOW - cert-to-device binding (Global-Admin-only preview).
|
||
// Stage 1 (CertTenantBinding) proves the certificate was issued to THIS tenant; this
|
||
// proves the specific device is one the tenant actually enrolled, so a certificate
|
||
// lifted from a decommissioned machine stops resolving. Observation only: it never
|
||
// touches deviceValidated or the returned result.
|
||
//
|
||
// Fire-and-forget for the same reason as the DevPrep lookup above - a cold Graph
|
||
// round-trip can take seconds and must never sit in the agent's request path. That
|
||
// rules out the request-row dimension stage 1 uses (the row is written the moment the
|
||
// function returns, before this task finishes), so the outcome is logged at Warning
|
||
// like the DevPrep shadow. Volume is bounded: only Global Admins can enable the
|
||
// toggle. Widening it beyond preview should move to an inline call plus the
|
||
// CertTenantBinding-style request dimension.
|
||
if (config.ValidateIntuneDeviceBinding && _intuneDeviceBindingValidator != null)
|
||
{
|
||
var bindingValidator = _intuneDeviceBindingValidator;
|
||
var bindingTenant = tenantId;
|
||
var bindingSession = sessionId;
|
||
var bindingSubject = certValidation.Subject;
|
||
var bindingThumbprint = certValidation.Thumbprint;
|
||
var bindingLogger = _logger;
|
||
|
||
_ = Task.Run(async () =>
|
||
{
|
||
try
|
||
{
|
||
TryGetIntuneDeviceIdFromCertSubject(bindingSubject, out var certDeviceId);
|
||
var binding = await bindingValidator.ValidateAsync(bindingTenant, certDeviceId, bindingSession);
|
||
|
||
// One line per real Graph lookup, not per request. Measured on the first
|
||
// live enrollment: 136 requests produced exactly ONE Graph call (the 30 min
|
||
// positive cache did its job), so per-request logging repeated the same
|
||
// finding 136 times. Cached repeats add nothing - the age just counts up
|
||
// mechanically from the same enrolledDateTime. Uncached outcomes
|
||
// (Transient, NoDeviceIdInCert) still log every time, which is what we want
|
||
// for anomalies.
|
||
if (binding.ServedFromCache)
|
||
return;
|
||
|
||
// Age of the device object at request time: the discriminator between a
|
||
// genuine enrollment race (object created moments ago, or not yet) and a
|
||
// certificate that never belonged to this tenant.
|
||
var enrolledAgeSeconds = binding.EnrolledDateTime.HasValue
|
||
? (long)Math.Round((DateTimeOffset.UtcNow - binding.EnrolledDateTime.Value).TotalSeconds)
|
||
: -1;
|
||
|
||
bindingLogger.LogWarning(
|
||
"AgentCertDeviceBinding outcome={Outcome} enforced={Enforced} tenant={TenantId} "
|
||
+ "certDeviceId={CertDeviceId} device={DeviceName} enrolledAgeSeconds={EnrolledAgeSeconds} "
|
||
+ "mgmtState={ManagementState} thumbprint={Thumbprint} session={SessionId} detail={Detail}",
|
||
binding.Outcome, false, bindingTenant,
|
||
certDeviceId ?? "n/a", binding.DeviceName ?? "n/a", enrolledAgeSeconds,
|
||
binding.ManagementState ?? "n/a", bindingThumbprint ?? "n/a",
|
||
bindingSession ?? "n/a", binding.ErrorMessage ?? "n/a");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
bindingLogger.LogWarning(ex,
|
||
"AgentCertDeviceBinding (shadow) failed for tenant {TenantId} - ignored.", bindingTenant);
|
||
}
|
||
});
|
||
}
|
||
|
||
// All checks passed
|
||
return new SecurityValidationResult
|
||
{
|
||
IsValid = true,
|
||
CertificateThumbprint = certValidation.Thumbprint,
|
||
Manufacturer = manufacturer,
|
||
Model = model,
|
||
SerialNumber = serialNumber,
|
||
AutopilotDeviceId = autopilotDeviceId,
|
||
RateLimitResult = rateLimitResult,
|
||
ValidatedBy = validatedBy
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// Joins the per-validator failure messages so the 403 Details / rejection log carries
|
||
/// every miss in chain order (Autopilot | CorporateIdentifier | CloudPc), not just the
|
||
/// last validator's.
|
||
/// </summary>
|
||
internal static string? CombineValidationErrors(string? accumulated, string? next)
|
||
{
|
||
if (string.IsNullOrEmpty(next)) return accumulated;
|
||
if (string.IsNullOrEmpty(accumulated)) return next;
|
||
return accumulated + " | " + next;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Extracts the Intune device id from an MDM client certificate subject. Certs issued by
|
||
/// the Microsoft Intune MDM Device CA carry the Intune managedDevice id as the CN
|
||
/// (field-verified 2026-08-06 on a W365 Cloud PC). Returns false when the subject has no
|
||
/// CN or the CN is not a canonical GUID — the CloudPc validator then rejects it as
|
||
/// "not a valid Intune device id" without ever touching Graph.
|
||
/// </summary>
|
||
internal static bool TryGetIntuneDeviceIdFromCertSubject(string? subject, out string? intuneDeviceId)
|
||
{
|
||
intuneDeviceId = null;
|
||
if (string.IsNullOrWhiteSpace(subject))
|
||
return false;
|
||
|
||
foreach (var part in subject!.Split(','))
|
||
{
|
||
var trimmed = part.Trim();
|
||
if (!trimmed.StartsWith("CN=", StringComparison.OrdinalIgnoreCase))
|
||
continue;
|
||
|
||
var value = trimmed.Substring(3).Trim();
|
||
if (IsValidGuid(value))
|
||
{
|
||
intuneDeviceId = value.ToLowerInvariant();
|
||
return true;
|
||
}
|
||
return false; // first CN wins; a non-GUID CN is definitive (not an Intune MDM device cert shape)
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/// <summary>
|
||
/// CERT-TENANT-BINDING-SHADOW — stage 1 observability for the client-certificate tenant
|
||
/// binding. Evaluates whether the tenant stamped into the agent's certificate matches the
|
||
/// tenant the request claims, and logs the outcome. It deliberately returns nothing and
|
||
/// changes no state: enforcement is stage 2, after this telemetry shows the miss rate.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Two carriers, because they answer different questions and have different costs:
|
||
/// <list type="bullet">
|
||
/// <item><description>Every outcome is stamped onto the request row via
|
||
/// <see cref="CertTenantBinding.RequestItemKey"/>. That row already exists per request and is
|
||
/// unsampled, so the denominator ("how many requests matched") costs no extra telemetry.
|
||
/// A trace line could not do this: worker-side <c>LogInformation</c> never reaches App
|
||
/// Insights, so the bulk "Match" case would simply be invisible.</description></item>
|
||
/// <item><description>Outcomes that need an enforcement decision (mismatch, missing or
|
||
/// undecodable extension) are additionally logged at Warning with the full context —
|
||
/// thumbprint, cert tenant, session — because each one is individually actionable.</description></item>
|
||
/// </list>
|
||
/// </remarks>
|
||
private void ObserveCertTenantBinding(
|
||
CertificateValidationResult certValidation,
|
||
string tenantId,
|
||
HttpRequestData req,
|
||
string? sessionId)
|
||
{
|
||
try
|
||
{
|
||
var outcome = CertTenantBinding.Evaluate(
|
||
certValidation.CertTenantId,
|
||
certValidation.CertTenantIdStatus,
|
||
tenantId);
|
||
|
||
// Denominator: rides along on the request row for every outcome, including Match.
|
||
var items = req.FunctionContext?.Items;
|
||
if (items != null)
|
||
items[CertTenantBinding.RequestItemKey] = outcome;
|
||
|
||
// Everything below is the detail record for outcomes someone has to act on.
|
||
if (outcome == CertTenantBinding.Outcome.Match)
|
||
return;
|
||
|
||
var agentVersion = req.Headers.Contains("X-Agent-Version")
|
||
? req.Headers.GetValues("X-Agent-Version").FirstOrDefault() ?? "n/a"
|
||
: "n/a";
|
||
|
||
// Only the cert's tenant is logged, never the raw certificate. On a mismatch this is
|
||
// the attacker-controlled side of the comparison and the single most useful field.
|
||
const string template =
|
||
"AgentCertTenantBinding outcome={Outcome} enforced={Enforced} wouldReject={WouldReject} "
|
||
+ "tenant={TenantId} certTenant={CertTenantId} status={Status} thumbprint={Thumbprint} "
|
||
+ "session={SessionId} ver={AgentVersion}";
|
||
|
||
var certTenant = certValidation.CertTenantId?.ToString() ?? "n/a";
|
||
var wouldReject = CertTenantBinding.WouldRejectUnderEnforcement(outcome);
|
||
|
||
_logger.LogWarning(template,
|
||
outcome, false, wouldReject, tenantId, certTenant, certValidation.CertTenantIdStatus,
|
||
certValidation.Thumbprint ?? "n/a", sessionId ?? "n/a", agentVersion);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// Shadow-mode observability must never be able to fail a request that would
|
||
// otherwise have been accepted.
|
||
_logger.LogWarning(ex, "AgentCertTenantBinding observation failed for tenant {TenantId}", tenantId);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Emits a structured warning with request-side context (path, hardware headers, agent
|
||
/// version) at every fail-fast rejection point. Pairs with the cert-side warnings emitted
|
||
/// inside <see cref="CertificateValidator"/> via shared <c>thumbprint</c> + correlates 1:1
|
||
/// with <c>AgentDistressReport</c> events on <c>TenantId</c>+<c>SerialNumber</c>+timestamp.
|
||
/// </summary>
|
||
private void LogRequestRejection(string stage, string tenantId, HttpRequestData req, string? sessionId, string? extraReason, string? thumbprint)
|
||
{
|
||
string GetHeader(string name) => req.Headers.Contains(name)
|
||
? (req.Headers.GetValues(name).FirstOrDefault() ?? "n/a")
|
||
: "n/a";
|
||
|
||
var path = req.Url?.AbsolutePath ?? "n/a";
|
||
var manufacturer = GetHeader("X-Device-Manufacturer");
|
||
var model = GetHeader("X-Device-Model");
|
||
var serial = GetHeader("X-Device-SerialNumber");
|
||
var agentVersion = GetHeader("X-Agent-Version");
|
||
|
||
_logger.LogWarning(
|
||
"AgentRequestRejected stage={Stage} tenant={TenantId} path={Path} session={SessionId} thumbprint={Thumbprint} mfr={Manufacturer} model={Model} serial={SerialNumber} ver={AgentVersion} reason={Reason}",
|
||
stage, tenantId, path, sessionId ?? "n/a", thumbprint ?? "n/a", manufacturer, model, serial, agentVersion, extraReason ?? "n/a");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Result of security validation
|
||
/// </summary>
|
||
public class SecurityValidationResult
|
||
{
|
||
/// <summary>
|
||
/// Whether the request passed all security checks
|
||
/// </summary>
|
||
public bool IsValid { get; set; }
|
||
|
||
/// <summary>
|
||
/// HTTP status code if validation failed
|
||
/// </summary>
|
||
public HttpStatusCode StatusCode { get; set; }
|
||
|
||
/// <summary>
|
||
/// Error message if validation failed
|
||
/// </summary>
|
||
public string? ErrorMessage { get; set; }
|
||
|
||
/// <summary>
|
||
/// Detailed error information
|
||
/// </summary>
|
||
public string? Details { get; set; }
|
||
|
||
/// <summary>
|
||
/// Certificate thumbprint (if validation succeeded)
|
||
/// </summary>
|
||
public string? CertificateThumbprint { get; set; }
|
||
|
||
/// <summary>
|
||
/// Device manufacturer (if validation succeeded)
|
||
/// </summary>
|
||
public string? Manufacturer { get; set; }
|
||
|
||
/// <summary>
|
||
/// Device model (if validation succeeded)
|
||
/// </summary>
|
||
public string? Model { get; set; }
|
||
|
||
/// <summary>
|
||
/// Device serial number (if Autopilot device validation is enabled and succeeded)
|
||
/// </summary>
|
||
public string? SerialNumber { get; set; }
|
||
|
||
/// <summary>
|
||
/// Autopilot device ID from Intune (if Autopilot device validation is enabled and succeeded)
|
||
/// </summary>
|
||
public string? AutopilotDeviceId { get; set; }
|
||
|
||
/// <summary>
|
||
/// Rate limit result (if validation succeeded)
|
||
/// </summary>
|
||
public RateLimitResult? RateLimitResult { get; set; }
|
||
|
||
/// <summary>
|
||
/// Suggested retry delay in seconds (set when StatusCode is 503 ServiceUnavailable)
|
||
/// </summary>
|
||
public int? RetryAfterSeconds { get; set; }
|
||
|
||
/// <summary>
|
||
/// Whether this request was authenticated via a bootstrap token (pre-MDM OOBE auth)
|
||
/// </summary>
|
||
public bool IsBootstrapAuth { get; set; }
|
||
|
||
/// <summary>
|
||
/// Bootstrap session short code (only set when IsBootstrapAuth is true)
|
||
/// </summary>
|
||
public string? BootstrapShortCode { get; set; }
|
||
|
||
/// <summary>
|
||
/// Which validator ultimately authorized this device. Copied into the
|
||
/// RegisterSession response so the agent can reconcile the enrollment-type
|
||
/// verdict with its registry-based detection.
|
||
/// </summary>
|
||
public ValidatorType ValidatedBy { get; set; } = ValidatorType.Unknown;
|
||
}
|
||
}
|