diff --git a/scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1 b/scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1
index 7809d9a8..b909139a 100644
--- a/scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1
+++ b/scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1
@@ -29,10 +29,9 @@
Cloud PCs are never Autopilot-registered, so this enables agent monitoring
of Cloud PC first-connect enrollment)
- IntuneDeviceBinding -> DeviceManagementManagedDevices.Read.All
- (cert-to-device binding: verifies that the Intune device id carried in the
- agent's client certificate is a device THIS tenant actually enrolled --
- currently a Global-Admin-only preview that records telemetry and does not
- block enrollment)
+ (Intune Enrollment Validation: accepts devices enrolled in THIS tenant's
+ Intune, matched by the Intune device id in the agent's client certificate,
+ without Autopilot registration, corporate identifier or device association)
- All -> every optional permission above, in one run
(also works with -Revoke to remove all optional grants at once)
diff --git a/src/Web/autopilot-monitor-web/app/about/faq.ts b/src/Web/autopilot-monitor-web/app/about/faq.ts
index dfbc1896..cfe915c7 100644
--- a/src/Web/autopilot-monitor-web/app/about/faq.ts
+++ b/src/Web/autopilot-monitor-web/app/about/faq.ts
@@ -67,7 +67,7 @@ export const ABOUT_FAQ: AboutFaqItem[] = [
{
question: "Which Autopilot scenarios are supported?",
answer:
- "User-driven, pre-provisioned (white glove), and self-deploying or kiosk Autopilot flows, for Microsoft Entra joined and Hybrid joined devices alike. Autopilot Device Preparation is supported, including device association as a validation method, and Windows 365 Cloud PCs can be enabled per tenant.",
+ "User-driven, pre-provisioned (white glove), and self-deploying or kiosk Autopilot flows, for Microsoft Entra joined and Hybrid joined devices alike. Autopilot Device Preparation is supported, with device association or corporate identifiers as validation methods, or without any pre-registration by validating the device's Intune enrollment. Windows 365 Cloud PCs can be enabled per tenant.",
},
{
question: "Where is my enrollment data stored?",
diff --git a/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx
index dbf4304b..199b4f2e 100644
--- a/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx
+++ b/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx
@@ -23,6 +23,7 @@ import { api } from "@/lib/api";
import { ApiError, apiErrorText, fetchJson, fetchOk, jsonBody } from "@/lib/apiClient";
import { CONFIG_PATH_PREFIX, invalidateCachedAuthFetch } from "@/lib/cachedAuthFetch";
import { classifyClientId, legacyConfigured } from "@/lib/authApp";
+import { hasAnyDeviceValidation } from "@/lib/deviceValidation";
import { appHomingErrorMessage } from "@/lib/appHoming";
import { trackEvent } from "@/lib/appInsights";
import { TenantAdminSection } from "./TenantAdminSection";
@@ -265,7 +266,7 @@ function TenantManagementSectionInner({
const clearFilters = () => setFilters(EMPTY_FILTERS);
// Statistics (always over all tenants, not filtered)
- const readyCount = tenants.filter(t => t.validateAutopilotDevice).length;
+ const readyCount = tenants.filter(t => hasAnyDeviceValidation(t)).length;
const waitlistCount = tenants.filter(t => onWaitlist(t, filterCtx)).length;
const totalCount = tenants.length;
const plans = planOverview(tenants, nowMs);
@@ -706,7 +707,7 @@ function TenantManagementSectionInner({
Paying
)}
- {tenant.validateAutopilotDevice && (
+ {hasAnyDeviceValidation(tenant) && (
Ready
diff --git a/src/Web/autopilot-monitor-web/app/admin/components/__tests__/tenantFilters.test.ts b/src/Web/autopilot-monitor-web/app/admin/components/__tests__/tenantFilters.test.ts
index 3106b0ee..ddcaf081 100644
--- a/src/Web/autopilot-monitor-web/app/admin/components/__tests__/tenantFilters.test.ts
+++ b/src/Web/autopilot-monitor-web/app/admin/components/__tests__/tenantFilters.test.ts
@@ -111,6 +111,12 @@ describe("matchesTenantFilters", () => {
expect(matchesTenantFilters(msp, payingPro, ctx())).toBe(false);
});
+ it("counts a tenant as ready with any device validation, not just Autopilot", () => {
+ expect(tenantFacetValues(tenant({ validateIntuneDeviceBinding: true }), ctx()).status).toContain("ready");
+ expect(tenantFacetValues(tenant({ validateDeviceAssociation: true }), ctx()).status).toContain("ready");
+ expect(tenantFacetValues(tenant(), ctx()).status).not.toContain("ready");
+ });
+
it("treats Status as multi-valued: a ready waitlisted tenant matches either value", () => {
const t = tenant({ tenantId: "w", validateAutopilotDevice: true });
const waitlisted = ctx({ isWaitlisted: (id) => id === "w" });
diff --git a/src/Web/autopilot-monitor-web/app/admin/components/tenantFilters.ts b/src/Web/autopilot-monitor-web/app/admin/components/tenantFilters.ts
index 5c078fb3..965fedbb 100644
--- a/src/Web/autopilot-monitor-web/app/admin/components/tenantFilters.ts
+++ b/src/Web/autopilot-monitor-web/app/admin/components/tenantFilters.ts
@@ -12,6 +12,8 @@
* app-registration classifier (reads env, `null` outside the dual-app parallel window).
*/
+import { hasAnyDeviceValidation } from "@/lib/deviceValidation";
+
export type PlanEdition = "community" | "pro" | "pro-trial" | "pro-msp";
export type TrialState = "active" | "expired" | "never";
export type AppValue = "primary" | "legacy" | "unknown";
@@ -30,7 +32,12 @@ export interface TenantFilterFields {
disabled: boolean;
disabledReason?: string | null;
mcpDisabled: boolean;
+ /** Device-validation flags; "ready" = any of them on (see hasAnyDeviceValidation). */
validateAutopilotDevice: boolean;
+ validateCorporateIdentifier?: boolean;
+ validateDeviceAssociation?: boolean;
+ validateCloudPcDevice?: boolean;
+ validateIntuneDeviceBinding?: boolean;
}
export interface TenantFilterContext {
@@ -178,7 +185,7 @@ export function onWaitlist(t: TenantFilterFields, ctx: TenantFilterContext): boo
export function tenantFacetValues(t: TenantFilterFields, ctx: TenantFilterContext): Record {
const status: string[] = [];
const offboarding = isOffboardingTombstone(t);
- if (t.validateAutopilotDevice) status.push("ready");
+ if (hasAnyDeviceValidation(t)) status.push("ready");
if (onWaitlist(t, ctx)) status.push("waitlist");
if (offboarding) status.push("offboarding");
if (t.disabled && !offboarding) status.push("suspended");
diff --git a/src/Web/autopilot-monitor-web/app/admin/tenants/sections/tenantConfigReportCatalog.ts b/src/Web/autopilot-monitor-web/app/admin/tenants/sections/tenantConfigReportCatalog.ts
index b99c22d0..9a22f39d 100644
--- a/src/Web/autopilot-monitor-web/app/admin/tenants/sections/tenantConfigReportCatalog.ts
+++ b/src/Web/autopilot-monitor-web/app/admin/tenants/sections/tenantConfigReportCatalog.ts
@@ -129,7 +129,7 @@ export const TENANT_FIELDS: Record {
it("shows the app-homing banner only on an explicit true", () => {
- expect(summarizeTenantSecurityConfig({ validateAutopilotDevice: true, appHomingFunnelActive: true }).appHomingFunnelActive).toBe(true);
- expect(summarizeTenantSecurityConfig({ validateAutopilotDevice: true, appHomingFunnelActive: false }).appHomingFunnelActive).toBe(false);
+ expect(summarizeTenantSecurityConfig({ deviceValidationEnabled: true, appHomingFunnelActive: true }).appHomingFunnelActive).toBe(true);
+ expect(summarizeTenantSecurityConfig({ deviceValidationEnabled: true, appHomingFunnelActive: false }).appHomingFunnelActive).toBe(false);
// Older backend without the field: never nag.
- expect(summarizeTenantSecurityConfig({ validateAutopilotDevice: true }).appHomingFunnelActive).toBe(false);
+ expect(summarizeTenantSecurityConfig({ deviceValidationEnabled: true }).appHomingFunnelActive).toBe(false);
});
it("keys the validation banner on the flag and the contact nag on Pro editions only", () => {
- const community = summarizeTenantSecurityConfig({ validateAutopilotDevice: false, edition: "community", contactEmailSet: false });
- expect(community.serialValidationEnabled).toBe(false);
+ const community = summarizeTenantSecurityConfig({ deviceValidationEnabled: false, edition: "community", contactEmailSet: false });
+ expect(community.deviceValidationEnabled).toBe(false);
expect(community.proContactMissing).toBe(false);
- const pro = summarizeTenantSecurityConfig({ validateAutopilotDevice: true, edition: "pro", contactEmailSet: false, companyNameSet: true });
- expect(pro.serialValidationEnabled).toBe(true);
+ const pro = summarizeTenantSecurityConfig({ deviceValidationEnabled: true, edition: "pro", contactEmailSet: false, companyNameSet: true });
+ expect(pro.deviceValidationEnabled).toBe(true);
expect(pro.proContactMissing).toBe(true);
expect(pro.proContactMissingParts).toEqual(["contact address"]);
});
diff --git a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useTenantSecurityConfig.ts b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useTenantSecurityConfig.ts
index 17bda370..d42bd981 100644
--- a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useTenantSecurityConfig.ts
+++ b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useTenantSecurityConfig.ts
@@ -11,7 +11,8 @@ type NotifyError = (title: string, err: unknown, key?: string, fallback?: string
/** The subset of the feature-flags response the dashboard banners read. */
export interface TenantConfigurationSummary {
- validateAutopilotDevice: boolean;
+ /** At least one device-validation method is enabled (backend HasAnyDeviceValidation). */
+ deviceValidationEnabled: boolean;
edition?: string;
contactEmailSet?: boolean;
companyNameSet?: boolean;
@@ -26,7 +27,7 @@ interface User {
export interface TenantSecuritySummary {
/** null while loading or on error; the red banner keys on `=== false`. */
- serialValidationEnabled: boolean | null;
+ deviceValidationEnabled: boolean | null;
/**
* Pro tenant (incl. trial) with an incomplete contact profile (address and/or company
* name) — drives the amber "complete your contact details" banner. Requires an EXPLICIT
@@ -44,7 +45,7 @@ export interface TenantSecuritySummary {
}
const EMPTY_SUMMARY: TenantSecuritySummary = {
- serialValidationEnabled: null,
+ deviceValidationEnabled: null,
proContactMissing: false,
proContactMissingParts: [],
appHomingFunnelActive: false,
@@ -55,7 +56,7 @@ export function summarizeTenantSecurityConfig(data: TenantConfigurationSummary):
const isPro = data.edition === "pro" || data.edition === "enterprise";
const missingParts = isPro ? missingContactProfileParts(data) : [];
return {
- serialValidationEnabled: !!data.validateAutopilotDevice,
+ deviceValidationEnabled: !!data.deviceValidationEnabled,
proContactMissing: missingParts.length > 0,
proContactMissingParts: missingParts,
appHomingFunnelActive: data.appHomingFunnelActive === true,
@@ -64,7 +65,7 @@ export function summarizeTenantSecurityConfig(data: TenantConfigurationSummary):
/**
* Fetches the tenant's feature-flags summary to drive the dashboard banners:
- * the red "Autopilot Device Validation is disabled" banner, the amber
+ * the red "no device validation enabled" banner, the amber
* Pro-without-contact-address banner, and the blue app-registration migration banner.
*
* Skips the fetch for users without a tenant role (they never see the dashboard).
diff --git a/src/Web/autopilot-monitor-web/app/dashboard/page.tsx b/src/Web/autopilot-monitor-web/app/dashboard/page.tsx
index 69158616..0c40afa6 100644
--- a/src/Web/autopilot-monitor-web/app/dashboard/page.tsx
+++ b/src/Web/autopilot-monitor-web/app/dashboard/page.tsx
@@ -257,7 +257,7 @@ function HomeContent() {
}
}, [user, router]);
- const { serialValidationEnabled, proContactMissing, proContactMissingParts, appHomingFunnelActive } = useTenantSecurityConfig(tenantId, user, getAccessToken, notifyError);
+ const { deviceValidationEnabled, proContactMissing, proContactMissingParts, appHomingFunnelActive } = useTenantSecurityConfig(tenantId, user, getAccessToken, notifyError);
// Disable global-scope mode for users without platform scope. A read-only Global Reader keeps it
// (their cross-tenant view is read-only-safe; writes are gated separately + backend-enforced).
@@ -367,7 +367,7 @@ function HomeContent() {
)}
- {serialValidationEnabled === false && (
+ {deviceValidationEnabled === false && (
@@ -375,9 +375,9 @@ function HomeContent() {
-
Action required: Autopilot Device Validation is disabled
+
Action required: No device validation is enabled
- Agent ingestion is blocked. Enable Autopilot Device Validation in Settings to start monitoring devices.
+ Agent ingestion is blocked. Enable a device validation method in Settings to start monitoring devices.
diff --git a/src/Web/autopilot-monitor-web/app/privacy/page.tsx b/src/Web/autopilot-monitor-web/app/privacy/page.tsx
index d1231672..2b947a4b 100644
--- a/src/Web/autopilot-monitor-web/app/privacy/page.tsx
+++ b/src/Web/autopilot-monitor-web/app/privacy/page.tsx
@@ -2,7 +2,7 @@ import { LandingNavbar } from "../../components/landing/LandingNavbar";
import { SiteFooter } from "../../components/SiteFooter";
import { DOCS_URL } from "@/utils/config";
-const LAST_UPDATED = "21 September 2026";
+const LAST_UPDATED = "25 September 2026";
const DOCS_SECURITY_FAQ = `${DOCS_URL}/trust/security-faq`;
const DOCS_DATA_FLOWS = `${DOCS_URL}/trust/data-flows`;
const DOCS_DPA = `${DOCS_URL}/legal/data-privacy-agreement-dpa`;
@@ -208,7 +208,7 @@ export default function PrivacyPage() {
How Your Data Is Protected
Device authentication by mutual TLS using the Intune MDM client certificate, validated against pinned Intune root CAs rather than the operating system trust store — and rejected if no trust anchor loads
-
Device registration validation via Microsoft Graph — only devices your tenant actually knows are accepted (Windows Autopilot registration, corporate device identifiers, or the Windows 365 Cloud PC inventory), with an optional hardware allow-list on top
+
Device validation via Microsoft Graph — only devices your tenant actually knows are accepted (Windows Autopilot registration, corporate device identifiers, device association, the Windows 365 Cloud PC inventory, or the Intune enrollment of the certificate's device, as enabled per tenant), with an optional hardware allow-list on top
Entra ID authentication for portal users with a restricted signing-algorithm allow-list and identity PII logging disabled
Fail-closed authorization — every API route must be registered with an access policy; an unregistered route is unreachable
Structural tenant isolation — storage partitioning by tenant, with the tenant identity taken from the validated token and never from a client-supplied header
diff --git a/src/Web/autopilot-monitor-web/app/sessions/components/SessionInfoCard.tsx b/src/Web/autopilot-monitor-web/app/sessions/components/SessionInfoCard.tsx
index 7abe8137..7bf4f6cc 100644
--- a/src/Web/autopilot-monitor-web/app/sessions/components/SessionInfoCard.tsx
+++ b/src/Web/autopilot-monitor-web/app/sessions/components/SessionInfoCard.tsx
@@ -175,8 +175,8 @@ function joinTypeLabel(session: Session): string {
/**
* Which backend device-validation path admitted the device at session registration
- * (SecurityValidator: Autopilot S/N lookup → Corporate Identifier fallback; Bootstrap
- * token for pre-MDM sessions; Device Association once DevPrep becomes a hard gate).
+ * (SecurityValidator chain: Autopilot → Corporate Identifier → Device Association → Cloud PC →
+ * Intune Enrollment; Bootstrap token for pre-MDM sessions).
* Null (item hidden) for legacy sessions or tenants with device validation disabled.
*/
function validatedByLabel(session: Session): string | null {
@@ -186,6 +186,7 @@ function validatedByLabel(session: Session): string | null {
case "DeviceAssociation": return "Device Association";
case "Bootstrap": return "Bootstrap Token (pre-MDM)";
case "CloudPc": return "Windows 365 Cloud PC";
+ case "IntuneEnrollment": return "Intune Enrollment";
default: return null;
}
}
diff --git a/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx b/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx
index 929bbd74..ea7c59ed 100644
--- a/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx
+++ b/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx
@@ -18,15 +18,11 @@ type ValidationTrigger = "autopilot" | "corporate" | "device-preparation";
// Human label for a validation trigger.
const validationLabel = (t: ValidationTrigger) =>
t === "corporate" ? "Corporate Identifier Validation"
- : t === "device-preparation" ? "DevPrep Device Association Validation"
+ : t === "device-preparation" ? "Device Association Validation"
: "Autopilot Device Validation";
-// Enable-confirmation suffix. DevPrep is shadow-mode (no hard gate), so its wording differs
-// from the agent-gating validations.
-const validationEnabledSuffix = (t: ValidationTrigger) =>
- t === "device-preparation"
- ? " enabled (shadow mode — does not block enrollment)."
- : " enabled. Backend agent endpoints are now unlocked for this tenant.";
+// Enable-confirmation suffix: every consent-driven validation is an accepting agent gate.
+const VALIDATION_ENABLED_SUFFIX = " enabled. Backend agent endpoints are now unlocked for this tenant.";
import { parseSasExpiry } from "./components/DiagnosticsSection";
import { COMMUNITY_DEFAULT, parseEditionInfo, type EditionInfo } from "@/lib/edition";
import { TenantConfiguration, TenantAdmin, DiagnosticsLogPath, NotificationChannel, LEGACY_CHANNEL_ID } from "./types";
@@ -952,7 +948,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
const saved = await persistValidation(trigger);
if (!saved) return "failed";
- setSuccessMessage(`Access is already approved by your organization — ${validationLabel(trigger)}${validationEnabledSuffix(trigger)}`);
+ setSuccessMessage(`Access is already approved by your organization — ${validationLabel(trigger)}${VALIDATION_ENABLED_SUFFIX}`);
return "reconciled";
},
[probeAccessCheck, persistValidation],
@@ -1122,7 +1118,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
if (attempts > 1) trackEvent("consent_verify_propagated", { trigger, attempts: String(attempts) });
const saved = await persistValidation(reconcileTrigger);
if (saved) {
- setSuccessMessage(`${validationLabel(reconcileTrigger)}${validationEnabledSuffix(reconcileTrigger)}`);
+ setSuccessMessage(`${validationLabel(reconcileTrigger)}${VALIDATION_ENABLED_SUFFIX}`);
}
} else if (probe === "transient") {
trackEvent("consent_verify_failed", { trigger, stage: "role-propagating", attempts: String(attempts - 1) });
@@ -1335,10 +1331,10 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
}, [saveConfiguration]);
/**
- * Toggle the cert-to-device binding check (Global-Admin-only preview). No consent flow -
- * DeviceManagementManagedDevices.Read.All is an Optional Graph capabilities add-on granted
- * with the script; without it the backend records "PermissionMissing" and nothing else, and
- * because the check runs in shadow mode it never affects an enrollment either way.
+ * Toggle Intune Enrollment Validation (field name validateIntuneDeviceBinding). No consent
+ * flow — DeviceManagementManagedDevices.Read.All is an Optional Graph capabilities add-on
+ * granted with the script; until it is granted no device is admitted through this option
+ * (the backend answers 503 Retry-After, like a missing core consent).
*/
const handleToggleIntuneDeviceBinding = useCallback(async (newValue: boolean) => {
setValidateIntuneDeviceBinding(newValue);
diff --git a/src/Web/autopilot-monitor-web/app/settings/components/AutopilotValidationSection.tsx b/src/Web/autopilot-monitor-web/app/settings/components/AutopilotValidationSection.tsx
index 56665c7f..7717593e 100644
--- a/src/Web/autopilot-monitor-web/app/settings/components/AutopilotValidationSection.tsx
+++ b/src/Web/autopilot-monitor-web/app/settings/components/AutopilotValidationSection.tsx
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
+import Link from "next/link";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import { DOCS_PATHS } from "@/lib/docsPaths";
import { ModalPortal } from "@/components/ModalPortal";
@@ -13,10 +14,12 @@ interface AutopilotValidationSectionProps {
/** Autopilot device preparation "Device association" — same Graph permission as the two above. */
validateDeviceAssociation: boolean;
setValidateDeviceAssociation: (value: boolean) => void;
- /** Cert-to-device binding - Global-Admin-only preview. Provided when `showIntuneDeviceBindingToggle=true`. */
+ /** Intune Enrollment Validation — admits enrolled Intune devices without pre-registration. */
validateIntuneDeviceBinding?: boolean;
+ /** Toggle + persist Intune Enrollment Validation (permission via the Optional Graph capabilities add-on, no consent dialog). */
onToggleIntuneDeviceBinding?: (v: boolean) => void | Promise;
- showIntuneDeviceBindingToggle?: boolean;
+ /** Whether the IntuneDeviceBinding add-on is granted; null while unknown (loading, transient). */
+ intuneEnrollmentPermission?: boolean | null;
/** Windows 365 Cloud PC validation — fallback gate for Cloud PCs (never Autopilot-registered). */
validateCloudPcDevice?: boolean;
/** Toggle + persist Cloud PC validation in one shot (permission comes via the Optional Graph capabilities add-on, no consent dialog). */
@@ -40,7 +43,7 @@ export default function AutopilotValidationSection({
setValidateDeviceAssociation,
validateIntuneDeviceBinding = false,
onToggleIntuneDeviceBinding,
- showIntuneDeviceBindingToggle = false,
+ intuneEnrollmentPermission = null,
validateCloudPcDevice = false,
onToggleCloudPc,
autopilotConsentInProgress,
@@ -48,8 +51,9 @@ export default function AutopilotValidationSection({
onBeginConsent,
onDetectExistingAccess,
}: AutopilotValidationSectionProps) {
- const anyValidationEnabled = validateAutopilotDevice || validateCorporateIdentifier || validateDeviceAssociation || validateCloudPcDevice;
- const [disableConfirm, setDisableConfirm] = useState<'autopilot' | 'corporate' | 'device-association' | 'cloudpc' | null>(null);
+ const enabledValidations = [validateAutopilotDevice, validateCorporateIdentifier, validateDeviceAssociation, validateCloudPcDevice, validateIntuneDeviceBinding];
+ const anyValidationEnabled = enabledValidations.some(Boolean);
+ const [disableConfirm, setDisableConfirm] = useState<'autopilot' | 'corporate' | 'device-association' | 'cloudpc' | 'intune-enrollment' | null>(null);
// The three serial-based validations share one Graph permission: the first one enabled
// carries the admin consent, every further one is a plain persisted toggle.
const consentAlreadyCarried = validateAutopilotDevice || validateCorporateIdentifier || validateDeviceAssociation;
@@ -94,6 +98,16 @@ export default function AutopilotValidationSection({
}
};
+ // Intune Enrollment Validation: add-on permission (DeviceManagementManagedDevices.Read.All via
+ // the grant script) like Cloud PC — direct toggle+persist, no consent dialog.
+ const handleToggleIntuneEnrollment = () => {
+ if (validateIntuneDeviceBinding) {
+ setDisableConfirm('intune-enrollment');
+ } else if (onToggleIntuneDeviceBinding) {
+ void onToggleIntuneDeviceBinding(true);
+ }
+ };
+
const confirmDisable = () => {
if (disableConfirm === 'autopilot') {
setValidateAutopilotDevice(false);
@@ -103,6 +117,8 @@ export default function AutopilotValidationSection({
setValidateDeviceAssociation(false);
} else if (disableConfirm === 'cloudpc' && onToggleCloudPc) {
void onToggleCloudPc(false);
+ } else if (disableConfirm === 'intune-enrollment' && onToggleIntuneDeviceBinding) {
+ void onToggleIntuneDeviceBinding(false);
}
setDisableConfirm(null);
};
@@ -245,27 +261,41 @@ export default function AutopilotValidationSection({
- Devices rejected with HTTP 403 because they were not found in this tenant's Autopilot or Corporate Identifier registry
+ Devices rejected with HTTP 403 because no enabled device validation method matched them
- 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.
+ When a device attempts to enroll but none of your enabled device validation methods matches it,
+ it appears here. An empty list means every device that reached the backend was in scope.
)}
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 eb00cb33..b6269cfb 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
@@ -1,8 +1,10 @@
"use client";
+import { useEffect } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { useTenant } from "@/contexts/TenantContext";
-import { useGlobalAdminUi } from "@/hooks/useGlobalAdminUi";
+import { useAuthenticatedFetch } from "@/hooks/useAuthenticatedFetch";
+import { api } from "@/lib/api";
import { buildAddOnGrantCommand } from "@/lib/appHoming";
import { legacyConfigured, primaryClientId, switchAuthApp } from "@/lib/authApp";
import { DOCS_PATHS } from "@/lib/docsPaths";
@@ -12,6 +14,8 @@ import { TenantNotifications } from "../../TenantNotifications";
import { AppHomingAddOnStep } from "../../components/AppHomingAddOnStep";
import AutopilotValidationSection from "../../components/AutopilotValidationSection";
import NotRegisteredDevicesInsights from "../../components/NotRegisteredDevicesInsights";
+import type { GetGraphPermissionsStatusResponse } from "@/utils/wire-types.generated";
+import { addOnGranted, INTUNE_ENROLLMENT_FEATURE } from "@/lib/deviceValidation";
export function SectionAutopilotValidation() {
const {
@@ -32,11 +36,15 @@ export function SectionAutopilotValidation() {
const { getAccessToken } = useAuth();
const { tenantId } = useTenant();
- // Cert-device binding stays Global-Admin-only: only the operator can turn the binding check
- // on while its enrollment-race behaviour is still being measured. Follows the Global-Admin
- // VIEW, so switching it off (or presenting in demo mode) yields the real tenant-admin section.
- // Read before the early return below — hooks may not sit behind a conditional.
- const showIntuneDeviceBindingToggle = useGlobalAdminUi();
+ // Add-on permission state for the Intune Enrollment option — the same status endpoint the
+ // Optional Graph capabilities page reads. Best effort: a failure leaves the hint hidden.
+ // Hooks sit before the early return below — they may not be conditional.
+ const permissionStatus = useAuthenticatedFetch({ onError: () => {} });
+ const fetchPermissionStatus = permissionStatus.execute;
+ useEffect(() => {
+ if (!tenantId || !canEditConfig) return;
+ void fetchPermissionStatus(api.graphPermissions.status(tenantId), undefined, { silent: true });
+ }, [tenantId, canEditConfig, fetchPermissionStatus]);
// Validation gates + the Entra admin-consent flow are tenant-admin territory —
// Operators do not see this section at all.
@@ -178,7 +186,7 @@ export function SectionAutopilotValidation() {
validateCloudPcDevice={validateCloudPcDevice}
validateIntuneDeviceBinding={validateIntuneDeviceBinding}
onToggleIntuneDeviceBinding={handleToggleIntuneDeviceBinding}
- showIntuneDeviceBindingToggle={showIntuneDeviceBindingToggle}
+ intuneEnrollmentPermission={addOnGranted(permissionStatus.data, INTUNE_ENROLLMENT_FEATURE)}
onToggleCloudPc={handleToggleCloudPcValidation}
autopilotConsentInProgress={autopilotConsentInProgress}
saving={savingSection === "autopilotValidation"}
diff --git a/src/Web/autopilot-monitor-web/app/settings/types.ts b/src/Web/autopilot-monitor-web/app/settings/types.ts
index e55c5783..a125769f 100644
--- a/src/Web/autopilot-monitor-web/app/settings/types.ts
+++ b/src/Web/autopilot-monitor-web/app/settings/types.ts
@@ -42,9 +42,8 @@ export interface TenantConfiguration {
validateAutopilotDevice: boolean;
validateCorporateIdentifier?: boolean;
/**
- * DevPrep Device Association validation (shadow mode during Private Preview).
- * Looks devices up via Graph `tenantAssociatedDevices` but does NOT block enrollment.
- * UI surface is gated to Global Admins until DevPrep ships GA.
+ * Device preparation "Device association" validation: matches the serial against Graph
+ * `tenantAssociatedDevices`. An accepting agent gate like the other validations.
*/
validateDeviceAssociation?: boolean;
/**
@@ -55,6 +54,12 @@ export interface TenantConfiguration {
* permission ("W365CloudPcValidation" in Optional Graph capabilities).
*/
validateCloudPcDevice?: boolean;
+ /**
+ * Intune Enrollment Validation: the last accepting gate — admits any device of the tenant's
+ * Intune inventory, matched by the Intune device id in the agent's MDM certificate, with no
+ * pre-registration. Requires the optional DeviceManagementManagedDevices.Read.All add-on
+ * ("IntuneDeviceBinding" in Optional Graph capabilities).
+ */
validateIntuneDeviceBinding?: boolean;
allowInsecureAgentRequests?: boolean;
/**
diff --git a/src/Web/autopilot-monitor-web/hooks/useGlobalAdminUi.ts b/src/Web/autopilot-monitor-web/hooks/useGlobalAdminUi.ts
index 31dddeff..14d0474d 100644
--- a/src/Web/autopilot-monitor-web/hooks/useGlobalAdminUi.ts
+++ b/src/Web/autopilot-monitor-web/hooks/useGlobalAdminUi.ts
@@ -8,7 +8,7 @@ import { useAdminMode } from "./useAdminMode";
* Global-Admin view is switched on (which demo mode forces off — see lib/demoMode.ts).
*
* Use this for operator-only surfaces that sit INSIDE an otherwise tenant-scoped page — the
- * platform-bot Telegram provider, the cert-device-binding toggle, the retention escape hatch, the
+ * platform-bot Telegram provider, the retention escape hatch, the
* backend/portal build blocks. Turning the Global-Admin view off then yields a view that is
* genuinely indistinguishable from a tenant admin's, which is what live demos need.
*
diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/deviceValidation.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/deviceValidation.test.ts
new file mode 100644
index 00000000..e3e90d86
--- /dev/null
+++ b/src/Web/autopilot-monitor-web/lib/__tests__/deviceValidation.test.ts
@@ -0,0 +1,57 @@
+import { describe, it, expect } from "vitest";
+import { DEVICE_VALIDATION_FIELDS, addOnGranted, hasAnyDeviceValidation, INTUNE_ENROLLMENT_FEATURE } from "../deviceValidation";
+import type { GetGraphPermissionsStatusResponse } from "@/utils/wire-types.generated";
+
+describe("DEVICE_VALIDATION_FIELDS", () => {
+ it("comes from the manifest and covers every validation method", () => {
+ // Independent list: a validation method missing here would make its tenants look "not ready"
+ // and trigger the "agent ingestion is blocked" banner although agents are accepted.
+ expect([...DEVICE_VALIDATION_FIELDS].sort()).toEqual([
+ "validateAutopilotDevice",
+ "validateCloudPcDevice",
+ "validateCorporateIdentifier",
+ "validateDeviceAssociation",
+ "validateIntuneDeviceBinding",
+ ]);
+ });
+});
+
+describe("hasAnyDeviceValidation", () => {
+ it("is true when any single method is on", () => {
+ for (const field of DEVICE_VALIDATION_FIELDS) {
+ expect(hasAnyDeviceValidation({ [field]: true }), field).toBe(true);
+ }
+ });
+
+ it("is false when every method is off or absent", () => {
+ expect(hasAnyDeviceValidation({})).toBe(false);
+ expect(hasAnyDeviceValidation({ validateAutopilotDevice: false, validateIntuneDeviceBinding: false })).toBe(false);
+ // Unrelated switches do not count.
+ expect(hasAnyDeviceValidation({ allowInsecureAgentRequests: true })).toBe(false);
+ });
+});
+
+describe("addOnGranted", () => {
+ const status = (overrides: Partial = {}): GetGraphPermissionsStatusResponse => ({
+ clientId: "aaaaaaaa-0000-0000-0000-000000000001",
+ isTransient: false,
+ grantedRoles: [],
+ features: [
+ { name: INTUNE_ENROLLMENT_FEATURE, granted: true, requiredPermissions: ["DeviceManagementManagedDevices.Read.All"] },
+ { name: "W365CloudPcValidation", granted: false, requiredPermissions: ["CloudPC.Read.All"] },
+ ],
+ ...overrides,
+ });
+
+ it("reads the feature's verdict", () => {
+ expect(addOnGranted(status(), INTUNE_ENROLLMENT_FEATURE)).toBe(true);
+ expect(addOnGranted(status(), "W365CloudPcValidation")).toBe(false);
+ });
+
+ it("is unknown while loading, on a transient snapshot, or for an unlisted feature", () => {
+ expect(addOnGranted(null, INTUNE_ENROLLMENT_FEATURE)).toBeNull();
+ expect(addOnGranted(status({ isTransient: true }), INTUNE_ENROLLMENT_FEATURE)).toBeNull();
+ expect(addOnGranted(status(), "Unknown")).toBeNull();
+ expect(addOnGranted(status({ features: [{ name: INTUNE_ENROLLMENT_FEATURE, requiredPermissions: [] }] }), INTUNE_ENROLLMENT_FEATURE)).toBeNull();
+ });
+});
diff --git a/src/Web/autopilot-monitor-web/lib/deviceValidation.ts b/src/Web/autopilot-monitor-web/lib/deviceValidation.ts
new file mode 100644
index 00000000..4c349b38
--- /dev/null
+++ b/src/Web/autopilot-monitor-web/lib/deviceValidation.ts
@@ -0,0 +1,26 @@
+import { SHARED_MANIFEST } from "@/utils/shared-manifests.generated";
+import type { GetGraphPermissionsStatusResponse } from "@/utils/wire-types.generated";
+
+/**
+ * Every `validate*` flag of the tenant configuration, read from the shared manifest. The agent
+ * gate accepts traffic when any of them is on (TenantConfiguration.HasAnyDeviceValidation in the
+ * backend, pinned to "every Validate* flag" by its own test), so a new validation method is
+ * picked up here without a code change.
+ */
+export const DEVICE_VALIDATION_FIELDS: readonly string[] =
+ SHARED_MANIFEST.tenantConfiguration.fields.filter((field: string) => /^validate[A-Z]/.test(field));
+
+/** True when at least one device-validation method is enabled on this configuration. */
+export function hasAnyDeviceValidation(config: object): boolean {
+ const values = config as Record;
+ return DEVICE_VALIDATION_FIELDS.some((field) => values[field] === true);
+}
+
+/** Grant-script feature backing Intune Enrollment Validation (GraphFeatureCatalog.FeatureIntuneDeviceBinding). */
+export const INTUNE_ENROLLMENT_FEATURE = "IntuneDeviceBinding";
+
+/** Granted verdict of one add-on feature; null while unknown (not loaded, failed, or transient snapshot). */
+export function addOnGranted(status: GetGraphPermissionsStatusResponse | null, feature: string): boolean | null {
+ if (!status || status.isTransient) return null;
+ return status.features.find((f) => f.name === feature)?.granted ?? null;
+}