Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 46 additions & 6 deletions scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }
}

Expand Down Expand Up @@ -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'
Expand All @@ -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' }
}
}
Expand All @@ -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' }
}
}
Expand All @@ -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
Expand Down
31 changes: 20 additions & 11 deletions src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<boolean> =>
saveConfiguration("autopilotValidation", changes),
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,10 @@ export function AppHomingAddOnStep({ missingRoles, command, busy, onDetectExisti
))}
</ul>
<p className="mt-1.5 text-blue-800 dark:text-blue-200">
Run the command below once with an account that can assign application permissions
(<strong>Global Administrator</strong>, <strong>Privileged Role Administrator</strong> or{" "}
<strong>Cloud Application Administrator</strong>) — Azure Cloud Shell is the easiest
place. Then click <strong>Detect existing access</strong>: the switch completes
automatically.
Run the command below once, signed in as a <strong>Global Administrator</strong> or{" "}
<strong>Privileged Role Administrator</strong> (only these Entra roles can grant Microsoft
Graph application permissions) — Azure Cloud Shell is the easiest place. Then click{" "}
<strong>Detect existing access</strong>: the switch completes automatically.
</p>
<pre className="mt-2.5 bg-gray-900 text-gray-100 text-xs font-mono p-3 rounded overflow-x-auto">
{command}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ describe("AppHomingAddOnStep", () => {
expect(html).toContain('-ClientId &quot;886ab5e2-0000-0000-0000-000000000000&quot;');
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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,8 @@ export function SectionOptionalGraphCapabilities() {
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-md font-semibold text-gray-900 mb-2">PowerShell grant command</h3>
<p className="text-sm text-gray-600 mb-3">
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{" "}
<a
href="https://shell.azure.com"
target="_blank"
Expand Down
18 changes: 10 additions & 8 deletions src/Web/autopilot-monitor-web/contexts/AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
tryBeginLoginFallback,
} from '@/lib/authApp';
import { api } from '@/lib/api';
import { apiErrorFromResponse } from '@/lib/apiClient';
import { classifyAuthMeRefusal } from '@/lib/authMeRefusal';
import { clearCachedAuthFetch } from '@/lib/cachedAuthFetch';
import { trackEvent } from '@/lib/appInsights';
import { seedDashboardFirstFetch } from '@/lib/dashboardSeed';
Expand Down Expand Up @@ -369,23 +371,23 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) {

if (!response.ok) {
if (response.status === 403) {
const errorData = await response.json();
// Both refusal body shapes (policy-middleware envelope, AuthFunction's own gates) are
// read by classifyAuthMeRefusal.
const refusal = classifyAuthMeRefusal(await apiErrorFromResponse(response));
// Suspended (operator kill-switch or the offboarding tombstone): keep the user signed
// in with claim-only info so ProtectedRoute renders the suspended page — an admin who
// just offboarded their tenant reloads into the farewell feedback form, not into a
// browser alert and a forced logout.
if (errorData.error === 'TenantSuspended') {
console.warn('[Auth] Tenant suspended:', errorData.message);
if (refusal?.kind === 'suspended') {
console.warn('[Auth] Tenant suspended:', refusal.message);
setTenantSuspended(true);
setSuspensionMessage(errorData.message || 'Your tenant has been suspended.');
setSuspensionMessage(refusal.message || 'Your tenant has been suspended.');
return claimsOnlyUserInfo(account);
}
// 'PendingActivation' is the current backend code; 'PrivatePreview' is the legacy
// code kept accepted so web and backend can deploy in any order.
if (errorData.error === 'PendingActivation' || errorData.error === 'PrivatePreview') {
if (refusal?.kind === 'activationPending') {
console.log('[Auth] Tenant not yet activated');
setActivationPending(true);
setActivationMessage(errorData.message || 'Your organization is being activated.');
setActivationMessage(refusal.message || 'Your organization is being activated.');
// Return basic user info so the user stays logged in but sees the activation page
return claimsOnlyUserInfo(account);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export const JSONPARSE_BASELINE: Record<string, number> = {
"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,
};

/**
Expand Down
68 changes: 68 additions & 0 deletions src/Web/autopilot-monitor-web/lib/__tests__/authMeRefusal.test.ts
Original file line number Diff line number Diff line change
@@ -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("<html>Forbidden</html>")).toBeNull();
});
});
Loading
Loading