Public/New-MsecApp.ps1
|
function New-MsecApp { <# .SYNOPSIS Sets up (or updates) the msec app registration: app + service principal + certificate in Key Vault + admin consent. Safe to re-run. .DESCRIPTION Idempotent. Re-run this whenever permissions change (e.g. when msec adds a new Graph scope) and it will *adjust* the existing app rather than creating a duplicate. Concretely each step is find-or-create / merge-don't-clobber: 1. Verifies an Azure context (Connect-AzAccount must have been run first). 2. Acquires a Microsoft Graph access token for *the user* via Az.Accounts and uses it for all Graph create/consent calls (we cannot use the app's own token here - the app may not exist yet). 3. Resolves the Graph and Defender resource service principals + the app role IDs for every required permission, by name (no hardcoded role GUIDs). 4. Finds an app registration by displayName, or creates one if missing. 5. PATCHes requiredResourceAccess - existing entries for unrelated resources are preserved; for Graph / WindowsDefenderATP, missing role IDs are added. 6. Finds or creates the matching service principal. 7. Finds or issues the self-signed certificate inside the named Key Vault. 8. Stamps the cert with AppId / TenantId tags (overwrites - idempotent). 9. Attaches the cert to the app only if a credential with that thumbprint is not already present. 10. Grants admin consent by creating appRoleAssignments - only for (resource, role) pairs not already assigned. 11. Returns an object with TenantId, ClientId, KeyVaultName, CertificateName. Current required permissions (configured at the top of the function in $resources): - Microsoft Graph: SecurityEvents.Read.All, DeviceManagementConfiguration.Read.All, DeviceManagementManagedDevices.Read.All, DeviceManagementScripts.Read.All, ThreatHunting.Read.All, SecurityIncident.Read.All, Policy.Read.All, AuditLog.Read.All, Organization.Read.All, RoleManagement.Read.Directory, User.Read.All, Group.Read.All, Application.Read.All, PrivilegedEligibilitySchedule.Read.AzureADGroup - WindowsDefenderATP: Score.Read.All - commercial-only. Skipped automatically in clouds without a Defender for Endpoint presence (e.g. Azure China), since its service principal doesn't exist there; the rest of the app is still created. Prerequisites (the user running this command needs): - Azure RBAC to create certificates in the target Key Vault. - Microsoft Entra role allowing application creation AND admin consent of application permissions (Global Administrator, Privileged Role Administrator, or Application Administrator + Cloud Application Administrator). .PARAMETER DisplayName Display name for the new app registration. Default: 'msec'. .PARAMETER KeyVaultName Name of an existing Azure Key Vault that will store the certificate. .PARAMETER CertificateName Name of the certificate object inside Key Vault. Default: 'msec-app'. .PARAMETER ValidityMonths Certificate lifetime in months. Default: 24. .EXAMPLE Connect-AzAccount $app = New-MsecApp -KeyVaultName 'kv-mysec' # Hand $app.TenantId / $app.ClientId / $app.KeyVaultName / $app.CertificateName to anyone # who should run reports; they Connect-Msec with those values. #> [CmdletBinding()] param( [Parameter()][string] $DisplayName = 'msec', [Parameter(Mandatory)][string] $KeyVaultName, [Parameter()][string] $CertificateName = 'msec-app', [Parameter()][ValidateRange(1, 24)][int] $ValidityMonths = 24 ) $ctx = Get-AzContext -ErrorAction SilentlyContinue if (-not $ctx) { throw 'No Azure context. Run Connect-AzAccount before New-MsecApp.' } $tenantId = $ctx.Tenant.Id # Cloud endpoints for the current context (China, US Gov, commercial). The well-known # permission resource appIds assigned below are constant across clouds; only the HTTP # endpoint to call Graph differs. DefenderResource is null where Defender for Endpoint # has no presence (e.g. retired in Azure China) - we use that to skip its permission. $envInfo = Get-MsecEnvironment $graphBase = $envInfo.GraphResource Write-Verbose 'Acquiring Graph token via Az.Accounts (your identity, not the app)' $tokenInfo = Get-AzAccessToken -ResourceUrl $graphBase -ErrorAction Stop $userGraphToken = if ($tokenInfo.Token -is [securestring]) { $tokenInfo.Token | ConvertFrom-SecureString -AsPlainText } else { $tokenInfo.Token } # Local Graph caller using the user's token (the module's Invoke-MsecGraphRequest needs a # session that does not exist yet). $graph = { param($Method, $Path, $Body) $uri = if ($Path -like 'https://*') { $Path } else { "$graphBase$Path" } $p = @{ Method = $Method Uri = $uri Headers = @{ Authorization = "Bearer $userGraphToken" } ErrorAction = 'Stop' } if ($null -ne $Body) { $p['ContentType'] = 'application/json' $p['Body'] = ($Body | ConvertTo-Json -Depth 20) } Invoke-RestMethod @p } # ---- 1. Resolve resource SPs + app role IDs by name (no hardcoded role GUIDs) ---- # Each resource can declare multiple roles. Add new permissions here when needed. $resources = @( @{ Name = 'Microsoft Graph' AppId = '00000003-0000-0000-c000-000000000000' RoleValues = @( 'SecurityEvents.Read.All', # Microsoft Secure Score (Graph) 'DeviceManagementConfiguration.Read.All', # Intune configurations / compliance policies 'DeviceManagementManagedDevices.Read.All',# Intune managed devices (Get-MsecIntuneDevice) # Intune SCRIPTS are a separate scope from Intune configuration, which is not # obvious from the endpoint paths: deviceHealthScripts and # deviceCustomAttributeShellScripts both sit under /deviceManagement alongside # the configuration policies, but DeviceManagementConfiguration.Read.All does # not cover them and they 403 without this (Get-MsecIntuneScriptResult). 'DeviceManagementScripts.Read.All', 'ThreatHunting.Read.All', # Advanced hunting / EmailEvents (Get-MsecDefenderEmailStats) 'SecurityIncident.Read.All', # Defender XDR incidents (Get-MsecDefenderIncidentStats) 'Policy.Read.All', # CA policies + tenant security settings (Get-MsecEntraConditionalAccessPolicy, Get-MsecEntraTenantSecuritySetting) 'AuditLog.Read.All', # Sign-in logs + MFA registration report (Get-MsecEntraConditionalAccessSignInLog, Get-MsecEntraMfaRegistration) - both also need Entra ID P1/P2 on the tenant 'Organization.Read.All', # Licence SKUs / service plans (Get-MsecEntraLicense) - tells "unlicensed" apart from "no permission" 'RoleManagement.Read.Directory', # Directory role assignments + eligibility (Get-MsecEntraRoleHolder) # Reading role assignments and reading the identities they point at are # SEPARATE grants. With RoleManagement.Read.Directory alone, Graph returns # every assignment but each principal as an id-and-type shell with all # properties null - a privileged-access report that is complete and # entirely anonymous. These three name the principals; Group.Read.All also # covers expanding role-assignable groups to the users inside them. 'User.Read.All', # Name user principals (Get-MsecEntraRoleHolder) 'Group.Read.All', # Name groups + read their transitive members (Get-MsecEntraRoleHolder) 'Application.Read.All', # Name service principals holding privileged roles (Get-MsecEntraRoleHolder) # PIM for Groups. A PIM-governed group has ELIGIBLE members, who are # absent from /transitiveMembers entirely - so without this the group # reads as empty and everyone who can activate into a role-carrying # group is missing from the inventory (Get-MsecEntraRoleHolder). 'PrivilegedEligibilitySchedule.Read.AzureADGroup' ) } ) # WindowsDefenderATP (Defender for Endpoint) is commercial-only. Its service principal # doesn't exist in clouds where Defender has no presence (e.g. Azure China), so resolving # it would 404 and abort the whole bootstrap. Add it only where Defender is available; # the Defender-backed functions (Get-MsecDefenderScore*) simply won't apply elsewhere. if ($envInfo.DefenderResource) { $resources += @{ Name = 'WindowsDefenderATP' AppId = 'fc780465-2017-40d4-a0c5-307022471b92' RoleValues = @('Score.Read.All') # Defender exposure + device config score } } else { Write-Warning "Defender for Endpoint is not available in '$($envInfo.EnvironmentName)' - skipping the WindowsDefenderATP (Score.Read.All) permission. Defender score functions will be unavailable in this cloud." } # Resolve each requested role to its app-role GUID. Sovereign clouds (notably Azure # China) expose a REDUCED set of Microsoft Graph app roles - some security permissions # like SecurityEvents.Read.All simply don't exist there. Rather than hard-fail on the # first missing role (which would block the whole app), warn and skip the ones this # cloud doesn't offer, then proceed with whatever subset is available - same philosophy # as the Defender skip above. Functions needing a skipped permission won't work here. $missingRoles = @() foreach ($r in $resources) { Write-Verbose "Resolving $($r.Name) service principal and app roles" $sp = & $graph GET "/v1.0/servicePrincipals(appId='$($r.AppId)')" $r.ResourceSpId = $sp.id $r.Roles = @(foreach ($rv in $r.RoleValues) { $role = $sp.appRoles | Where-Object { $_.value -eq $rv -and $_.allowedMemberTypes -contains 'Application' } | Select-Object -First 1 if ($role) { [PSCustomObject]@{ Value = $rv; Id = $role.id } } else { $missingRoles += "$($r.Name): $rv" } }) } if ($missingRoles) { Write-Warning ( "These app roles are not available in '$($envInfo.EnvironmentName)' and will be skipped - " + "msec functions that need them won't work in this cloud:`n - " + ($missingRoles -join "`n - ")) } # Drop resources left with no available roles - nothing to request or consent for them. # Existing requiredResourceAccess entries for such resources are preserved untouched by # the merge below (they fall through the 'else' branch), so re-runs don't clobber them. $resources = @($resources | Where-Object { $_.Roles.Count -gt 0 }) if ($resources.Count -eq 0) { throw "None of the required app roles are available in '$($envInfo.EnvironmentName)'; cannot configure the app." } # ---- 2. Find-or-create the application (idempotent) ---- $existingApps = & $graph GET "/v1.0/applications?`$filter=displayName eq '$DisplayName'" if ($existingApps.value -and $existingApps.value.Count -gt 0) { if ($existingApps.value.Count -gt 1) { Write-Warning "Multiple apps named '$DisplayName' exist; using the first ($($existingApps.value[0].appId))." } $app = $existingApps.value[0] Write-Host "Found existing app: $($app.displayName) ($($app.appId))" } else { $app = & $graph POST '/v1.0/applications' @{ displayName = $DisplayName signInAudience = 'AzureADMyOrg' } Write-Host "Created app: $($app.displayName) ($($app.appId))" } $clientId = $app.appId # ---- 3. Ensure requiredResourceAccess includes every desired role (merge, don't clobber) ---- $desiredByResource = @{} foreach ($r in $resources) { $desiredByResource[$r.AppId] = @($r.Roles | ForEach-Object { @{ id = $_.Id; type = 'Role' } }) } $newRRA = @() $touched = @{} foreach ($entry in @($app.requiredResourceAccess)) { if ($desiredByResource.ContainsKey($entry.resourceAppId)) { $existingIds = @($entry.resourceAccess | ForEach-Object { $_.id }) $merged = @($entry.resourceAccess | ForEach-Object { @{ id = $_.id; type = $_.type } }) foreach ($d in $desiredByResource[$entry.resourceAppId]) { if ($d.id -notin $existingIds) { $merged += $d } } $newRRA += @{ resourceAppId = $entry.resourceAppId; resourceAccess = $merged } $touched[$entry.resourceAppId] = $true } else { $newRRA += $entry } } foreach ($k in $desiredByResource.Keys) { if (-not $touched.ContainsKey($k)) { $newRRA += @{ resourceAppId = $k; resourceAccess = $desiredByResource[$k] } } } Write-Verbose 'Patching requiredResourceAccess (merged with any existing entries)' & $graph PATCH "/v1.0/applications/$($app.id)" @{ requiredResourceAccess = $newRRA } | Out-Null # ---- 4. Find-or-create the matching service principal ---- $existingSp = & $graph GET "/v1.0/servicePrincipals?`$filter=appId eq '$clientId'" $appSp = if ($existingSp.value -and $existingSp.value.Count -gt 0) { Write-Verbose "Reusing existing service principal $($existingSp.value[0].id)" $existingSp.value[0] } else { Write-Verbose 'Creating service principal for the app' & $graph POST '/v1.0/servicePrincipals' @{ appId = $clientId } } # ---- 5. Find-or-create the certificate in Key Vault ---- $kvCert = try { Get-AzKeyVaultCertificate -VaultName $KeyVaultName -Name $CertificateName -ErrorAction Stop } catch { $null } if ($kvCert) { Write-Verbose "Using existing certificate '$CertificateName' (thumbprint $($kvCert.Thumbprint))" } else { Write-Verbose "Issuing self-signed certificate '$CertificateName' in Key Vault '$KeyVaultName'" $policy = New-AzKeyVaultCertificatePolicy ` -SecretContentType 'application/x-pkcs12' ` -SubjectName "CN=$DisplayName" ` -IssuerName 'Self' ` -ValidityInMonths $ValidityMonths ` -KeyType 'RSA' -KeySize 2048 ` -ReuseKeyOnRenewal:$false Add-AzKeyVaultCertificate -VaultName $KeyVaultName -Name $CertificateName -CertificatePolicy $policy | Out-Null $deadline = [DateTime]::UtcNow.AddMinutes(2) do { Start-Sleep -Seconds 2 $op = Get-AzKeyVaultCertificateOperation -VaultName $KeyVaultName -Name $CertificateName -ErrorAction Stop } until ($op.Status -eq 'completed' -or [DateTime]::UtcNow -gt $deadline) if ($op.Status -ne 'completed') { throw "Key Vault certificate issuance did not complete in time (last status: $($op.Status))." } $kvCert = Get-AzKeyVaultCertificate -VaultName $KeyVaultName -Name $CertificateName -ErrorAction Stop } # Tags overwrite each run - idempotent. Write-Verbose 'Tagging certificate with AppId / TenantId' Update-AzKeyVaultCertificate -VaultName $KeyVaultName -Name $CertificateName ` -Tag @{ AppId = $clientId; TenantId = $tenantId } -PassThru | Out-Null # ---- 6. Attach the cert to the app (only if a credential with this thumbprint isn't already there) ---- $thumbBytes = New-Object byte[] ($kvCert.Thumbprint.Length / 2) for ($i = 0; $i -lt $thumbBytes.Length; $i++) { $thumbBytes[$i] = [Convert]::ToByte($kvCert.Thumbprint.Substring($i * 2, 2), 16) } $thumbB64 = [Convert]::ToBase64String($thumbBytes) $alreadyAttached = @($app.keyCredentials) | Where-Object { $_.customKeyIdentifier -eq $thumbB64 } if ($alreadyAttached) { Write-Verbose 'Certificate already attached to app - skipping keyCredentials patch' } else { Write-Verbose 'Attaching certificate to the app registration' $publicCertB64 = [Convert]::ToBase64String($kvCert.Certificate.RawData) # NB: Graph treats keyCredentials as a replacement collection AND returns existing entries # with `key=null` on read, so we can't safely re-include them. We replace with just our # credential. The msec app is dedicated to this purpose, so this is acceptable. & $graph PATCH "/v1.0/applications/$($app.id)" @{ keyCredentials = @(@{ type = 'AsymmetricX509Cert' usage = 'Verify' displayName = $CertificateName key = $publicCertB64 }) } | Out-Null } # ---- 7. Grant admin consent for any (resource, role) pair not already assigned ---- # NB: this GET is not paged. Graph returns up to 100 assignments by default and msec asks # for around fifteen, so a second page would mean the app had been granted a great deal # more than this module needs - worth knowing if that ever becomes true. $existingAssignments = & $graph GET "/v1.0/servicePrincipals/$($appSp.id)/appRoleAssignments" $existingPairs = @{} foreach ($a in @($existingAssignments.value)) { $existingPairs["$($a.resourceId)|$($a.appRoleId)"] = $true } # Tracked rather than merely logged. This step used to report only through # Write-Verbose, which meant a re-run that added a dozen permissions printed one line # about finding the app and nothing whatsoever about the grants - indistinguishable, # from the outside, from having done nothing at all. $grantedNow = [System.Collections.Generic.List[string]]::new() $alreadyGranted = [System.Collections.Generic.List[string]]::new() foreach ($r in $resources) { foreach ($role in $r.Roles) { $label = "$($r.Name): $($role.Value)" $pair = "$($r.ResourceSpId)|$($role.Id)" if ($existingPairs.ContainsKey($pair)) { Write-Verbose "Already consented: $label" $alreadyGranted.Add($label) continue } Write-Verbose "Granting admin consent: $label" & $graph POST "/v1.0/servicePrincipals/$($appSp.id)/appRoleAssignments" @{ principalId = $appSp.id resourceId = $r.ResourceSpId appRoleId = $role.Id } | Out-Null $grantedNow.Add($label) } } # ---- 8. Say what happened ---------------------------------------------------------- Write-Host "Permissions: $($grantedNow.Count) granted now, $($alreadyGranted.Count) already present$(if ($missingRoles) { ", $(@($missingRoles).Count) unavailable in this cloud" })." foreach ($g in $grantedNow) { Write-Host " + $g" } if ($grantedNow.Count) { # The grant is immediate, but a token already issued does not carry it - consent # applies to tokens minted afterwards. Anyone who re-runs this to fix a 403 and then # retries the same command in the same session hits the identical 403 and concludes # the grant did not work, so this is the one instruction that has to be loud. Write-Host '' Write-Host 'Run Disconnect-Msec then Connect-Msec to pick up the new permissions - a cached token predates the grant and will still be refused.' -ForegroundColor Yellow } [PSCustomObject]@{ TenantId = $tenantId ClientId = $clientId KeyVaultName = $KeyVaultName CertificateName = $CertificateName AppObjectId = $app.id DisplayName = $app.displayName # Returned as well as printed, so a caller can assert on them rather than scrape # the console - and so the tests can pin this behaviour. GrantedNow = $grantedNow.ToArray() AlreadyGranted = $alreadyGranted.ToArray() UnavailableRoles = @($missingRoles) } } |