Files
AutopilotMonitor/scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1
T
Oliver Kieselbach e4c4dd57db feat(backend,web): optional Graph add-on permissions for Intune script display names
Adds opt-in resolution of Intune Platform Script + Remediation display names in
session timelines via a tenant-side appRoleAssignment grant. No app manifest
change, no impact on customers who don't enable it. Architecture is reusable for
future optional Graph permissions: catalog -> detector -> resolver -> endpoint.

Backend
- GraphFeatureDetector parses the `roles` claim from the SP access token and
  caches the (token, roles) bundle per tenant. GetSnapshotAsync surfaces a
  transient flag so admin-UI callers can render "unknown" instead of a
  misleading "0 of 0 granted" when token acquire times out.
- GraphTokenService now accepts a CancellationToken so optional-feature callers
  can apply tight budgets instead of inheriting the 5+15+30 s retry chain.
- ScriptDisplayNameResolver: cache-first, list-pull-on-cold-tenant, per-ID
  fallback. Throttle-aware abort: skip per-ID for kinds whose full-pull hit a
  transient failure; break the per-ID loop on the first HttpRequestException.
  Total budget 5 s, with a tight 1.5 s sub-budget on the token acquire.
- Azure Table `ScriptNameCache` + direct-TableClient fail-loud repository
  (mirrors TableOffboardingAuditRepository pattern). Daily
  ScriptNameCacheCleanupFunction: 7 d positive / 1 h NotFound TTL.
- POST /api/tenants/{id}/scripts/display-names (body-carried refs to dodge URL
  length limits; backend cap 200 refs/request).
- GET /api/tenants/{id}/graph-permissions/status (4 s budget; includes app
  ClientId so the UI can render an exact grant command).
- POST /api/tenants/{id}/graph-permissions/refresh (invalidates token cache).
- All three routes registered in EndpointAccessPolicyCatalog (MemberRead for the
  resolve, TenantAdminOrGA for the admin-UI surfaces).

Web
- `useScriptDisplayNames` hook with stable refset fingerprint (no spurious
  refetches on parent re-renders); clears stale map on tenant/refset change so
  a session switch never leaks names across tenants.
- Chunked POST loop: 200-ref batches with partial-success semantics. Preserves
  earlier chunks when later ones fail (non-2xx OR transport throw); only
  TokenExpiredError propagates so auth flow can react.
- ScriptExecutions renders " . {DisplayName}" alongside existing label/short-id,
  keyed by Kind + policyId so Platform/Remediation under the same id never
  collide.
- New Settings section "Optional Graph capabilities": status table with granted
  / not granted / unknown, copy-paste PS one-liner with ClientId baked in,
  refresh button gated on response.ok.

Customer-facing
- scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1: idempotent
  grant / verify / revoke script. -Features ScriptDisplayNames (high level) or
  -Permissions ... (admin-UI generated). Pure ASCII for PS 5.1 compat.
- docs/customer-graph-add-on-permissions.md: end-to-end customer guide
  covering prerequisites, the appRoleAssignment pattern, and revocation.

Tests
- 1718 backend xUnit / 364 web vitest. Highlights: JWT roles parsing edge
  cases; full-pull-429 suppresses per-ID; per-ID-429 breaks loop; transient
  detection via GetSnapshotAsync; chunked partial success (HTTP failure + thrown
  transport); TokenExpiredError propagation; EndpointPolicyCatalogCompleteness
  covers the three new routes by reflection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 01:14:21 +02:00

284 lines
12 KiB
PowerShell

#Requires -Version 5.1
<#
.SYNOPSIS
Grants additional Microsoft Graph application permissions to the Autopilot Monitor
service principal in the current tenant via tenant-side appRoleAssignment.
.DESCRIPTION
The Autopilot Monitor multi-tenant app requests only a minimal default permission set.
Optional features (such as resolving Intune Platform Script + Remediation Script display
names in session timelines) require additional Graph application permissions on the
Autopilot Monitor service principal in YOUR tenant. This script grants those permissions
WITHOUT changing the publisher's app manifest -- the grant is local to your tenant.
The script is idempotent: re-running skips permissions that are already granted and
reports the final state. Use -VerifyOnly to inspect without making changes.
.PARAMETER ClientId
Application (client) ID of the Autopilot Monitor multi-tenant app. Copy this from the
Autopilot Monitor admin UI ("Settings -> Optional Graph capabilities") or from your
vendor.
.PARAMETER Features
One or more high-level feature identifiers. The script translates each to the underlying
Graph permissions. Use this when you don't want to know permission strings.
Available features:
- ScriptDisplayNames -> DeviceManagementScripts.Read.All
.PARAMETER Permissions
Microsoft Graph application permission names to grant directly. Use when the admin UI
rendered a copy-paste command for you, or when you have a specific list in mind.
.PARAMETER TenantId
Tenant ID (GUID) or verified domain. Optional -- defaults to the signed-in context.
.PARAMETER VerifyOnly
Read-only mode: lists current appRoleAssignments on the SP without modifying anything.
.PARAMETER Revoke
Revokes the listed Features / Permissions instead of granting them.
.EXAMPLE
# Grant the optional capability for resolving Intune script display names:
.\Grant-AutopilotMonitorAddOn.ps1 -ClientId "<your-client-id>" -Features ScriptDisplayNames
.EXAMPLE
# Same effect, using the explicit-permission form (what the admin UI generates):
.\Grant-AutopilotMonitorAddOn.ps1 `
-ClientId "<your-client-id>" `
-Permissions "DeviceManagementScripts.Read.All" `
-TenantId "contoso.onmicrosoft.com"
.EXAMPLE
# Verify current state only, no changes:
.\Grant-AutopilotMonitorAddOn.ps1 -ClientId "<your-client-id>" -Features ScriptDisplayNames -VerifyOnly
.EXAMPLE
# Revoke the add-on permission:
.\Grant-AutopilotMonitorAddOn.ps1 -ClientId "<your-client-id>" -Features ScriptDisplayNames -Revoke
.NOTES
Requires:
- Microsoft.Graph.Authentication PowerShell module (auto-installs if missing).
- Sign-in as Global Administrator OR Privileged Role Administrator OR Cloud Application Administrator.
- 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
"Refresh permission status" button in the admin UI ("Settings -> Optional Graph
capabilities") to apply changes immediately.
#>
[CmdletBinding(SupportsShouldProcess = $true, DefaultParameterSetName = 'ByFeatures', ConfirmImpact = 'Medium')]
param(
[Parameter(Mandatory = $true)]
[ValidatePattern('^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')]
[string]$ClientId,
[Parameter(Mandatory = $true, ParameterSetName = 'ByFeatures')]
[ValidateSet('ScriptDisplayNames')]
[string[]]$Features,
[Parameter(Mandatory = $true, ParameterSetName = 'ByPermissions')]
[string[]]$Permissions,
[Parameter(Mandatory = $false)]
[string]$TenantId,
[Parameter(Mandatory = $false)]
[switch]$VerifyOnly,
[Parameter(Mandatory = $false)]
[switch]$Revoke
)
$ErrorActionPreference = 'Stop'
# ---- Feature -> Graph permission catalog -----------------------------------
# Keep in lock-step with src/Shared/AutopilotMonitor.Shared/Models/Graph/GraphFeatureCatalog.cs
$FeatureCatalog = @{
'ScriptDisplayNames' = @('DeviceManagementScripts.Read.All')
}
# Translate Features -> Permissions when caller used the high-level form.
if ($PSCmdlet.ParameterSetName -eq 'ByFeatures') {
$Permissions = @()
foreach ($f in $Features) {
$Permissions += $FeatureCatalog[$f]
}
$Permissions = $Permissions | Select-Object -Unique
}
# ---- Constants ----
$MicrosoftGraphAppId = '00000003-0000-0000-c000-000000000000'
# ---- 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
Install-Module Microsoft.Graph.Authentication -Scope CurrentUser -Force -AllowClobber
}
Import-Module Microsoft.Graph.Authentication -ErrorAction Stop
# ---- Sign-in ----
$connectParams = @{
Scopes = @('AppRoleAssignment.ReadWrite.All', 'Application.Read.All')
NoWelcome = $true
}
if ($TenantId) { $connectParams['TenantId'] = $TenantId }
Write-Host ""
Write-Host "[Auth] Connecting to Microsoft Graph..." -ForegroundColor Cyan
Connect-MgGraph @connectParams | Out-Null
$ctx = Get-MgContext
if (-not $ctx) {
Write-Error "Microsoft Graph sign-in failed."
return
}
Write-Host "[Auth] Signed in as : $($ctx.Account)"
Write-Host "[Auth] Tenant : $($ctx.TenantId)"
Write-Host "[Auth] Granted scopes: $($ctx.Scopes -join ', ')"
if (-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)."
if (-not $VerifyOnly) { return }
}
# ---- 1. Find Autopilot Monitor SP in the current tenant ----
Write-Host ""
Write-Host "[1/4] Locating Autopilot Monitor service principal (AppId=$ClientId)..." -ForegroundColor Cyan
$spUri = "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=appId eq '$ClientId'&`$select=id,appId,displayName"
$spResponse = Invoke-MgGraphRequest -Method GET -Uri $spUri
$sp = @($spResponse.value) | Select-Object -First 1
if (-not $sp) {
Write-Error @"
Service principal for AppId $ClientId was not found in tenant $($ctx.TenantId).
The Autopilot Monitor app must be admin-consented in this tenant first (one-time consent at
https://login.microsoftonline.com/$($ctx.TenantId)/adminconsent?client_id=$ClientId).
"@
return
}
Write-Host "[1/4] Found: '$($sp.displayName)' (ObjectId=$($sp.id))" -ForegroundColor Green
# ---- 2. Microsoft Graph SP (resource) ----
Write-Host ""
Write-Host "[2/4] Locating Microsoft Graph service principal..." -ForegroundColor Cyan
$graphUri = "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=appId eq '$MicrosoftGraphAppId'&`$select=id,appRoles"
$graphResponse = Invoke-MgGraphRequest -Method GET -Uri $graphUri
$graphSp = @($graphResponse.value) | Select-Object -First 1
if (-not $graphSp) {
Write-Error "Microsoft Graph service principal not found in tenant - unexpected, please report."
return
}
Write-Host "[2/4] Microsoft Graph SP ObjectId=$($graphSp.id), exposes $($graphSp.appRoles.Count) appRoles" -ForegroundColor Green
# ---- 3. Read existing assignments on the Autopilot Monitor SP ----
Write-Host ""
Write-Host "[3/4] Reading existing appRoleAssignments..." -ForegroundColor Cyan
$existingUri = "https://graph.microsoft.com/v1.0/servicePrincipals/$($sp.id)/appRoleAssignments"
$existingResponse = Invoke-MgGraphRequest -Method GET -Uri $existingUri
$existing = @($existingResponse.value)
Write-Host "[3/4] Currently assigned: $($existing.Count) role(s)"
$existingMap = @{}
foreach ($asn in $existing) {
$existingMap[$asn.appRoleId] = [pscustomobject]@{
AssignmentId = $asn.id
ResourceDisplayName = $asn.resourceDisplayName
AppRoleId = $asn.appRoleId
}
}
$graphRoleById = @{}
foreach ($r in $graphSp.appRoles) { $graphRoleById[$r.id] = $r.value }
if ($existing.Count -gt 0) {
Write-Host ""
Write-Host " Current grants:" -ForegroundColor Gray
foreach ($asn in $existing) {
$name = $graphRoleById[$asn.appRoleId]
if (-not $name) { $name = "<non-Graph resource: $($asn.resourceDisplayName)>" }
Write-Host " - $name" -ForegroundColor Gray
}
}
if ($VerifyOnly) {
Write-Host ""
Write-Host "VerifyOnly mode - no changes made." -ForegroundColor Yellow
return
}
# ---- 4. Grant or Revoke ----
Write-Host ""
$action = if ($Revoke) { 'Revoke' } else { 'Grant' }
Write-Host "[4/4] $action mode - processing $($Permissions.Count) permission(s)..." -ForegroundColor Cyan
$results = @()
foreach ($permName in $Permissions) {
$appRole = $graphSp.appRoles | Where-Object {
$_.value -eq $permName -and $_.allowedMemberTypes -contains 'Application'
} | Select-Object -First 1
if (-not $appRole) {
Write-Warning " [SKIP] '$permName' - not found on Microsoft Graph as an Application role"
$results += [pscustomobject]@{ Permission = $permName; Status = 'NotFound' }
continue
}
if ($Revoke) {
if (-not $existingMap.ContainsKey($appRole.id)) {
Write-Host " [SKIP] '$permName' - not currently granted" -ForegroundColor Gray
$results += [pscustomobject]@{ Permission = $permName; Status = 'NotGranted' }
continue
}
$assignmentId = $existingMap[$appRole.id].AssignmentId
$revokeUri = "https://graph.microsoft.com/v1.0/servicePrincipals/$($sp.id)/appRoleAssignments/$assignmentId"
if ($PSCmdlet.ShouldProcess("$permName on SP $($sp.id)", "Revoke")) {
try {
Invoke-MgGraphRequest -Method DELETE -Uri $revokeUri | Out-Null
Write-Host " [DONE] '$permName' revoked" -ForegroundColor Green
$results += [pscustomobject]@{ Permission = $permName; Status = 'Revoked' }
}
catch {
Write-Warning " [FAIL] '$permName' revoke failed: $($_.Exception.Message)"
$results += [pscustomobject]@{ Permission = $permName; Status = 'Failed' }
}
}
continue
}
if ($existingMap.ContainsKey($appRole.id)) {
Write-Host " [OK] '$permName' already granted" -ForegroundColor Gray
$results += [pscustomobject]@{ Permission = $permName; Status = 'AlreadyGranted' }
continue
}
$body = @{
principalId = $sp.id
resourceId = $graphSp.id
appRoleId = $appRole.id
} | ConvertTo-Json
$grantUri = "https://graph.microsoft.com/v1.0/servicePrincipals/$($sp.id)/appRoleAssignments"
if ($PSCmdlet.ShouldProcess("$permName on SP $($sp.id)", "Grant")) {
try {
Invoke-MgGraphRequest -Method POST -Uri $grantUri -Body $body -ContentType 'application/json' | Out-Null
Write-Host " [DONE] '$permName' granted" -ForegroundColor Green
$results += [pscustomobject]@{ Permission = $permName; Status = 'Granted' }
}
catch {
Write-Warning " [FAIL] '$permName' grant failed: $($_.Exception.Message)"
$results += [pscustomobject]@{ Permission = $permName; Status = 'Failed' }
}
}
}
Write-Host ""
Write-Host "=========== Summary ===========" -ForegroundColor Cyan
$results | Format-Table -AutoSize | Out-Host
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
Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null