feat(backend+web): detect pre-approved consent for rights-less admins

In larger tenants the multi-tenant app is often pre-approved by someone
with consent rights, while the admin operating our UI lacks them. The
/adminconsent redirect then fails, so ValidateAutopilotDevice/
ValidateCorporateIdentifier never persist and the tenant stays "Disabled"
even though the app + DeviceManagementServiceConfig.Read.All effectively
exist. That bool is also the hard gate in SecurityValidator, so a UI-only
badge would leave agent traffic 403'd.

Add a read-only access-check probe that verifies the concrete role in the
SP token's roles claim (via GraphFeatureDetector, not mere token
acquirability) and, when present, persists the gate bool through the
normal config PUT — opening the UI badge AND the agent gate together.

Backend:
- GraphAppPermissions.DeviceManagementServiceConfigReadAll constant.
- GET /api/config/{tenantId}/autopilot-device-validation/access-check
  (TenantAdminOrGA): InvalidateTenant + fresh GetSnapshotAsync (4s budget),
  pure ResolveAccessPresent (transient => never present), emits
  ConsentAccessDetected telemetry. Always-fresh so a propagation-time
  empty-roles snapshot can't wedge the admin out for the token TTL.

Web:
- lib/accessCheck.ts classifyAccessCheck pure fn (transient/absent never
  reconcile) + tests.
- Reconcile on consent-redirect failure (silently enables) and a per-toggle
  "Detect existing access" button for Autopilot, Corporate, and DevPrep
  Device Association (all share the same role).
- Successful consent callback now verifies the concrete role too: persists
  ONLY on authoritative "reconciled"; transient => retry/propagation
  message, absent => "permission not granted" — neither opens the gate.
- saveConfiguration returns boolean so success is shown only after a
  confirmed persist.

No ConfigVersion bump (backend + web only). Backend 2326/2326, web 464/464.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-06-18 18:41:34 +02:00
parent b515db07d7
commit 8862d73eed
10 changed files with 450 additions and 18 deletions
@@ -0,0 +1,70 @@
using System.Collections.Generic;
using AutopilotMonitor.Functions.Functions.Admin;
using AutopilotMonitor.Functions.Services.GraphResolution;
using AutopilotMonitor.Shared.Models.Graph;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Covers the reconcile decision behind the <c>access-check</c> probe
/// (<see cref="AutopilotDeviceValidationConsentFunction.ResolveAccessPresent"/>) — the
/// "rights-less admin" path where the multi-tenant app was pre-approved by someone else.
/// The HTTP entry point itself is intentionally not exercised (mocking HttpRequestData +
/// the security pipeline is more setup than it's worth, per the project's function-test
/// convention); the security-relevant logic lives in this pure helper.
/// </summary>
public class AutopilotDeviceValidationAccessCheckTests
{
private static GraphPermissionSnapshot Snapshot(bool transient, params string[] roles)
=> new GraphPermissionSnapshot
{
GrantedRoles = new HashSet<string>(roles),
IsTransient = transient,
};
[Fact]
public void AccessPresent_WhenCoreRoleGranted()
{
var snapshot = Snapshot(false, GraphAppPermissions.DeviceManagementServiceConfigReadAll);
Assert.True(AutopilotDeviceValidationConsentFunction.ResolveAccessPresent(snapshot));
}
[Theory]
[InlineData("devicemanagementserviceconfig.read.all")]
[InlineData("DEVICEMANAGEMENTSERVICECONFIG.READ.ALL")]
public void AccessPresent_IsCaseInsensitive(string roleCasing)
{
// Azure AD may issue the role with slightly different casing across tokens.
var snapshot = Snapshot(false, roleCasing);
Assert.True(AutopilotDeviceValidationConsentFunction.ResolveAccessPresent(snapshot));
}
[Fact]
public void AccessAbsent_WhenRoleMissing()
{
// SP exists / token acquirable, but the core validation role was not consented —
// must NOT report present (would open the gate then 503 the agent forever).
var snapshot = Snapshot(false, GraphAppPermissions.DeviceManagementScriptsReadAll);
Assert.False(AutopilotDeviceValidationConsentFunction.ResolveAccessPresent(snapshot));
}
[Fact]
public void AccessAbsent_WhenNoRolesGranted()
{
Assert.False(AutopilotDeviceValidationConsentFunction.ResolveAccessPresent(Snapshot(false)));
}
[Fact]
public void AccessAbsent_WhenTransient_EvenIfRolePresent()
{
// Inconclusive probe (timeout / Graph 5xx) must never flip the gate, even if the
// (stale) granted-roles set happens to carry the role.
var snapshot = Snapshot(true, GraphAppPermissions.DeviceManagementServiceConfigReadAll);
Assert.False(AutopilotDeviceValidationConsentFunction.ResolveAccessPresent(snapshot));
}
}
@@ -1,7 +1,11 @@
using System.Linq;
using System.Net;
using System.Threading;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.GraphResolution;
using AutopilotMonitor.Shared.Models.Graph;
using Microsoft.ApplicationInsights;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
@@ -12,9 +16,18 @@ namespace AutopilotMonitor.Functions.Functions.Admin;
public class AutopilotDeviceValidationConsentFunction
{
/// <summary>
/// Hard wall on the access-check probe's token acquire — the admin click must feel
/// responsive and never get stuck in <see cref="GraphTokenService"/>'s long
/// consent-propagation retry chain (5 + 15 + 30 s). Mirrors
/// <c>GetGraphPermissionsStatusFunction.StatusBudget</c>.
/// </summary>
internal static readonly TimeSpan AccessCheckBudget = TimeSpan.FromSeconds(4);
private readonly ILogger<AutopilotDeviceValidationConsentFunction> _logger;
private readonly IConfiguration _configuration;
private readonly GraphTokenService _graphTokenService;
private readonly IGraphFeatureDetector _graphFeatureDetector;
private readonly TelemetryClient _telemetryClient;
private readonly OpsEventService _opsEventService;
@@ -33,12 +46,14 @@ public class AutopilotDeviceValidationConsentFunction
ILogger<AutopilotDeviceValidationConsentFunction> logger,
IConfiguration configuration,
GraphTokenService graphTokenService,
IGraphFeatureDetector graphFeatureDetector,
TelemetryClient telemetryClient,
OpsEventService opsEventService)
{
_logger = logger;
_configuration = configuration;
_graphTokenService = graphTokenService;
_graphFeatureDetector = graphFeatureDetector;
_telemetryClient = telemetryClient;
_opsEventService = opsEventService;
}
@@ -146,6 +161,101 @@ public class AutopilotDeviceValidationConsentFunction
return response;
}
/// <summary>
/// <c>GET /api/config/{tenantId}/autopilot-device-validation/access-check</c> — probes whether
/// the Autopilot Monitor service principal already holds the core validation permission
/// (<see cref="GraphAppPermissions.DeviceManagementServiceConfigReadAll"/>) in this tenant,
/// regardless of who granted consent.
/// <para>
/// This is the "rights-less admin" escape hatch: in larger tenants the multi-tenant app is
/// often pre-approved by someone with consent rights, so the app + permission exist even though
/// the admin operating our UI cannot complete the <c>/adminconsent</c> redirect. When this probe
/// reports <c>accessPresent: true</c>, the frontend silently persists
/// <c>ValidateAutopilotDevice</c>/<c>ValidateCorporateIdentifier</c> via the normal config PUT —
/// opening both the UI badge AND the agent hard gate — without ever running the redirect.
/// </para>
/// <para>
/// Read-only: this endpoint never persists. It checks the SP token's <c>roles</c> claim (via
/// <see cref="IGraphFeatureDetector"/>) rather than mere token-acquirability, so we never flip
/// the gate "enabled" when the SP exists but the role was not actually consented (which would
/// otherwise leave the agent stuck in an endless 503 at the real Graph call).
/// </para>
/// </summary>
[Function("GetAutopilotDeviceValidationAccessCheck")]
public async Task<HttpResponseData> GetAccessCheck(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "config/{tenantId}/autopilot-device-validation/access-check")] HttpRequestData req,
string tenantId)
{
// Authentication + tenant-admin policy enforced by PolicyEnforcementMiddleware
var requestCtx = req.GetRequestContext();
// Always read fresh. This probe is admin-triggered (reconcile on consent-fail, the
// "detect existing access" button, post-consent verification) and low-frequency, so a
// stale cached snapshot must never decide it. In particular, a snapshot acquired during
// AAD consent propagation can carry an empty roles claim and would otherwise be cached for
// the token lifetime (~55 min) — wedging the admin out long after consent has propagated.
// Invalidating here makes every click re-acquire from AAD, so a retry always reflects reality.
_graphFeatureDetector.InvalidateTenant(requestCtx.TargetTenantId);
bool isTransient;
bool accessPresent;
using (var budgetCts = new CancellationTokenSource(AccessCheckBudget))
{
GraphPermissionSnapshot snapshot;
try
{
snapshot = await _graphFeatureDetector.GetSnapshotAsync(requestCtx.TargetTenantId, budgetCts.Token);
}
catch (OperationCanceledException)
{
snapshot = new GraphPermissionSnapshot { IsTransient = true };
}
isTransient = snapshot.IsTransient;
accessPresent = ResolveAccessPresent(snapshot);
}
_telemetryClient.TrackEvent("ConsentAccessDetected", new Dictionary<string, string>
{
["TenantId"] = requestCtx.TargetTenantId,
["UserId"] = requestCtx.UserPrincipalName,
["AccessPresent"] = accessPresent.ToString(),
["IsTransient"] = isTransient.ToString(),
});
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
{
accessPresent,
isTransient,
requiredPermission = GraphAppPermissions.DeviceManagementServiceConfigReadAll,
});
return response;
}
/// <summary>
/// Pure decision for the access-check probe: does the SP snapshot prove the core validation
/// role is effectively granted? Extracted so the reconcile decision is unit testable without
/// mocking the HTTP pipeline.
/// <para>
/// A transient snapshot is NEVER "present" — we must not flip the agent gate to enabled on an
/// inconclusive probe (timeout / Graph 5xx), or the persisted bool could open the gate while the
/// role is actually absent, leaving the agent stuck in an endless 503 at the real Graph call.
/// Role comparison is case-insensitive: Azure AD may issue the role with slightly different
/// casing across tokens.
/// </para>
/// </summary>
internal static bool ResolveAccessPresent(GraphPermissionSnapshot snapshot)
{
if (snapshot == null || snapshot.IsTransient)
{
return false;
}
return snapshot.GrantedRoles.Any(r => string.Equals(
r, GraphAppPermissions.DeviceManagementServiceConfigReadAll, StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// Known consent triggers — frontend flows that initiate the AAD admin-consent dialog.
/// "device-preparation" is reserved for a future DevPrep-specific consent flow; it is
@@ -223,6 +223,7 @@ public static class EndpointAccessPolicyCatalog
new("GET", "config/{tenantId}/autopilot-device-validation/consent-status", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
new("POST", "config/{tenantId}/autopilot-device-validation/consent-failure", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
new("POST", "config/{tenantId}/autopilot-device-validation/consent-success", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
new("GET", "config/{tenantId}/autopilot-device-validation/access-check", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
new("POST", "config/{tenantId}/test-notification", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
// ── Graph add-on permissions ────────────────────────────────────────
@@ -7,6 +7,16 @@ namespace AutopilotMonitor.Shared.Models.Graph
/// </summary>
public static class GraphAppPermissions
{
/// <summary>
/// Core enrollment device-validation permission. Required to read
/// <c>windowsAutopilotDeviceIdentities</c> (Autopilot Device Validation) and
/// <c>importedDeviceIdentities</c> (Corporate Identifier Validation). Part of the
/// publisher manifest's default-consent set — present in the SP's token <c>roles</c>
/// claim once admin consent has been granted by anyone in the tenant. Used by the
/// access-check probe to silently reconcile pre-approved consent.
/// </summary>
public const string DeviceManagementServiceConfigReadAll = "DeviceManagementServiceConfig.Read.All";
/// <summary>Read display names + file names of Intune device management scripts (Platform Scripts + Remediations).</summary>
public const string DeviceManagementScriptsReadAll = "DeviceManagementScripts.Read.All";
@@ -8,6 +8,22 @@ import { useNotifications } from "../../contexts/NotificationContext";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { trackEvent } from "@/lib/appInsights";
import { classifyAccessCheck, type AccessCheckOutcome, type AccessCheckPayload } from "@/lib/accessCheck";
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"
: "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.";
import { parseSasExpiry } from "./components/DiagnosticsSection";
import { TenantConfiguration, TenantAdmin, DiagnosticsLogPath } from "./types";
import { type BootstrapSessionItem } from "./components/BootstrapSessionsSection";
@@ -48,6 +64,12 @@ interface TenantConfigContextValue {
handleToggleDeviceAssociationValidation: (newValue: boolean) => Promise<void>;
autopilotConsentInProgress: boolean;
beginDeviceValidationConsentFlow: (trigger: "autopilot" | "corporate" | "device-preparation") => Promise<void>;
/**
* Probe whether the multi-tenant app is already pre-approved in this tenant (by someone with
* consent rights) and, if so, enable validation without running the /adminconsent redirect —
* the "rights-less admin" escape hatch.
*/
detectExistingAccess: (trigger: "autopilot" | "corporate" | "device-preparation") => Promise<void>;
// Hardware whitelist
manufacturerWhitelist: string;
@@ -579,8 +601,8 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
// -----------------------------------------------------------------------
// Save configuration (shared by all sections)
// -----------------------------------------------------------------------
const saveConfiguration = useCallback(async (sectionName: string, overrides?: { validateAutopilotDevice?: boolean; validateCorporateIdentifier?: boolean; validateDeviceAssociation?: boolean }) => {
if (!tenantId || !config) return;
const saveConfiguration = useCallback(async (sectionName: string, overrides?: { validateAutopilotDevice?: boolean; validateCorporateIdentifier?: boolean; validateDeviceAssociation?: boolean }): Promise<boolean> => {
if (!tenantId || !config) return false;
try {
setSavingSection(sectionName);
@@ -672,6 +694,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
trackEvent("settings_saved", { section: sectionName });
setSuccessMessage("Configuration saved successfully!");
setTimeout(() => setSuccessMessage(null), 3000);
return true;
} catch (err) {
if (err instanceof TokenExpiredError) {
addNotification('error', 'Session Expired', err.message, 'session-expired-error');
@@ -680,6 +703,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
trackEvent("settings_error", { action: "save", section: sectionName, error: msg });
setError(msg);
}
return false;
} finally {
setSavingSection(null);
}
@@ -702,6 +726,59 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
// -----------------------------------------------------------------------
// Consent flow
// -----------------------------------------------------------------------
// Probe-only: fetch the access-check and classify it. No persistence, no messaging.
// A non-ok response or thrown error (except token-expiry) is treated as "transient" (retryable),
// never "absent" — we must not conclude "no access" from an inconclusive probe.
const probeAccessCheck = useCallback(async (): Promise<AccessCheckOutcome> => {
if (!tenantId) return "absent";
let ok = false;
let payload: AccessCheckPayload | undefined;
try {
const response = await authenticatedFetch(api.config.autopilotAccessCheck(tenantId), getAccessToken);
ok = response.ok;
if (ok) payload = await response.json();
} catch (err) {
if (err instanceof TokenExpiredError) throw err;
return "transient";
}
return classifyAccessCheck(ok, payload);
}, [tenantId, getAccessToken]);
// Persist the validation gate bool for a trigger via the shared config PUT. Returns true ONLY
// on a confirmed server persist — saveConfiguration reports failure as false (and sets the
// error). Callers MUST NOT claim success without checking this, or the admin sees "enabled"
// while the gate bool never landed. Message is caller-owned (reconcile vs normal-consent differ).
const persistValidation = useCallback(
async (trigger: ValidationTrigger): Promise<boolean> => {
if (trigger === "corporate") return saveConfiguration("autopilotValidation", { validateCorporateIdentifier: true });
if (trigger === "device-preparation") return saveConfiguration("autopilotValidation", { validateDeviceAssociation: true });
return saveConfiguration("autopilotValidation", { validateAutopilotDevice: true });
},
[saveConfiguration],
);
// Rights-less-admin reconcile: probe whether the app's core validation permission is already
// effectively granted in this tenant (pre-approved by someone with consent rights). If so,
// persist the gate bool — opening the UI badge AND the agent hard gate — without ever running
// the /adminconsent redirect the rights-less admin can't complete. Returns "reconciled"
// (enabled), "transient" (inconclusive, retry), "absent" (no access), or "failed" (access
// present but the persist did not land — so callers never show success on a swallowed save error).
const tryReconcilePreApprovedConsent = useCallback(
async (trigger: ValidationTrigger): Promise<AccessCheckOutcome | "failed"> => {
const outcome = await probeAccessCheck();
if (outcome !== "reconciled") return outcome;
const saved = await persistValidation(trigger);
if (!saved) return "failed";
setSuccessMessage(`Access is already approved by your organization — ${validationLabel(trigger)}${validationEnabledSuffix(trigger)}`);
return "reconciled";
},
[probeAccessCheck, persistValidation],
);
const beginDeviceValidationConsentFlow = useCallback(async (trigger: "autopilot" | "corporate" | "device-preparation") => {
if (!tenantId) return;
try {
@@ -760,12 +837,6 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
sessionStorage.removeItem("consentTrigger");
if (consentError) {
const errorText = consentErrorDescription
? `${consentError}: ${decodeURIComponent(consentErrorDescription)}`
: consentError;
setError(`Admin consent failed: ${errorText}`);
setAutopilotConsentInProgress(false);
// Report consent failure to backend for observability —
// without this, Azure AD errors (e.g. AADSTS50011 redirect mismatch)
// are invisible to our monitoring.
@@ -782,6 +853,36 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
// Best-effort — don't block the UI if reporting fails
}
// Rights-less-admin path: the redirect may have failed only because THIS admin lacks
// consent rights, while the app is already pre-approved in the tenant. Probe and, if the
// permission is effectively present, silently enable instead of surfacing the error.
try {
const reconcileTrigger: ValidationTrigger =
trigger === "corporate" ? "corporate" : trigger === "device-preparation" ? "device-preparation" : "autopilot";
const outcome = await tryReconcilePreApprovedConsent(reconcileTrigger);
if (outcome === "reconciled" || outcome === "failed") {
// "reconciled": silently enabled. "failed": access was present but the config persist
// failed — saveConfiguration already surfaced that error; don't overwrite it with the
// (now misleading) consent error.
setAutopilotConsentInProgress(false);
router.replace("/settings/tenant/autopilot");
return;
}
} catch (err) {
if (err instanceof TokenExpiredError) {
addNotification('error', 'Session Expired', err.message, 'session-expired-error');
setAutopilotConsentInProgress(false);
router.replace("/settings/tenant/autopilot");
return;
}
// otherwise fall through to surface the original consent error
}
const errorText = consentErrorDescription
? `${consentError}: ${decodeURIComponent(consentErrorDescription)}`
: consentError;
setError(`Admin consent failed: ${errorText}`);
setAutopilotConsentInProgress(false);
router.replace("/settings/tenant/autopilot");
return;
}
@@ -816,12 +917,25 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
// swallow — observability only
}
if (trigger === "corporate") {
await saveConfiguration("autopilotValidation", { validateCorporateIdentifier: true });
setSuccessMessage("Corporate Identifier Validation enabled. Backend agent endpoints are now unlocked for this tenant.");
// Verify the concrete role is present before opening the gate. consent-status only proves
// a token is acquirable (SP provisioned) — NOT that DeviceManagementServiceConfig.Read.All
// is in the roles claim. Persist ONLY on an authoritative "reconciled": a transient probe
// (timeout / Graph 5xx / propagation lag) is inconclusive, so opening the gate on it would
// re-introduce the exact risk this check closes (gate open while the role is unproven).
const reconcileTrigger: ValidationTrigger =
trigger === "corporate" ? "corporate" : trigger === "device-preparation" ? "device-preparation" : "autopilot";
const probe = await probeAccessCheck();
if (probe === "reconciled") {
const saved = await persistValidation(reconcileTrigger);
if (saved) {
setSuccessMessage(`${validationLabel(reconcileTrigger)}${validationEnabledSuffix(reconcileTrigger)}`);
}
} else if (probe === "transient") {
setError("Admin consent succeeded, but the required permission could not be confirmed yet (access is still propagating). Please retry in a moment — toggle the option again or use 'Detect existing access'.");
} else {
await saveConfiguration("autopilotValidation", { validateAutopilotDevice: true });
setSuccessMessage("Autopilot Device Validation enabled. Backend agent endpoints are now unlocked for this tenant.");
// "absent" — consent went through but the role is genuinely not on the app in this tenant.
setError("Admin consent succeeded, but the required permission (DeviceManagementServiceConfig.Read.All) is not granted to the app in this tenant. Ensure it is included when granting consent, then try again.");
}
router.replace("/settings/tenant/autopilot");
} catch (err) {
@@ -836,7 +950,36 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
};
handleConsentCallback();
}, [tenantId, config, router, getAccessToken, addNotification, saveConfiguration]);
}, [tenantId, config, router, getAccessToken, addNotification, tryReconcilePreApprovedConsent, probeAccessCheck, persistValidation]);
// Manual "Detect existing access" affordance — for admins who never even attempt the consent
// redirect because they know they lack consent rights. Probes and, on success, enables
// validation silently; otherwise surfaces an actionable message.
const detectExistingAccess = useCallback(async (trigger: ValidationTrigger) => {
if (!tenantId) return;
try {
setAutopilotConsentInProgress(true);
setError(null);
setSuccessMessage(null);
const outcome = await tryReconcilePreApprovedConsent(trigger);
if (outcome === "transient") {
setError("Couldn't verify access right now (timed out). Please try again in a moment.");
} else if (outcome === "absent") {
setError("No existing access detected for this tenant. Complete admin consent, or ask someone with consent rights (Application or Global Administrator) to approve the app first.");
}
// "reconciled" => success message already set by the helper.
// "failed" => access present but persist failed; saveConfiguration already set the error.
} catch (err) {
if (err instanceof TokenExpiredError) {
addNotification('error', 'Session Expired', err.message, 'session-expired-error');
} else {
setError(err instanceof Error ? err.message : "Failed to detect existing access");
}
} finally {
setAutopilotConsentInProgress(false);
}
}, [tenantId, tryReconcilePreApprovedConsent, addNotification]);
// -----------------------------------------------------------------------
// Test webhook
@@ -1250,7 +1393,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
validateCorporateIdentifier, setValidateCorporateIdentifier,
validateDeviceAssociation, setValidateDeviceAssociation,
handleToggleDeviceAssociationValidation,
autopilotConsentInProgress, beginDeviceValidationConsentFlow,
autopilotConsentInProgress, beginDeviceValidationConsentFlow, detectExistingAccess,
// Hardware whitelist
manufacturerWhitelist, setManufacturerWhitelist,
@@ -16,6 +16,11 @@ interface AutopilotValidationSectionProps {
autopilotConsentInProgress: boolean;
saving: boolean;
onBeginConsent: (trigger: 'autopilot' | 'corporate' | 'device-preparation') => void;
/**
* Probe for pre-approved consent and enable the given validation without the redirect — for
* admins without consent rights whose tenant already had the app approved by someone else.
*/
onDetectExistingAccess?: (trigger: 'autopilot' | 'corporate' | 'device-preparation') => void | Promise<void>;
}
export default function AutopilotValidationSection({
@@ -29,6 +34,7 @@ export default function AutopilotValidationSection({
autopilotConsentInProgress,
saving,
onBeginConsent,
onDetectExistingAccess,
}: AutopilotValidationSectionProps) {
const anyValidationEnabled = validateAutopilotDevice || validateCorporateIdentifier;
const [disableConfirm, setDisableConfirm] = useState<'autopilot' | 'corporate' | null>(null);
@@ -66,6 +72,20 @@ export default function AutopilotValidationSection({
setDisableConfirm(null);
};
// Per-validation "detect existing access" affordance — probes for pre-approved consent and
// enables that specific validation without the redirect. Shown under a disabled toggle.
const renderDetectButton = (trigger: 'autopilot' | 'corporate' | 'device-preparation') =>
onDetectExistingAccess ? (
<button
type="button"
onClick={() => { void onDetectExistingAccess(trigger); }}
disabled={saving || autopilotConsentInProgress}
className="text-xs font-medium text-amber-700 hover:text-amber-800 underline underline-offset-2 disabled:opacity-60 disabled:cursor-not-allowed"
>
Detect existing access
</button>
) : null;
return (
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200 bg-gradient-to-r from-amber-50 to-orange-50">
@@ -89,10 +109,15 @@ export default function AutopilotValidationSection({
</div>
</div>
<div className="p-6 space-y-5">
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3">
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3 space-y-2">
<p className="text-sm text-gray-700">
Both validations require the <strong>DeviceManagementServiceConfig.Read.All</strong> permission. Enabling either option starts Microsoft Entra admin consent if not already granted. After consent, the setting is saved automatically. Granting consent requires at least the <strong>Application Administrator</strong> or <strong>Global Administrator</strong> Entra role.
These validations require the <strong>DeviceManagementServiceConfig.Read.All</strong> permission. Enabling an option starts Microsoft Entra admin consent if not already granted. After consent, the setting is saved automatically. Granting consent requires at least the <strong>Application Administrator</strong> or <strong>Global Administrator</strong> Entra role.
</p>
{onDetectExistingAccess && (
<p className="text-sm text-gray-700">
Already approved by your organization? In larger tenants the app is often pre-approved by someone with consent rights. If so, use <strong>Detect existing access</strong> under a disabled option to enable it without running the consent flow.
</p>
)}
</div>
{/* Windows Autopilot (v1) */}
@@ -113,6 +138,7 @@ export default function AutopilotValidationSection({
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${validateAutopilotDevice ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</label>
{!validateAutopilotDevice && renderDetectButton('autopilot')}
</div>
{/* Windows Autopilot Device Preparation (v2) */}
@@ -133,6 +159,7 @@ export default function AutopilotValidationSection({
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${validateCorporateIdentifier ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</label>
{!validateCorporateIdentifier && renderDetectButton('corporate')}
</div>
{/* DevPrep Device Association — Private Preview, GA-gated, shadow-mode (no enrollment block) */}
@@ -160,6 +187,7 @@ export default function AutopilotValidationSection({
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${validateDeviceAssociation ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</label>
{!validateDeviceAssociation && renderDetectButton('device-preparation')}
</div>
)}
@@ -13,7 +13,7 @@ export function SectionAutopilotValidation() {
validateDeviceAssociation,
handleToggleDeviceAssociationValidation,
autopilotConsentInProgress, savingSection,
beginDeviceValidationConsentFlow,
beginDeviceValidationConsentFlow, detectExistingAccess,
} = useTenantConfig();
const { user, getAccessToken } = useAuth();
@@ -39,6 +39,7 @@ export function SectionAutopilotValidation() {
autopilotConsentInProgress={autopilotConsentInProgress}
saving={savingSection === "autopilotValidation"}
onBeginConsent={beginDeviceValidationConsentFlow}
onDetectExistingAccess={detectExistingAccess}
/>
<NotRegisteredDevicesInsights getAccessToken={getAccessToken} />
</>
@@ -0,0 +1,30 @@
import { describe, it, expect } from "vitest";
import { classifyAccessCheck } from "@/lib/accessCheck";
describe("classifyAccessCheck", () => {
it("reconciles only when access is authoritatively present", () => {
expect(classifyAccessCheck(true, { accessPresent: true, isTransient: false })).toBe("reconciled");
});
it("treats a non-ok HTTP response as transient (retryable), never absent", () => {
expect(classifyAccessCheck(false, undefined)).toBe("transient");
});
it("treats a transient probe as transient even if accessPresent is (stale) true", () => {
// An inconclusive probe must never flip the gate — guards against opening the agent gate
// on a timeout / Graph 5xx while the role may actually be absent.
expect(classifyAccessCheck(true, { accessPresent: true, isTransient: true })).toBe("transient");
});
it("treats a missing/undefined payload as transient", () => {
expect(classifyAccessCheck(true, undefined)).toBe("transient");
});
it("reports absent when the permission is authoritatively not granted", () => {
expect(classifyAccessCheck(true, { accessPresent: false, isTransient: false })).toBe("absent");
});
it("reports absent when accessPresent is omitted on an authoritative response", () => {
expect(classifyAccessCheck(true, { isTransient: false })).toBe("absent");
});
});
@@ -0,0 +1,31 @@
// Classification for the autopilot-device-validation "access-check" probe — the rights-less
// admin reconcile path where the multi-tenant app was pre-approved by someone else in the tenant.
export type AccessCheckOutcome = "reconciled" | "transient" | "absent";
export interface AccessCheckPayload {
accessPresent?: boolean;
isTransient?: boolean;
}
/**
* Classifies an access-check probe response into a reconcile outcome.
*
* Security-critical: an inconclusive (transient) or missing-permission (absent) probe must
* NEVER reconcile to "enabled". Reconciling persists the validation gate bool, which opens the
* agent hard gate — if we did that while the role is actually absent, the agent would pass the
* first gate and then get stuck in an endless 503 at the real Graph call. Only an authoritative
* `accessPresent === true` (with `isTransient` falsy) flips to "reconciled".
*
* @param ok whether the HTTP response was ok (non-2xx => transient/retryable, not "absent")
* @param payload parsed JSON body (may be undefined when `ok` is false)
*/
export function classifyAccessCheck(
ok: boolean,
payload: AccessCheckPayload | undefined,
): AccessCheckOutcome {
if (!ok) return "transient";
if (!payload || payload.isTransient) return "transient";
if (!payload.accessPresent) return "absent";
return "reconciled";
}
+8
View File
@@ -109,6 +109,14 @@ export const api = {
`${API_BASE_URL}/api/config/${tenantId}/autopilot-device-validation/consent-failure`,
autopilotConsentSuccess: (tenantId: string) =>
`${API_BASE_URL}/api/config/${tenantId}/autopilot-device-validation/consent-success`,
/**
* Probe whether the app's core validation permission is already effectively granted in
* the tenant (pre-approved by someone with consent rights), so a rights-less admin can be
* enabled without running the failing /adminconsent redirect. Returns
* { accessPresent, isTransient, requiredPermission }.
*/
autopilotAccessCheck: (tenantId: string) =>
`${API_BASE_URL}/api/config/${tenantId}/autopilot-device-validation/access-check`,
testNotification: (tenantId: string) =>
`${API_BASE_URL}/api/config/${tenantId}/test-notification`,
latestVersions: (opts?: { refresh?: boolean }) =>