From 6658303c0d97e70dccf8c2bfdedb020c2a7302f4 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Fri, 8 May 2026 14:51:54 +0200 Subject: [PATCH] feat(ops/consent): emit ConsentFlowSuccess to close the consent-flow lifecycle Pairs with ConsentFlowStarted/Failed in the OpsEvents table so admins can see whether repeated consent failures (AADSTS, redirect-mismatch, denied) eventually resolved into a successful grant. New POST endpoint config/{tenantId}/autopilot-device-validation/consent-success is called from the post-redirect callback only after consent-status confirms isConsented=true, so generic status polls do not fire false positives. Trigger whitelist accepts autopilot, corporate, and device-preparation; the third value is wired through the type system end-to-end so a future DevPrep-specific consent flow can use it without a schema bump. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/commands | 2 +- .../buildcounter.txt | 2 +- .../AutopilotMonitor.Agent/buildcounter.txt | 2 +- ...utopilotDeviceValidationConsentFunction.cs | 61 +++++++++++++++++++ .../Security/EndpointAccessPolicyCatalog.cs | 1 + .../Services/OpsEventService.cs | 5 ++ .../admin/components/OpsAlertRulesSection.tsx | 2 +- .../app/settings/TenantConfigContext.tsx | 16 ++++- .../components/AutopilotValidationSection.tsx | 2 +- src/Web/autopilot-monitor-web/lib/api.ts | 2 + 10 files changed, 88 insertions(+), 7 deletions(-) diff --git a/.claude/commands b/.claude/commands index dc0ebd69..fbf0ff62 160000 --- a/.claude/commands +++ b/.claude/commands @@ -1 +1 @@ -Subproject commit dc0ebd69af810835d0bf8f5de13a641450ffee84 +Subproject commit fbf0ff6214c771c152300d2b424b39278f9d734e diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt index fb8477e5..51e227a6 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt @@ -1 +1 @@ -643 +644 diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index 87e36611..5e9ad0bf 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -1278 +1279 diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/AutopilotDeviceValidationConsentFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/AutopilotDeviceValidationConsentFunction.cs index a7100932..963766ec 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/AutopilotDeviceValidationConsentFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/AutopilotDeviceValidationConsentFunction.cs @@ -146,6 +146,67 @@ public class AutopilotDeviceValidationConsentFunction return response; } + /// + /// 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 + /// accepted by the success endpoint so the value can land in ops events when wired up. + /// + internal static readonly HashSet KnownConsentTriggers = new(StringComparer.OrdinalIgnoreCase) + { + "autopilot", + "corporate", + "device-preparation", + }; + + /// + /// Frontend confirms a consent flow succeeded — called from the post-redirect callback + /// after consent-status reports isConsented=true. Pairs with + /// ConsentFlowStarted / ConsentFlowFailed so ops can see whether + /// repeated failures eventually resolved into a success. + /// + [Function("ReportConsentSuccess")] + public async Task ReportConsentSuccess( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "config/{tenantId}/autopilot-device-validation/consent-success")] HttpRequestData req, + string tenantId) + { + // Authentication enforced by PolicyEnforcementMiddleware + var requestCtx = req.GetRequestContext(); + + ConsentSuccessReport? report; + try + { + report = await req.ReadFromJsonAsync(); + } + catch + { + report = null; + } + + var rawTrigger = report?.Trigger ?? string.Empty; + var trigger = KnownConsentTriggers.Contains(rawTrigger) ? rawTrigger.ToLowerInvariant() : "unknown"; + + _logger.LogInformation( + "ConsentFlowSuccess: tenant={TenantId} user={UserId} trigger={Trigger}", + requestCtx.TargetTenantId, requestCtx.UserPrincipalName, trigger); + + _telemetryClient.TrackEvent("ConsentFlowSuccess", new Dictionary + { + ["TenantId"] = requestCtx.TargetTenantId, + ["UserId"] = requestCtx.UserPrincipalName, + ["Trigger"] = trigger, + }); + + await _opsEventService.RecordConsentFlowSuccessAsync( + requestCtx.TargetTenantId, requestCtx.UserPrincipalName, trigger); + + return req.CreateResponse(HttpStatusCode.OK); + } + + private sealed class ConsentSuccessReport + { + public string? Trigger { get; set; } + } + /// /// Frontend reports consent flow failures (Azure AD errors) so we have visibility /// into broken consent flows that never reach our callback. diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs index bdb6414c..19e3c2cf 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs @@ -190,6 +190,7 @@ public static class EndpointAccessPolicyCatalog new("GET", "config/{tenantId}/autopilot-device-validation/consent-url", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam), 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("POST", "config/{tenantId}/test-notification", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam), // ── BootstrapManagerOrGA ──────────────────────────────────────── diff --git a/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs b/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs index 7f0b7e27..f38df64d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs @@ -33,6 +33,11 @@ namespace AutopilotMonitor.Functions.Services $"Admin consent flow started by {userId}", tenantId, userId, new { redirectUri }); + public Task RecordConsentFlowSuccessAsync(string tenantId, string userId, string trigger) + => WriteAsync(OpsEventCategory.Consent, "ConsentFlowSuccess", OpsEventSeverity.Info, + $"Admin consent confirmed for {trigger} by {userId}", + tenantId, userId, new { trigger }); + public Task RecordConsentFlowFailedAsync(string tenantId, string userId, string error, string errorDescription) => WriteAsync(OpsEventCategory.Consent, "ConsentFlowFailed", OpsEventSeverity.Error, $"Admin consent failed: {error}", diff --git a/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx index db015270..5f9e8e00 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx @@ -5,7 +5,7 @@ import { type OpsAlertRule } from "../AdminConfigContext"; // All known ops event types grouped by category const OPS_EVENT_TYPES: Record = { - Consent: ["ConsentFlowStarted", "ConsentFlowFailed", "ConsentRedirectUriMismatch"], + Consent: ["ConsentFlowStarted", "ConsentFlowSuccess", "ConsentFlowFailed", "ConsentRedirectUriMismatch"], Maintenance: ["MaintenanceCompleted", "MaintenanceFailed", "OpsEventCleanup", "SessionTimeouts"], Security: ["DeviceBlocked", "ExcessiveDataBlocked", "VersionBlocked"], Tenant: ["TenantOffboarded"], diff --git a/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx b/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx index cf630849..4915521e 100644 --- a/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx @@ -35,7 +35,7 @@ interface TenantConfigContextValue { /** Toggle + persist DevPrep Device Association validation in one shot (no consent flow needed). */ handleToggleDeviceAssociationValidation: (newValue: boolean) => Promise; autopilotConsentInProgress: boolean; - beginDeviceValidationConsentFlow: (trigger: "autopilot" | "corporate") => Promise; + beginDeviceValidationConsentFlow: (trigger: "autopilot" | "corporate" | "device-preparation") => Promise; // Hardware whitelist manufacturerWhitelist: string; @@ -658,7 +658,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } // ----------------------------------------------------------------------- // Consent flow // ----------------------------------------------------------------------- - const beginDeviceValidationConsentFlow = useCallback(async (trigger: "autopilot" | "corporate") => { + const beginDeviceValidationConsentFlow = useCallback(async (trigger: "autopilot" | "corporate" | "device-preparation") => { if (!tenantId) return; try { setAutopilotConsentInProgress(true); @@ -760,6 +760,18 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } throw new Error(statusData.message || "Consent is not active yet for this tenant."); } + // Best-effort ops-event: pairs with ConsentFlowStarted/Failed so admins can see + // whether repeated failures eventually resolved. Don't block the UI if it fails. + try { + await authenticatedFetch(api.config.autopilotConsentSuccess(tenantId), getAccessToken, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ trigger }), + }); + } catch { + // 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."); 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 0d462531..a7e69907 100644 --- a/src/Web/autopilot-monitor-web/app/settings/components/AutopilotValidationSection.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/components/AutopilotValidationSection.tsx @@ -15,7 +15,7 @@ interface AutopilotValidationSectionProps { showDeviceAssociationToggle?: boolean; autopilotConsentInProgress: boolean; saving: boolean; - onBeginConsent: (trigger: 'autopilot' | 'corporate') => void; + onBeginConsent: (trigger: 'autopilot' | 'corporate' | 'device-preparation') => void; } export default function AutopilotValidationSection({ diff --git a/src/Web/autopilot-monitor-web/lib/api.ts b/src/Web/autopilot-monitor-web/lib/api.ts index cfb90665..96f555d2 100644 --- a/src/Web/autopilot-monitor-web/lib/api.ts +++ b/src/Web/autopilot-monitor-web/lib/api.ts @@ -96,6 +96,8 @@ export const api = { `${API_BASE_URL}/api/config/${tenantId}/autopilot-device-validation/consent-status`, autopilotConsentFailure: (tenantId: string) => `${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`, testNotification: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}/test-notification`, latestVersions: (opts?: { refresh?: boolean }) =>