mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
feat(web): Intune Enrollment Validation in the tenant settings; banners follow any device validation (D-282)
The option is a regular tenant-admin toggle in its own "Without pre-registration" block, with the add-on permission state read from the existing graph-permissions status endpoint. The dashboard banner and the admin Ready badge key on "any validation method enabled" (feature flag deviceValidationEnabled, manifest-driven validate* fields) instead of Autopilot alone, so a device-preparation tenant that validates by device association is no longer told that agent ingestion is blocked. Stale "shadow mode" wording for Device Association is gone, the Not-registered report names every method, sessions show "Intune Enrollment", and the privacy page and FAQ list the new method. Grant script help describes the IntuneDeviceBinding feature as Intune Enrollment Validation. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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
|
||||
</span>
|
||||
)}
|
||||
{tenant.validateAutopilotDevice && (
|
||||
{hasAnyDeviceValidation(tenant) && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
Ready
|
||||
</span>
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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<FacetKey, readonly string[]> {
|
||||
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");
|
||||
|
||||
+1
-1
@@ -129,7 +129,7 @@ export const TENANT_FIELDS: Record<keyof TenantConfiguration, TenantRow | Exclud
|
||||
validateCorporateIdentifier: { section: "Security & Validation", label: "Validate Corporate Identifier" },
|
||||
validateDeviceAssociation: { section: "Security & Validation", label: "Validate Device Association" },
|
||||
validateCloudPcDevice: { section: "Security & Validation", label: "Validate Cloud PC Device" },
|
||||
validateIntuneDeviceBinding: { section: "Security & Validation", label: "Validate Intune Device Binding" },
|
||||
validateIntuneDeviceBinding: { section: "Security & Validation", label: "Validate Intune Enrollment" },
|
||||
allowInsecureAgentRequests: { section: "Security & Validation", label: "Allow Insecure Agent Requests" },
|
||||
customRateLimitRequestsPerMinute: { section: "Security & Validation", label: "Device API Rate Limit Override" },
|
||||
customUserRateLimitRequestsPerMinute: { section: "Security & Validation", label: "MCP & Integrations API Rate Limit Override" },
|
||||
|
||||
+7
-7
@@ -3,19 +3,19 @@ import { summarizeTenantSecurityConfig } from "../useTenantSecurityConfig";
|
||||
|
||||
describe("summarizeTenantSecurityConfig", () => {
|
||||
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"]);
|
||||
});
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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() {
|
||||
<AppHomingBanner />
|
||||
)}
|
||||
|
||||
{serialValidationEnabled === false && (
|
||||
{deviceValidationEnabled === false && (
|
||||
<div className="mb-6 bg-red-600 border-2 border-red-700 rounded-xl p-5 shadow-lg">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -375,9 +375,9 @@ function HomeContent() {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p className="text-base font-bold text-white">Action required: Autopilot Device Validation is disabled</p>
|
||||
<p className="text-base font-bold text-white">Action required: No device validation is enabled</p>
|
||||
<p className="text-sm text-red-100 mt-0.5">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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() {
|
||||
<h2 className="text-lg font-semibold text-gray-900 mt-6">How Your Data Is Protected</h2>
|
||||
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
|
||||
<li><strong>Device authentication by mutual TLS</strong> 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</li>
|
||||
<li><strong>Device registration validation via Microsoft Graph</strong> — 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</li>
|
||||
<li><strong>Device validation via Microsoft Graph</strong> — 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</li>
|
||||
<li><strong>Entra ID authentication</strong> for portal users with a restricted signing-algorithm allow-list and identity PII logging disabled</li>
|
||||
<li><strong>Fail-closed authorization</strong> — every API route must be registered with an access policy; an unregistered route is unreachable</li>
|
||||
<li><strong>Structural tenant isolation</strong> — storage partitioning by tenant, with the tenant identity taken from the validated token and never from a client-supplied header</li>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
+52
-21
@@ -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<void>;
|
||||
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({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cert-to-device binding - GA-gated preview, shadow-mode (no enrollment block) */}
|
||||
{showIntuneDeviceBindingToggle && onToggleIntuneDeviceBinding && (
|
||||
<div className="border-t border-gray-100 pt-5 space-y-3" data-testid="intune-device-binding-toggle">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-semibold text-gray-700 tracking-wide">Certificate Device Binding</p>
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-wider bg-purple-100 text-purple-800">
|
||||
Preview · GA only
|
||||
</span>
|
||||
</div>
|
||||
{/* Intune Enrollment — no pre-registration; permission via the Optional Graph capabilities add-on */}
|
||||
{onToggleIntuneDeviceBinding && (
|
||||
<div className="border-t border-gray-100 pt-5 space-y-3" data-testid="intune-enrollment-validation-toggle">
|
||||
<p className="text-sm font-semibold text-gray-700 tracking-wide">Without pre-registration</p>
|
||||
<label className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">Enable Intune Device Binding Validation</p>
|
||||
<p className="text-sm font-medium text-gray-900">Enable Intune Enrollment Validation</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Checks that the Intune device id in the agent{"'"}s client certificate belongs to a device this tenant actually enrolled. Currently runs in <strong>shadow mode</strong> - the result is recorded as telemetry only and does NOT block enrollment. Requires the optional <strong>DeviceManagementManagedDevices.Read.All</strong> permission - grant the{" "}
|
||||
<strong>IntuneDeviceBinding</strong> add-on under <em>Optional Graph capabilities</em>.
|
||||
Accepts every device enrolled in this tenant's Intune, matched by the Intune device id in the
|
||||
agent's MDM certificate. No Autopilot registration, corporate identifier or device association is
|
||||
needed, which suits Autopilot device preparation without pre-registration. Checked last, only when no
|
||||
option above matched.
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
This reaches exactly as far as your Intune enrollment restrictions: if personal devices may enroll,
|
||||
they are accepted too. For corporate devices only, block personal enrollment or prefer the options above.
|
||||
Requires the optional <strong>DeviceManagementManagedDevices.Read.All</strong>{" "}permission — grant the{" "}
|
||||
<strong>IntuneDeviceBinding</strong>{" "}add-on under{" "}
|
||||
<Link href="/settings/tenant/graph-permissions" className="underline underline-offset-2 hover:text-gray-700">
|
||||
Optional Graph capabilities
|
||||
</Link>.
|
||||
</p>
|
||||
{intuneEnrollmentPermission === true && (
|
||||
<p className="text-xs font-medium text-emerald-700 mt-1">Permission granted.</p>
|
||||
)}
|
||||
{intuneEnrollmentPermission === false && (
|
||||
<p className="text-xs font-medium text-amber-700 mt-1">
|
||||
Permission not granted yet. No device is accepted this way until the add-on is granted.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { void onToggleIntuneDeviceBinding(!validateIntuneDeviceBinding); }}
|
||||
onClick={handleToggleIntuneEnrollment}
|
||||
disabled={saving}
|
||||
aria-label="Toggle Intune device binding validation"
|
||||
aria-label="Toggle Intune enrollment validation"
|
||||
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors disabled:opacity-60 disabled:cursor-not-allowed ${validateIntuneDeviceBinding ? 'bg-emerald-500' : 'bg-gray-300'}`}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${validateIntuneDeviceBinding ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||
@@ -305,7 +335,8 @@ export default function AutopilotValidationSection({
|
||||
{disableConfirm === 'autopilot' ? 'Autopilot Device Validation'
|
||||
: disableConfirm === 'corporate' ? 'Corporate Identifier Validation'
|
||||
: disableConfirm === 'device-association' ? 'Device Association Validation'
|
||||
: 'Windows 365 Cloud PC Validation'}
|
||||
: disableConfirm === 'intune-enrollment' ? 'Intune Enrollment Validation'
|
||||
: 'Windows 365 Cloud PC Validation'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -313,7 +344,7 @@ export default function AutopilotValidationSection({
|
||||
<p className="text-sm text-gray-700 mb-2">
|
||||
Are you sure you want to disable this validation?
|
||||
</p>
|
||||
{[validateAutopilotDevice, validateCorporateIdentifier, validateDeviceAssociation, validateCloudPcDevice].filter(Boolean).length === 1 && (
|
||||
{enabledValidations.filter(Boolean).length === 1 && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4 text-sm text-red-800">
|
||||
This is the last active validation. Disabling it will cause the backend to <strong>reject all agent requests</strong> for this tenant.
|
||||
</div>
|
||||
|
||||
+3
-3
@@ -107,7 +107,7 @@ export default function NotRegisteredDevicesInsights({
|
||||
</h3>
|
||||
{hasDevices && (
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
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
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -124,8 +124,8 @@ export default function NotRegisteredDevicesInsights({
|
||||
{!hasDevices && (
|
||||
<div className="px-6 py-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+15
-7
@@ -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<GetGraphPermissionsStatusResponse>({ 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"}
|
||||
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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> = {}): 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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user