diff --git a/scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1 b/scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1 index b909139ae..669db564c 100644 --- a/scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1 +++ b/scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1 @@ -75,15 +75,17 @@ .NOTES Requires: - Microsoft.Graph.Authentication PowerShell module (auto-installs if missing). - - Sign-in as Global Administrator OR Privileged Role Administrator OR Cloud Application Administrator. + - Sign-in as Global Administrator OR Privileged Role Administrator (with PIM, activate the + role first). Only these roles may assign Microsoft Graph application permissions; + Application Administrator and Cloud Application Administrator are not enough. + - The Autopilot Monitor app must be admin-consented in your tenant first + (its service principal must exist before grants can be added). Sign-in behaviour: In Azure Cloud Shell the script signs in via the ambient identity endpoint (a delegated token for the already-signed-in user) - no device-code prompt, which Conditional Access policies commonly block. Outside Cloud Shell it falls back to a normal interactive Microsoft Graph sign-in. -TenantId only applies to the interactive fallback. - - The Autopilot Monitor app must be admin-consented in your tenant first - (its service principal must exist before grants can be added). After granting: The Autopilot Monitor backend may cache an older token for up to ~1 hour. Use the @@ -139,6 +141,36 @@ if ($PSCmdlet.ParameterSetName -eq 'ByFeatures') { # ---- Constants ---- $MicrosoftGraphAppId = '00000003-0000-0000-c000-000000000000' +# ---- Graph failure details ---- +# Invoke-MgGraphRequest puts only "Response status code does not indicate success: ..." into the +# exception message. Graph's own error (code + message) is the response body in ErrorDetails, +# behind the request line, the status line and the headers. Some headers carry JSON themselves +# (x-ms-ags-diagnostic), so the body is taken from behind the blank line that ends the headers. +function Get-GraphFailure { + param([System.Management.Automation.ErrorRecord]$ErrorRecord) + + $status = 0 + if ($ErrorRecord.Exception.PSObject.Properties['Response'] -and $ErrorRecord.Exception.Response) { + $status = [int]$ErrorRecord.Exception.Response.StatusCode + } + + $text = $ErrorRecord.Exception.Message + if ($ErrorRecord.ErrorDetails -and $ErrorRecord.ErrorDetails.Message) { + $body = ($ErrorRecord.ErrorDetails.Message -split '\r?\n\r?\n', 2)[-1].Trim() + if ($body.StartsWith('{')) { + try { + $graphError = ($body | ConvertFrom-Json).error + if ($graphError.code) { $text = "$($graphError.code): $($graphError.message)" } + } + catch { + # Not a Graph JSON body - keep the exception message. + } + } + } + + [pscustomobject]@{ Status = $status; Text = $text } +} + # ---- Pre-flight: module ---- if (-not (Get-Module -ListAvailable -Name Microsoft.Graph.Authentication)) { Write-Host "[Setup] Microsoft.Graph.Authentication not installed. Installing for current user..." -ForegroundColor Yellow @@ -188,7 +220,7 @@ Write-Host "[Auth] Granted scopes: $($ctx.Scopes -join ', ')" # carries a fixed scope set (incl. Directory.AccessAsUser.All) instead of the requested scopes, # and its effective rights follow the signed-in user's directory role. if (-not $identityConnected -and -not ($ctx.Scopes -contains 'AppRoleAssignment.ReadWrite.All')) { - Write-Warning "Scope 'AppRoleAssignment.ReadWrite.All' was NOT granted. The signed-in user likely lacks the required role (Global Admin / Privileged Role Admin / Cloud App Admin)." + Write-Warning "Scope 'AppRoleAssignment.ReadWrite.All' was NOT granted. The signed-in user likely lacks the required role (Global Administrator or Privileged Role Administrator)." if (-not $VerifyOnly) { return } } @@ -262,6 +294,7 @@ $action = if ($Revoke) { 'Revoke' } else { 'Grant' } Write-Host "[4/4] $action mode - processing $($Permissions.Count) permission(s)..." -ForegroundColor Cyan $results = @() +$graphForbidden = $false foreach ($permName in $Permissions) { $appRole = $graphSp.appRoles | Where-Object { $_.value -eq $permName -and $_.allowedMemberTypes -contains 'Application' @@ -288,7 +321,9 @@ foreach ($permName in $Permissions) { $results += [pscustomobject]@{ Permission = $permName; Status = 'Revoked' } } catch { - Write-Warning " [FAIL] '$permName' revoke failed: $($_.Exception.Message)" + $failure = Get-GraphFailure $_ + if ($failure.Status -eq 403) { $graphForbidden = $true } + Write-Warning " [FAIL] '$permName' revoke failed: $($failure.Text)" $results += [pscustomobject]@{ Permission = $permName; Status = 'Failed' } } } @@ -315,7 +350,9 @@ foreach ($permName in $Permissions) { $results += [pscustomobject]@{ Permission = $permName; Status = 'Granted' } } catch { - Write-Warning " [FAIL] '$permName' grant failed: $($_.Exception.Message)" + $failure = Get-GraphFailure $_ + if ($failure.Status -eq 403) { $graphForbidden = $true } + Write-Warning " [FAIL] '$permName' grant failed: $($failure.Text)" $results += [pscustomobject]@{ Permission = $permName; Status = 'Failed' } } } @@ -324,6 +361,9 @@ foreach ($permName in $Permissions) { Write-Host "" Write-Host "=========== Summary ===========" -ForegroundColor Cyan $results | Format-Table -AutoSize | Out-Host +if ($graphForbidden) { + Write-Warning "Microsoft Graph refused the change (403): the signed-in account may not manage Microsoft Graph application permissions. Sign in as Global Administrator or Privileged Role Administrator (with PIM, activate the role first) and run the script again." +} Write-Host "" Write-Host "Note: token-cached backends may need up to ~1h to see the new permission set." -ForegroundColor Yellow Write-Host " Use 'Refresh permission status' in the admin UI to apply changes immediately." -ForegroundColor Yellow diff --git a/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx b/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx index 5f1c6c381..4a5e709d8 100644 --- a/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx @@ -743,6 +743,18 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } // that still reaches a save (the backend would 403 the PATCH regardless). if (!tenantId || !config || !canEditConfig) return false; + // The validation gates and Unrestricted Mode have no save bar, so their form state must always + // equal the stored row. Some of their toggles move the state before this save runs, so a failed + // save puts it back: success syncs to the merged row, failure to the loaded one. + const syncGateState = (stored: TenantConfiguration) => { + setValidateAutopilotDevice(stored.validateAutopilotDevice); + setValidateCorporateIdentifier(stored.validateCorporateIdentifier ?? false); + setValidateDeviceAssociation(stored.validateDeviceAssociation ?? false); + setValidateCloudPcDevice(stored.validateCloudPcDevice ?? false); + setValidateIntuneDeviceBinding(stored.validateIntuneDeviceBinding ?? false); + setUnrestrictedMode(stored.unrestrictedMode ?? false); + }; + try { setSavingSection(sectionName); setError(null); @@ -836,18 +848,13 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } // Sync the gate-relevant form state with what is now persisted (merge of loaded // config + this patch) — mirrors the old PUT-response sync. - const persisted = { ...config, ...patchFields } as TenantConfiguration; - setValidateAutopilotDevice(persisted.validateAutopilotDevice); - setValidateCorporateIdentifier(persisted.validateCorporateIdentifier ?? false); - setValidateDeviceAssociation(persisted.validateDeviceAssociation ?? false); - setValidateCloudPcDevice(persisted.validateCloudPcDevice ?? false); - setValidateIntuneDeviceBinding(persisted.validateIntuneDeviceBinding ?? false); - setUnrestrictedMode(persisted.unrestrictedMode ?? false); + syncGateState({ ...config, ...patchFields } as TenantConfiguration); trackEvent("settings_saved", { section: sectionName, fieldCount: Object.keys(patchFields).length }); setSuccessMessage("Configuration saved successfully!"); setTimeout(() => setSuccessMessage(null), 3000); return true; } catch (err) { + syncGateState(config); const msg = apiErrorText(err, "Failed to save configuration"); trackEvent("settings_error", { action: "save", section: sectionName, error: msg }); setError(msg); @@ -900,8 +907,8 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } }, [tenantId, getAccessToken, noteHomingProbe]); // Direct gate persist for the toggle UI (disable + second-gate enable): same shared config - // PUT, explicit override values. saveConfiguration re-syncs the local gate state from the - // server response, so the toggle reflects the persisted truth (or snaps back on failure). + // PATCH, explicit override values. saveConfiguration re-syncs the local gate state to the stored + // row (the merge on success, the loaded row on failure), so the toggle shows the persisted truth. const saveValidationGate = useCallback( (changes: { validateAutopilotDevice?: boolean; validateCorporateIdentifier?: boolean; validateDeviceAssociation?: boolean }): Promise => saveConfiguration("autopilotValidation", changes), @@ -1310,7 +1317,8 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } * Toggle the Windows 365 Cloud PC validation fallback. No consent flow — the backing * CloudPC.Read.All permission is an Optional Graph capabilities add-on (grant script); * without the grant the backend simply keeps rejecting Cloud PCs with a pointer to the - * add-on, so enabling early is safe. + * add-on, so enabling early is safe. The toggle moves at once; a failed save moves it back + * (saveConfiguration re-syncs the gates). */ const handleToggleCloudPcValidation = useCallback(async (newValue: boolean) => { setValidateCloudPcDevice(newValue); @@ -1321,7 +1329,8 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } * 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). + * (the backend answers 503 Retry-After, like a missing core consent). Moves at once and back on + * a failed save, like the Cloud PC toggle. */ const handleToggleIntuneDeviceBinding = useCallback(async (newValue: boolean) => { setValidateIntuneDeviceBinding(newValue); diff --git a/src/Web/autopilot-monitor-web/app/settings/components/AppHomingAddOnStep.tsx b/src/Web/autopilot-monitor-web/app/settings/components/AppHomingAddOnStep.tsx index ad5e01746..2d6d7f799 100644 --- a/src/Web/autopilot-monitor-web/app/settings/components/AppHomingAddOnStep.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/components/AppHomingAddOnStep.tsx @@ -59,11 +59,10 @@ export function AppHomingAddOnStep({ missingRoles, command, busy, onDetectExisti ))}

- Run the command below once with an account that can assign application permissions - (Global Administrator, Privileged Role Administrator or{" "} - Cloud Application Administrator) — Azure Cloud Shell is the easiest - place. Then click Detect existing access: the switch completes - automatically. + Run the command below once, signed in as a Global Administrator or{" "} + Privileged Role Administrator (only these Entra roles can grant Microsoft + Graph application permissions) — Azure Cloud Shell is the easiest place. Then click{" "} + Detect existing access: the switch completes automatically.

 {command}
diff --git a/src/Web/autopilot-monitor-web/app/settings/components/__tests__/AppHomingAddOnStep.test.ts b/src/Web/autopilot-monitor-web/app/settings/components/__tests__/AppHomingAddOnStep.test.ts
index a444bc291..81c06b954 100644
--- a/src/Web/autopilot-monitor-web/app/settings/components/__tests__/AppHomingAddOnStep.test.ts
+++ b/src/Web/autopilot-monitor-web/app/settings/components/__tests__/AppHomingAddOnStep.test.ts
@@ -17,6 +17,10 @@ describe("AppHomingAddOnStep", () => {
     expect(html).toContain('-ClientId "886ab5e2-0000-0000-0000-000000000000"');
     expect(html).toContain("Copy command");
     expect(html).toContain("Detect existing access");
+    // Only these two Entra roles can grant Microsoft Graph application permissions.
+    expect(html).toContain("Global Administrator");
+    expect(html).toContain("Privileged Role Administrator");
+    expect(html).not.toContain("Cloud Application Administrator");
     expect(html).toContain(`href="${DOCS_URL}/troubleshooting-and-support/app-registration-migration#optional-graph-add-on-permissions"`);
     expect(html).toContain(`href="${DOCS_URL}/reference/optional-graph-permissions"`);
     // Progress, not an error: stays in the blue family of the funnel banner.
diff --git a/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionOptionalGraphCapabilities.tsx b/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionOptionalGraphCapabilities.tsx
index c14ec35dd..11efc4a60 100644
--- a/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionOptionalGraphCapabilities.tsx
+++ b/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionOptionalGraphCapabilities.tsx
@@ -237,7 +237,8 @@ export function SectionOptionalGraphCapabilities() {
         

PowerShell grant command

- Run the commands below as a tenant administrator in{" "} + Run the commands below signed in as a Global Administrator or Privileged Role Administrator + (only these Entra roles can grant Microsoft Graph application permissions) in{" "} = { "app/dashboard/hooks/deleteSessionResponse.ts": 1, "app/health-check/page.tsx": 1, "components/landing/StatsBand.tsx": 2, - "contexts/AuthContext.tsx": 3, + "contexts/AuthContext.tsx": 2, }; /** diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/authMeRefusal.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/authMeRefusal.test.ts new file mode 100644 index 000000000..188c1c88b --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/__tests__/authMeRefusal.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from "vitest"; +import { apiErrorFromResponse } from "../apiClient"; +import { classifyAuthMeRefusal } from "../authMeRefusal"; + +vi.mock("../appInsights", () => ({ trackEvent: vi.fn() })); + +/** + * auth/me refuses in two body shapes; both must land on the right portal page. The envelope + * case is the one that regressed: since the typed envelope (2026-09-05) the policy middleware's + * suspension gate carries the code in `code`, and the old `error === "TenantSuspended"` check + * never matched — suspended and offboarded tenants saw neither page. + */ +async function refusal(body: unknown) { + const response = new Response(typeof body === "string" ? body : JSON.stringify(body), { + status: 403, + statusText: "Forbidden", + headers: { "Content-Type": "application/json" }, + }); + return classifyAuthMeRefusal(await apiErrorFromResponse(response)); +} + +describe("classifyAuthMeRefusal", () => { + it("reads the policy middleware's envelope for a suspended tenant", async () => { + expect( + await refusal({ + error: "Your tenant has been suspended. Please contact support for more information.", + code: "TenantSuspended", + correlationId: "3f2a9c1e-7b4d-4e0a-9d2c-1a2b3c4d5e6f", + }), + ).toEqual({ + kind: "suspended", + message: "Your tenant has been suspended. Please contact support for more information.", + }); + }); + + it("keeps the offboarding tombstone text, which switches ProtectedRoute to the farewell page", async () => { + expect(await refusal({ error: "Offboarding in progress", code: "TenantSuspended" })).toEqual({ + kind: "suspended", + message: "Offboarding in progress", + }); + }); + + it("reads AuthFunction's pre-envelope body for a suspended tenant", async () => { + expect( + await refusal({ error: "TenantSuspended", message: "Maintenance window", disabledUntil: null, contactSupport: true }), + ).toEqual({ kind: "suspended", message: "Maintenance window" }); + }); + + it("reads the activation gate and its legacy PrivatePreview spelling", async () => { + expect(await refusal({ error: "PendingActivation", message: "Your tenant is being activated." })).toEqual({ + kind: "activationPending", + message: "Your tenant is being activated.", + }); + expect(await refusal({ error: "PrivatePreview" })).toEqual({ kind: "activationPending", message: null }); + }); + + it("returns null for any other refusal", async () => { + expect( + await refusal({ + error: "Access denied. You do not have permission to access this resource.", + code: "InsufficientPermissions", + }), + ).toBeNull(); + // A suspension text in the envelope's `error` must not be mistaken for the code. + expect(await refusal({ error: "TenantSuspended", code: "Forbidden" })).toBeNull(); + expect(await refusal("Forbidden")).toBeNull(); + }); +}); diff --git a/src/Web/autopilot-monitor-web/lib/authMeRefusal.ts b/src/Web/autopilot-monitor-web/lib/authMeRefusal.ts new file mode 100644 index 000000000..88f9dc7da --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/authMeRefusal.ts @@ -0,0 +1,32 @@ +import type { ApiError } from "@/lib/apiClient"; +import type { ApiErrorCode } from "@/lib/apiErrorCodes"; + +/** What a 403 from auth/me means for the portal shell (ProtectedRoute renders the matching page). */ +export type AuthMeRefusal = + | { kind: "suspended"; message: string | null } + | { kind: "activationPending"; message: string | null }; + +const TENANT_SUSPENDED = "TenantSuspended" satisfies ApiErrorCode; +/** AuthFunction's activation gate; "PrivatePreview" is the legacy spelling, kept so web and backend deploy in any order. */ +const ACTIVATION_PENDING_CODES: ReadonlySet = new Set(["PendingActivation", "PrivatePreview"]); + +/** + * Classify auth/me's refusal. Two body shapes reach the SPA: + * - the error envelope `{ error: , code, correlationId }` — written by the policy middleware, + * whose tenant-suspension gate answers BEFORE AuthFunction runs (every caller without platform + * scope of a suspended or offboarded tenant); + * - AuthFunction's own pre-envelope body `{ error: , message: }` — the activation gate, + * and the suspension gate for platform callers. + * apiErrorFromResponse maps `error` to ApiError.message and `code` to ApiError.code, so an empty + * code marks the pre-envelope body, whose code then sits in the message. + */ +export function classifyAuthMeRefusal(err: ApiError): AuthMeRefusal | null { + const envelope = err.code !== ""; + const code = envelope ? err.code : err.message; + const bodyMessage = err.body?.message; + const message = envelope ? err.message : typeof bodyMessage === "string" && bodyMessage.length > 0 ? bodyMessage : null; + + if (code === TENANT_SUSPENDED) return { kind: "suspended", message }; + if (ACTIVATION_PENDING_CODES.has(code)) return { kind: "activationPending", message }; + return null; +}