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) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-05-08 14:51:54 +02:00
parent 5ae7dc40af
commit 6658303c0d
10 changed files with 88 additions and 7 deletions
@@ -1 +1 @@
643
644
@@ -1 +1 @@
1278
1279
@@ -146,6 +146,67 @@ public class AutopilotDeviceValidationConsentFunction
return response;
}
/// <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
/// accepted by the success endpoint so the value can land in ops events when wired up.
/// </summary>
internal static readonly HashSet<string> KnownConsentTriggers = new(StringComparer.OrdinalIgnoreCase)
{
"autopilot",
"corporate",
"device-preparation",
};
/// <summary>
/// Frontend confirms a consent flow succeeded — called from the post-redirect callback
/// after <c>consent-status</c> reports <c>isConsented=true</c>. Pairs with
/// <c>ConsentFlowStarted</c> / <c>ConsentFlowFailed</c> so ops can see whether
/// repeated failures eventually resolved into a success.
/// </summary>
[Function("ReportConsentSuccess")]
public async Task<HttpResponseData> 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<ConsentSuccessReport>();
}
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<string, string>
{
["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; }
}
/// <summary>
/// Frontend reports consent flow failures (Azure AD errors) so we have visibility
/// into broken consent flows that never reach our callback.
@@ -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 ────────────────────────────────────────
@@ -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}",
@@ -5,7 +5,7 @@ import { type OpsAlertRule } from "../AdminConfigContext";
// All known ops event types grouped by category
const OPS_EVENT_TYPES: Record<string, string[]> = {
Consent: ["ConsentFlowStarted", "ConsentFlowFailed", "ConsentRedirectUriMismatch"],
Consent: ["ConsentFlowStarted", "ConsentFlowSuccess", "ConsentFlowFailed", "ConsentRedirectUriMismatch"],
Maintenance: ["MaintenanceCompleted", "MaintenanceFailed", "OpsEventCleanup", "SessionTimeouts"],
Security: ["DeviceBlocked", "ExcessiveDataBlocked", "VersionBlocked"],
Tenant: ["TenantOffboarded"],
@@ -35,7 +35,7 @@ interface TenantConfigContextValue {
/** Toggle + persist DevPrep Device Association validation in one shot (no consent flow needed). */
handleToggleDeviceAssociationValidation: (newValue: boolean) => Promise<void>;
autopilotConsentInProgress: boolean;
beginDeviceValidationConsentFlow: (trigger: "autopilot" | "corporate") => Promise<void>;
beginDeviceValidationConsentFlow: (trigger: "autopilot" | "corporate" | "device-preparation") => Promise<void>;
// 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.");
@@ -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({
+2
View File
@@ -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 }) =>