mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
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:
+1
-1
Submodule .claude/commands updated: dc0ebd69af...fbf0ff6214
@@ -1 +1 @@
|
||||
643
|
||||
644
|
||||
|
||||
@@ -1 +1 @@
|
||||
1278
|
||||
1279
|
||||
|
||||
+61
@@ -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.");
|
||||
|
||||
+1
-1
@@ -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({
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
Reference in New Issue
Block a user