<# .SYNOPSIS Hal SIEM tenant setup - automated alternative to the manual click-through. .DESCRIPTION Creates the Azure AD app registration ("hal-siem-log-collector"), attaches the 15 standard application permissions (12 Microsoft Graph + 3 Office 365 Management API), grants admin consent, mints a 24-month client secret, ensures Unified Audit Logging is enabled, and prints the credentials Hal needs. Self-auditing and idempotent. Reads current tenant state at each phase, reports what it found, and only modifies what needs changing. Pauses inline before each consequential change so you can review and abort. PowerShell 7+ required (Windows, macOS, or Linux). Microsoft.Graph 2.x - which this script uses - depends on .NET 5+, which Windows PowerShell 5.1's .NET Framework 4.x runtime cannot load. If launched under 5.1 the script offers to install PowerShell 7 via winget and re-invoke itself under pwsh; PS7 installs side-by-side with 5.1. No compiled binaries, no external runtime dependencies beyond Microsoft's official PowerShell modules (auto-installed in CurrentUser scope on first run if missing): - ExchangeOnlineManagement - Microsoft.Graph.Authentication - Microsoft.Graph.Applications - Microsoft.Graph.Identity.SignIns Two interactive sign-ins required (Global Administrator on the customer's M365 tenant): one for Exchange Online, one for Microsoft Graph. .PARAMETER UserPrincipalName Global Administrator UPN for the customer's M365 tenant. Used for the Exchange Online sign-in. .PARAMETER ForceNewSecret Mint a new client secret even if a non-expired secret already exists. Old secrets continue to work until expiry; use this when you've lost the value of an earlier secret and need a new one. .PARAMETER NonInteractive Skip the inline confirmation pauses. Useful for automation. Without this flag, the script pauses before each consequential change so you can review and abort with Ctrl+C. .EXAMPLE .\hal-m365-tenant-setup.ps1 -UserPrincipalName admin@contoso.com .EXAMPLE .\hal-m365-tenant-setup.ps1 -UserPrincipalName admin@contoso.com -ForceNewSecret .NOTES Documentation: https://runhal.com/docs/getting-started/script/microsoft-365/ Manual equivalent: https://runhal.com/docs/getting-started/manual/microsoft-365/ #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$UserPrincipalName, [switch]$ForceNewSecret, [switch]$NonInteractive ) $ErrorActionPreference = "Stop" # -------------------------------------------------------------------------- # PowerShell version gate # # Microsoft.Graph 2.x targets .NET 5+ and cannot load under Windows # PowerShell 5.1's .NET Framework 4.x runtime (symptom: Import-Module # fails with "Method GetTokenAsync ... does not have an implementation"). # On 5.1 we offer to install PowerShell 7 via winget and re-invoke # this script under pwsh - PS7 installs side-by-side with 5.1, doesn't # replace it. # -------------------------------------------------------------------------- if ($PSVersionTable.PSVersion.Major -lt 7) { # Look for an existing pwsh.exe before doing anything else. winget # updates the persistent PATH but the current 5.1 process inherited # the old one, so Get-Command pwsh from inside 5.1 may miss a # perfectly good install. Check the standard locations directly. function Find-Pwsh { $candidates = @( "$env:ProgramFiles\PowerShell\7\pwsh.exe" "${env:ProgramFiles(x86)}\PowerShell\7\pwsh.exe" "$env:LocalAppData\Microsoft\PowerShell\7\pwsh.exe" "$env:LocalAppData\Microsoft\WindowsApps\pwsh.exe" ) foreach ($c in $candidates) { if ($c -and (Test-Path $c)) { return $c } } return $null } function Invoke-UnderPwsh { param([string]$PwshPath) # $PSCommandPath is the automatic variable for the running script's # full path and is correct in any scope (unlike $MyInvocation, which # would point at the function definition when read from inside it). $relaunchArgs = @( "-ExecutionPolicy", "Bypass", "-NoProfile", "-File", $PSCommandPath, "-UserPrincipalName", $UserPrincipalName ) if ($ForceNewSecret) { $relaunchArgs += "-ForceNewSecret" } if ($NonInteractive) { $relaunchArgs += "-NonInteractive" } & $PwshPath @relaunchArgs exit $LASTEXITCODE } $pwshPath = Find-Pwsh if ($pwshPath) { Write-Host "" Write-Host "=== PowerShell 7 already installed - re-invoking under pwsh ===" -ForegroundColor Cyan Write-Host "" Write-Host " You launched this from Windows PowerShell $($PSVersionTable.PSVersion) (Desktop)," Write-Host " but PowerShell 7 is already on this machine at:" Write-Host " $pwshPath" Write-Host " Re-invoking under pwsh now (no install needed)." Write-Host "" Invoke-UnderPwsh -PwshPath $pwshPath } Write-Host "" Write-Host "=== PowerShell 7+ required ===" -ForegroundColor Yellow Write-Host "" Write-Host " You are running PowerShell $($PSVersionTable.PSVersion) ($($PSVersionTable.PSEdition))." Write-Host "" Write-Host " Microsoft.Graph 2.x (which this script uses) targets .NET 5+" Write-Host " and cannot be loaded by Windows PowerShell 5.1. PowerShell 7" Write-Host " installs side-by-side with 5.1 - it doesn't replace it." Write-Host "" # Auto-install path: only on Windows PowerShell Desktop edition with # winget on PATH and an interactive session. $isWinPSDesktop = ($PSVersionTable.PSEdition -eq "Desktop") $hasWinget = $false if ($isWinPSDesktop) { $hasWinget = $null -ne (Get-Command winget -ErrorAction SilentlyContinue) } $autoInstall = $false if ($hasWinget -and -not $NonInteractive) { Write-Host " Install PowerShell 7 now via winget and re-launch this script?" -ForegroundColor Magenta $resp = Read-Host " [Y/n]" if (($resp -eq "") -or ($resp -match "^[Yy]")) { $autoInstall = $true } } if ($autoInstall) { Write-Host "" Write-Host " Running: winget install --id Microsoft.PowerShell --source winget" -ForegroundColor Cyan Write-Host " This usually takes a minute or two. If Windows raises a UAC" -ForegroundColor Gray Write-Host " prompt, approve it; on most Windows 11 setups winget installs" -ForegroundColor Gray Write-Host " PowerShell 7 per-user without prompting." -ForegroundColor Gray Write-Host "" & winget install --id Microsoft.PowerShell --source winget ` --accept-source-agreements --accept-package-agreements $wingetExit = $LASTEXITCODE if ($wingetExit -ne 0) { Write-Host "" Write-Host " winget exited with code $wingetExit. Aborting." -ForegroundColor Red exit 1 } $pwshPath = Find-Pwsh if (-not $pwshPath) { Write-Host "" Write-Host " PowerShell 7 install reported success but pwsh.exe was not" -ForegroundColor Red Write-Host " found in any expected location. Open a new terminal window" -ForegroundColor Red Write-Host " (so PATH refreshes) and re-run:" -ForegroundColor Red Write-Host " pwsh -ExecutionPolicy Bypass -File .\hal-m365-tenant-setup.ps1 -UserPrincipalName $UserPrincipalName" -ForegroundColor Red exit 1 } Write-Host "" Write-Host " Found PowerShell 7 at: $pwshPath" -ForegroundColor Green Write-Host " Re-invoking script under pwsh ..." -ForegroundColor Cyan Write-Host "" Invoke-UnderPwsh -PwshPath $pwshPath } Write-Host " Manual install:" Write-Host " winget install --id Microsoft.PowerShell --source winget" Write-Host "" Write-Host " Or download the MSI from:" Write-Host " https://aka.ms/install-powershell" Write-Host "" Write-Host " Then open a new terminal and re-run with pwsh (not powershell):" Write-Host " pwsh -ExecutionPolicy Bypass -File .\hal-m365-tenant-setup.ps1 -UserPrincipalName " Write-Host "" exit 1 } # -------------------------------------------------------------------------- # Configuration - kept in lock-step with hal-app: portal/oauth_azure.py. # Don't change one without changing the other. # -------------------------------------------------------------------------- $AppRegName = "hal-siem-log-collector" $SecretLifetimeMonths = 24 $GraphAppId = "00000003-0000-0000-c000-000000000000" $O365MgmtAppId = "c5393580-f805-4401-95e8-94b7a6ef2fc2" $GraphPermissions = [ordered]@{ "Application.Read.All" = "9a5d68dd-52b0-4cc2-bd40-abcf44ac3a30" "AuditLog.Read.All" = "b0afded3-3588-46d8-8b3d-9842eff778da" "Directory.Read.All" = "7ab1d382-f21e-4acd-a863-ba3e13f7da61" "IdentityRiskEvent.Read.All" = "6e472fd1-ad78-48da-a0f0-97ab2c6b769e" "IdentityRiskyUser.Read.All" = "dc5007c0-2d7d-4c42-879c-2dab87571379" "MailboxSettings.Read" = "40f97065-369a-49f4-947c-6a255697ae91" "Policy.Read.All" = "246dd0d5-5bd0-4def-940b-0421030a5b68" "Reports.Read.All" = "230c1aed-a721-4c5d-9cb4-a90514e508ef" "RoleManagement.Read.Directory" = "483bed4a-2ad3-4361-a73b-c83ccdbdc53c" "SecurityAlert.Read.All" = "472e4a4d-bb4a-4026-98d1-0b0d74cb74a5" "SecurityIncident.Read.All" = "45cc0394-e837-488b-a098-1918f48d186c" "User.Read.All" = "df021288-bdef-4463-88db-98f22de89214" } $O365MgmtPermissions = [ordered]@{ "ActivityFeed.Read" = "594c1fb6-4f81-4475-ae41-0c394909246c" "ActivityFeed.ReadDlp" = "4807a72c-ad38-4250-94c9-4eabfe26cd55" "ServiceHealth.Read" = "e2cea78f-e743-4d8f-a16a-75b629a038ae" } # -------------------------------------------------------------------------- # Output + interaction helpers # -------------------------------------------------------------------------- function Write-Step { Write-Host "`n==> $($args[0])" -ForegroundColor Cyan } function Write-Info { Write-Host " $($args[0])" -ForegroundColor Gray } function Write-Done { Write-Host " [ok] $($args[0])" -ForegroundColor Green } function Write-Will { Write-Host " [will] $($args[0])" -ForegroundColor Yellow } function Write-Skip { Write-Host " [skip] $($args[0])" -ForegroundColor Gray } function Write-Warn2 { Write-Host " [warn] $($args[0])" -ForegroundColor Yellow } function Confirm-Continue { param( [string]$Prompt = "Press Enter to proceed, Ctrl+C to abort" ) if ($NonInteractive) { return } Write-Host "" Write-Host " $Prompt" -ForegroundColor Magenta [void](Read-Host) } function Ensure-PackagingPrereqs { # PowerShellGet's Install-Module pulls from the PSGallery via NuGet. # On a fresh machine, NuGet provider isn't installed (interactive # prompt) and PSGallery is marked Untrusted (another prompt). Bootstrap # both up front so subsequent Install-Module calls run silently. if (-not (Get-PackageProvider -Name NuGet -ListAvailable -ErrorAction SilentlyContinue | Where-Object { $_.Version -ge [version]"2.8.5.201" })) { Write-Info "Bootstrapping NuGet provider (CurrentUser scope) ..." Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 ` -Scope CurrentUser -Force -Confirm:$false | Out-Null } $repo = Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue if ($repo -and $repo.InstallationPolicy -ne "Trusted") { Write-Info "Trusting PSGallery for this user ..." Set-PSRepository -Name PSGallery -InstallationPolicy Trusted } } function Ensure-Module { param([string]$Name) if (-not (Get-Module -ListAvailable -Name $Name)) { Write-Info "Installing $Name (CurrentUser scope) ..." Install-Module -Name $Name -Scope CurrentUser -Force -AllowClobber -Confirm:$false -ErrorAction Stop } Import-Module $Name -ErrorAction Stop } function Show-Credentials { param( [string]$TenantId, [string]$AppClientId, [string]$SecretValue, [datetime]$SecretExpires ) $line = ('=' * 72) Write-Host "" Write-Host $line -ForegroundColor Green Write-Host " Paste these credentials into your Hal MSP portal" -ForegroundColor Green Write-Host $line -ForegroundColor Green Write-Host "" Write-Host ("Directory (tenant) ID: {0}" -f $TenantId) Write-Host ("Application (client) ID: {0}" -f $AppClientId) if ($SecretValue) { Write-Host ("Client Secret Value: {0}" -f $SecretValue) Write-Host ("Secret Expires: {0}" -f $SecretExpires) } else { Write-Host "Client Secret Value: " Write-Host " (re-run with -ForceNewSecret if you've lost it)" } Write-Host "" } # -------------------------------------------------------------------------- # Pre-flight summary # -------------------------------------------------------------------------- $line = ('=' * 72) Write-Host "" Write-Host $line -ForegroundColor Cyan Write-Host " Hal - Microsoft 365 tenant setup (script edition)" -ForegroundColor Cyan Write-Host $line -ForegroundColor Cyan Write-Host "" Write-Host " Target UPN: $UserPrincipalName" Write-Host " Force new secret: $($ForceNewSecret.IsPresent)" Write-Host " Non-interactive: $($NonInteractive.IsPresent)" Write-Host "" Write-Host " This script will:" Write-Host " 1. Read tenant state (hydration, Unified Audit Logging)." Write-Host " 2. Hydrate the tenant if dehydrated." Write-Host " 3. Enable Unified Audit Logging if not already on." Write-Host " 4. Find or create the 'hal-siem-log-collector' app registration." Write-Host " 5. Audit the app's required permissions vs the script's expected list." Write-Host " 6. Grant admin consent on any permission not yet consented." Write-Host " 7. Mint a 24-month client secret (skipped if one exists)." Write-Host " 8. Print the credentials for paste into the MSP portal." Write-Host "" Write-Host " Self-auditing: pauses before each consequential change so" Write-Host " you can review and abort with Ctrl+C." Write-Host "" Write-Host " Heads up: you'll see TWO interactive sign-in prompts as the" -ForegroundColor Yellow Write-Host " same Global Admin - one for Exchange Online (Phase 2) and" -ForegroundColor Yellow Write-Host " one for Microsoft Graph (Phase 4). They are separate Microsoft" -ForegroundColor Yellow Write-Host " APIs with separate token audiences; on Windows 11 the second" -ForegroundColor Yellow Write-Host " is usually just a one-click WAM account picker, not a full" -ForegroundColor Yellow Write-Host " password re-entry." -ForegroundColor Yellow Write-Host "" Confirm-Continue # -------------------------------------------------------------------------- # Phase 1: Module readiness # -------------------------------------------------------------------------- Write-Step "Phase 1 - checking PowerShell modules" Ensure-PackagingPrereqs Ensure-Module ExchangeOnlineManagement Ensure-Module Microsoft.Graph.Authentication Ensure-Module Microsoft.Graph.Applications Ensure-Module Microsoft.Graph.Identity.SignIns Write-Done "Modules ready" # -------------------------------------------------------------------------- # Phase 2: Exchange Online - sign in, read state, change only what's needed # -------------------------------------------------------------------------- Write-Step "Phase 2 - Exchange Online (sign in)" Write-Info "Opening interactive sign-in for $UserPrincipalName ..." Connect-ExchangeOnline -UserPrincipalName $UserPrincipalName -ShowBanner:$false Write-Done "Connected to Exchange Online" Write-Step "Phase 3 - read tenant state" $orgConfig = Get-OrganizationConfig $ualState = (Get-AdminAuditLogConfig).UnifiedAuditLogIngestionEnabled Write-Info ("IsDehydrated: {0}" -f $orgConfig.IsDehydrated) Write-Info ("UnifiedAuditLogIngestionEnabled: {0}" -f $ualState) $needHydrate = [bool]$orgConfig.IsDehydrated $needUAL = -not $ualState if (-not $needHydrate -and -not $needUAL) { Write-Done "No tenant-side changes needed (already hydrated; UAL already on)" } else { if ($needHydrate) { Write-Will "Will run Enable-OrganizationCustomization" } if ($needUAL) { Write-Will "Will run Set-AdminAuditLogConfig -UnifiedAuditLogIngestionEnabled `$true" } Confirm-Continue } if ($needHydrate) { Write-Step "Phase 3a - hydrate" try { Enable-OrganizationCustomization Write-Done "Customization enabled (Microsoft will propagate this server-side)" } catch { if ($_.Exception.Message -match "already|OrganizationCustomization") { Write-Info "Customization was already enabled (no-op)" } else { throw } } } if ($needUAL) { Write-Step "Phase 3b - enable Unified Audit Logging" try { Set-AdminAuditLogConfig -UnifiedAuditLogIngestionEnabled $true Write-Done "Set-AdminAuditLogConfig succeeded" } catch { if ($_.Exception.Message -match "dehydrat") { Write-Host "" Write-Warn2 "Hydration is still propagating on Microsoft's backend." Write-Warn2 "This typically takes a few minutes but can take several hours." Write-Warn2 "There is no signal beyond 'the command eventually succeeds.'" Write-Host "" Write-Warn2 "Wait some time, then re-run this script." Write-Warn2 "It is safe to re-run; earlier steps are idempotent and will no-op." Write-Host "" Disconnect-ExchangeOnline -Confirm:$false | Out-Null exit 2 } throw } $ualState = (Get-AdminAuditLogConfig).UnifiedAuditLogIngestionEnabled if (-not $ualState) { Write-Error "UAL verification failed; UnifiedAuditLogIngestionEnabled = $ualState" exit 1 } Write-Done "UnifiedAuditLogIngestionEnabled = True" } Disconnect-ExchangeOnline -Confirm:$false | Out-Null # -------------------------------------------------------------------------- # Phase 4: Microsoft Graph - sign in # -------------------------------------------------------------------------- Write-Step "Phase 4 - Microsoft Graph (sign in)" # Pin Graph sign-in to the tenant derived from the UPN domain. Without # this, WAM (Windows Account Manager) can complete sign-in against the # wrong cached account / tenant and return a context with an empty # TenantId; subsequent Get-MgApplication calls then run with no scope # and return nothing, making the script falsely believe the app # doesn't exist. $tenantDomain = ($UserPrincipalName -split '@')[1] if ([string]::IsNullOrEmpty($tenantDomain)) { Write-Host "ERROR: could not parse a tenant domain from UPN '$UserPrincipalName'" -ForegroundColor Red exit 1 } Write-Info "Opening interactive sign-in for tenant '$tenantDomain' (second sign-in, same Global Admin) ..." $graphScopes = @( "Application.ReadWrite.All", "AppRoleAssignment.ReadWrite.All", "Directory.Read.All" ) Connect-MgGraph -Scopes $graphScopes -TenantId $tenantDomain -NoWelcome Write-Done "Connected to Microsoft Graph" $ctx = Get-MgContext if (-not $ctx -or [string]::IsNullOrEmpty($ctx.TenantId)) { Write-Host "" Write-Host "ERROR: Microsoft Graph sign-in returned no tenant context." -ForegroundColor Red Write-Host " This usually means WAM (Windows Account Manager) finished" -ForegroundColor Red Write-Host " with a different account than expected. Re-run the script" -ForegroundColor Red Write-Host " and pick the '$UserPrincipalName' account in the picker." -ForegroundColor Red exit 1 } $tenantId = $ctx.TenantId Write-Info "Tenant ID: $tenantId" if ($ctx.Account -and ($ctx.Account -ne $UserPrincipalName)) { Write-Warn2 "Signed-in Graph account is '$($ctx.Account)', not '$UserPrincipalName'." Write-Warn2 "Continuing - but if this is the wrong account, abort with Ctrl+C." } # Make a no-op Graph API call so any WAM follow-up confirmation # happens here (inside the sign-in phase) rather than later when the # user is mid-script and not expecting another popup. Write-Info "Warming up Graph token (one Get-MgOrganization call) ..." try { $null = Get-MgOrganization -ErrorAction Stop Write-Done "Graph token warm; ready for API calls" } catch { Write-Warn2 "Graph warm-up call failed: $($_.Exception.Message)" Write-Warn2 "Continuing - the next Graph call will retry." } # -------------------------------------------------------------------------- # Phase 5: App registration - find or create # -------------------------------------------------------------------------- Write-Step "Phase 5 - app registration" Write-Info "Looking for app registration named '$AppRegName' ..." # Try server-side filter first (fast). Some tenants / scope combinations # return an empty result for $filter eq displayName even when the app # exists; if that happens, fall back to a full-list client-side match. $app = $null try { $app = Get-MgApplication -Filter "displayName eq '$AppRegName'" -ErrorAction Stop } catch { Write-Warn2 "Server-side filter call failed: $($_.Exception.Message)" } if (-not $app) { Write-Info "Server-side filter returned nothing; checking the full application list ..." $allApps = @() try { $allApps = Get-MgApplication -All -ErrorAction Stop } catch { Write-Warn2 "Get-MgApplication -All failed: $($_.Exception.Message)" } Write-Info ("Tenant has {0} application registration(s) total." -f @($allApps).Count) $app = $allApps | Where-Object { $_.DisplayName -eq $AppRegName } | Select-Object -First 1 if (-not $app) { # Diagnostic: any app whose name looks Hal-related? $similar = $allApps | Where-Object { $_.DisplayName -match "(?i)hal|siem|log-collector" } if ($similar) { Write-Warn2 "No app named exactly '$AppRegName', but found similarly-named apps:" foreach ($a in $similar) { Write-Warn2 (" - '{0}' (AppId: {1})" -f $a.DisplayName, $a.AppId) } Write-Warn2 "If one of those is the existing Hal app under a different" Write-Warn2 "name, abort now (Ctrl+C); otherwise we'll create a new" Write-Warn2 "'$AppRegName' alongside them." } } } $createdNewApp = $false if ($app) { Write-Done "Found existing '$AppRegName' app registration" Write-Info ("AppId: {0}" -f $app.AppId) Write-Info ("ObjectId: {0}" -f $app.Id) Write-Info ("Created: {0}" -f $app.CreatedDateTime) } else { Write-Will "App registration '$AppRegName' does not exist; will create." Confirm-Continue $app = New-MgApplication -DisplayName $AppRegName -SignInAudience "AzureADMyOrg" $createdNewApp = $true Write-Done "Created - AppId: $($app.AppId)" } # -------------------------------------------------------------------------- # Phase 6: Permission audit # -------------------------------------------------------------------------- Write-Step "Phase 6 - permission audit" $expectedGraphIds = @($GraphPermissions.Values) $expectedO365Ids = @($O365MgmtPermissions.Values) $currentGraphIds = @() $currentO365Ids = @() foreach ($rra in $app.RequiredResourceAccess) { if ($rra.ResourceAppId -eq $GraphAppId) { $currentGraphIds = @($rra.ResourceAccess | Where-Object { $_.Type -eq "Role" } | ForEach-Object { $_.Id }) } elseif ($rra.ResourceAppId -eq $O365MgmtAppId) { $currentO365Ids = @($rra.ResourceAccess | Where-Object { $_.Type -eq "Role" } | ForEach-Object { $_.Id }) } } function Resolve-Names { param([string[]]$Ids, $Lookup) $rev = @{} foreach ($k in $Lookup.Keys) { $rev[$Lookup[$k]] = $k } foreach ($id in $Ids) { if ($rev.ContainsKey($id)) { $rev[$id] } else { "(unknown:$id)" } } } $missingGraph = $expectedGraphIds | Where-Object { $_ -notin $currentGraphIds } $extraGraph = $currentGraphIds | Where-Object { $_ -notin $expectedGraphIds } $missingO365 = $expectedO365Ids | Where-Object { $_ -notin $currentO365Ids } $extraO365 = $currentO365Ids | Where-Object { $_ -notin $expectedO365Ids } Write-Info ("Microsoft Graph application permissions: {0} expected, {1} on app" -f $expectedGraphIds.Count, $currentGraphIds.Count) Write-Info ("Office 365 Management API permissions: {0} expected, {1} on app" -f $expectedO365Ids.Count, $currentO365Ids.Count) $inSync = ($missingGraph.Count -eq 0 -and $extraGraph.Count -eq 0 -and $missingO365.Count -eq 0 -and $extraO365.Count -eq 0) if ($inSync) { Write-Done "App's permissions exactly match the script's expected list (no drift)" if ($createdNewApp) { Write-Will "Update-MgApplication will set RequiredResourceAccess on the newly-created app" Confirm-Continue } else { Write-Skip "Update-MgApplication will be a no-op; running it anyway for safety" } } else { Write-Warn2 "Drift detected between app's current permissions and script's expected list:" if ($missingGraph.Count -gt 0) { Write-Warn2 " Graph - missing on app, will be added:" foreach ($n in (Resolve-Names $missingGraph $GraphPermissions)) { Write-Host " + $n" -ForegroundColor Yellow } } if ($extraGraph.Count -gt 0) { Write-Warn2 " Graph - present on app but NOT in script's list (will be removed):" foreach ($n in (Resolve-Names $extraGraph $GraphPermissions)) { Write-Host " - $n" -ForegroundColor Red } } if ($missingO365.Count -gt 0) { Write-Warn2 " O365 Mgmt - missing on app, will be added:" foreach ($n in (Resolve-Names $missingO365 $O365MgmtPermissions)) { Write-Host " + $n" -ForegroundColor Yellow } } if ($extraO365.Count -gt 0) { Write-Warn2 " O365 Mgmt - present on app but NOT in script's list (will be removed):" foreach ($n in (Resolve-Names $extraO365 $O365MgmtPermissions)) { Write-Host " - $n" -ForegroundColor Red } } Write-Host "" Write-Warn2 "Update-MgApplication will REPLACE RequiredResourceAccess with the" Write-Warn2 "script's expected list. Any '-' entries above will be lost. Review" Write-Warn2 "before continuing." Confirm-Continue "Press Enter to apply the script's list, Ctrl+C to abort" } $requiredAccess = @( @{ ResourceAppId = $GraphAppId ResourceAccess = $GraphPermissions.Values | ForEach-Object { @{ Id = $_; Type = "Role" } } }, @{ ResourceAppId = $O365MgmtAppId ResourceAccess = $O365MgmtPermissions.Values | ForEach-Object { @{ Id = $_; Type = "Role" } } } ) Update-MgApplication -ApplicationId $app.Id -RequiredResourceAccess $requiredAccess Write-Done ("RequiredResourceAccess set ({0} permissions)" -f ($expectedGraphIds.Count + $expectedO365Ids.Count)) # -------------------------------------------------------------------------- # Phase 7: Service principal + admin consent audit # -------------------------------------------------------------------------- Write-Step "Phase 7 - service principal + admin consent" $sp = Get-MgServicePrincipal -Filter "appId eq '$($app.AppId)'" -ErrorAction SilentlyContinue if (-not $sp) { Write-Will "Service principal does not exist; will create." Confirm-Continue $sp = New-MgServicePrincipal -AppId $app.AppId Write-Done "Service principal created" } else { Write-Done "Service principal exists (ObjectId $($sp.Id))" } $graphSp = Get-MgServicePrincipal -Filter "appId eq '$GraphAppId'" $o365MgmtSp = Get-MgServicePrincipal -Filter "appId eq '$O365MgmtAppId'" $existingAssignments = Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $sp.Id $consentsNeeded = @() foreach ($name in $GraphPermissions.Keys) { $rid = $GraphPermissions[$name] $hit = $existingAssignments | Where-Object { $_.AppRoleId -eq $rid -and $_.ResourceId -eq $graphSp.Id } if (-not $hit) { $consentsNeeded += @{ Label = "Graph: $name"; Role = $rid; Resource = $graphSp.Id } } } foreach ($name in $O365MgmtPermissions.Keys) { $rid = $O365MgmtPermissions[$name] $hit = $existingAssignments | Where-Object { $_.AppRoleId -eq $rid -and $_.ResourceId -eq $o365MgmtSp.Id } if (-not $hit) { $consentsNeeded += @{ Label = "O365 Mgmt: $name"; Role = $rid; Resource = $o365MgmtSp.Id } } } if ($consentsNeeded.Count -eq 0) { Write-Done "All $($GraphPermissions.Count + $O365MgmtPermissions.Count) permissions already have admin consent. No grants needed." } else { Write-Will ("Will grant admin consent on {0} permission(s):" -f $consentsNeeded.Count) foreach ($c in $consentsNeeded) { Write-Host " + $($c.Label)" -ForegroundColor Yellow } Confirm-Continue foreach ($c in $consentsNeeded) { New-MgServicePrincipalAppRoleAssignment ` -ServicePrincipalId $sp.Id ` -PrincipalId $sp.Id ` -ResourceId $c.Resource ` -AppRoleId $c.Role | Out-Null Write-Done "$($c.Label) - granted" } } # -------------------------------------------------------------------------- # Phase 8: Client secret # -------------------------------------------------------------------------- Write-Step "Phase 8 - client secret" $now = Get-Date $activeSecrets = $app.PasswordCredentials | Where-Object { $_.EndDateTime -gt $now } if ($activeSecrets) { Write-Info ("Found {0} non-expired secret(s):" -f $activeSecrets.Count) foreach ($s in $activeSecrets) { Write-Host (" * {0} (expires {1:yyyy-MM-dd}, displayName='{2}')" -f $s.KeyId, $s.EndDateTime, $s.DisplayName) } Write-Host "" } else { Write-Info "No non-expired secrets on the app" } if ($activeSecrets -and -not $ForceNewSecret) { Write-Skip "Existing non-expired secret in place; skipping new-secret creation" Write-Info "(Microsoft does not allow re-displaying an existing secret value." Write-Info " If you've lost the value, re-run with -ForceNewSecret to mint a new one;" Write-Info " the old secret continues to work until its expiration.)" Show-Credentials -TenantId $tenantId -AppClientId $app.AppId -SecretValue $null -SecretExpires ([datetime]::MinValue) Disconnect-MgGraph | Out-Null exit 0 } if ($activeSecrets -and $ForceNewSecret) { Write-Will "Will mint an additional secret (existing secrets continue to work until expiry)" } else { Write-Will "Will mint the first client secret on this app" } Confirm-Continue $secretParams = @{ DisplayName = "hal" EndDateTime = (Get-Date).AddMonths($SecretLifetimeMonths) } $newSecret = Add-MgApplicationPassword -ApplicationId $app.Id -PasswordCredential $secretParams Write-Done "Secret minted (expires $($newSecret.EndDateTime))" # -------------------------------------------------------------------------- # Phase 9: Output # -------------------------------------------------------------------------- Show-Credentials ` -TenantId $tenantId ` -AppClientId $app.AppId ` -SecretValue $newSecret.SecretText ` -SecretExpires $newSecret.EndDateTime Disconnect-MgGraph | Out-Null