Remediation/EntraID/ENT-APP-REGISTRATIONS.ps1
|
# UNTESTED: this script has never been run on a real tenant. Read it, try it on a test tenant first, and use it at your own risk. <# .SYNOPSIS Exports the application registrations of the sources and recreates in the target those the customer keeps (procedure ENT-APP-REGISTRATIONS). .DESCRIPTION Data read: the applications table of each source and, in an -Apply run, the configuration of each registration on the source. In an -Apply run, the script first exports the configuration of the sources to exports\registrations-<tenant>.json: URIs, audience, allowed APIs, exposed roles. The export holds no secret value, only the count and the end dates. decisions\ENT-APP-REGISTRATIONS.csv: one row per registration, Carry over or Drop. The applications of this module are proposed for Drop. For a Carry over row, the script creates the registration in the target and its service principal: - same name and same redirect URIs, single tenant; - only the permissions marked Grant in target in ENT-APP-PERMISSIONS.csv, never a copy of the source; - actually assigning these permissions falls to ENT-APP-PERMISSIONS, to run again after a new snapshot. Limits: the script creates no secret, because its value must not be written. The owner creates it, stores it in the customer vault and updates the configuration of the application: tenant identifier, application identifier, credentials. The identifierUris and delegated permissions stay to carry over by hand: the URI often holds the old AppId. Deleting the source registrations happens at closure. #> $c = Open-TcRemediation $SelectList = 'id,appId,displayName,signInAudience,web,spa,publicClient,isFallbackPublicClient,identifierUris,requiredResourceAccess,appRoles,api,optionalClaims,passwordCredentials,keyCredentials' $PermissionFile = 'ENT-APP-PERMISSIONS' $OwnApps = (Get-TcRemediationOption 'ProtectedApplicationPattern') function ConvertTo-ExportCopy { <# Copy with no sensitive value: only the count and end dates of credentials are kept. #> param($App) return [ordered]@{ displayName = (Get-TcMember $App 'displayName'); appId = (Get-TcMember $App 'appId'); signInAudience = (Get-TcMember $App 'signInAudience'); web = (Get-TcMember $App 'web'); spa = (Get-TcMember $App 'spa'); publicClient = (Get-TcMember $App 'publicClient') isFallbackPublicClient = (Get-TcMember $App 'isFallbackPublicClient'); identifierUris = (Get-TcMember $App 'identifierUris'); requiredResourceAccess = (Get-TcMember $App 'requiredResourceAccess'); appRoles = (Get-TcMember $App 'appRoles'); api = (Get-TcMember $App 'api'); optionalClaims = (Get-TcMember $App 'optionalClaims') secretItems = @((Get-TcMember $App 'passwordCredentials') | Where-Object { $_ }).Count; certificates = @((Get-TcMember $App 'keyCredentials') | Where-Object { $_ }).Count; credentialEnds = @(@((Get-TcMember $App 'passwordCredentials')) + @((Get-TcMember $App 'keyCredentials')) | Where-Object { $_ } | ForEach-Object { (Get-TcMember $_ 'endDateTime') }) } } function Get-KeptAccess { <# requiredResourceAccess of the target, built from the review: the resource and the role are resolved on the target by name. #> param([string]$Tenant, [object[]]$ChosenPolicies) $byResource = @{} foreach ($p in @($ChosenPolicies)) { $r = @(Invoke-TcGraph -Tenant $Tenant -Uri ("v1.0/servicePrincipals?`$filter=displayName eq '{0}'&`$select=appId,appRoles" -f [uri]::EscapeDataString((Get-TcMember $p 'Resource'))) -All) | Select-Object -First 1 $role = if ($r) { @((Get-TcMember $r 'appRoles') | Where-Object { (Get-TcMember $_ 'value') -eq (Get-TcMember $p 'Permission') }) | Select-Object -First 1 } if (-not $role) { Write-TcRemediationAlert ("Permission not found in the target: {0} ({1})" -f (Get-TcMember $p 'Permission'), (Get-TcMember $p 'Resource')); continue } if (-not $byResource[(Get-TcMember $r 'appId')]) { $byResource[$r.appId] = [System.Collections.Generic.List[object]]::new() } $byResource[(Get-TcMember $r 'appId')].Add(@{ id = (Get-TcMember $role 'id'); type = 'Role' }) } return @($byResource.Keys | ForEach-Object { @{ resourceAppId = $_; resourceAccess = @($byResource[$_]) } }) } $proposals = foreach ($t in $c.Tenants) { Write-TcRemediationTenant $t 'Registrations read' $rows = @(Get-TcRemediationTable $t 'applications') if (-not $rows.Count -and -not (Test-TcCollectionComplete $t 'applications')) { Write-TcRemediationAlert 'Registrations: collection incomplete, an empty table proves nothing.' } Write-TcRemediationNote ("{0} app registration(s)" -f $rows.Count) foreach ($l in $rows) { [ordered]@{ Tenant = $t; Application = $l['Name']; AppId = $l['AppId']; Audience = $l['Audience']; Created = $l['Created']; SecretItems = $l['Secrets']; Certificates = $l['Certificates'] Proposal = $(if ($l['Name'] -match $OwnApps) { 'Drop' } else { '' }) } } } $decisions = @(Get-TcDecision -Name 'ENT-APP-REGISTRATIONS' -Proposal @($proposals | Where-Object { $_ }) -Key 'Tenant', 'AppId' -Choice 'Carry over', 'Drop') foreach ($t in $c.Tenants) { Add-TcBlockAction -Tenant $t -Service Graph -Object 'Configuration export' -Description 'Export the registration configuration, without secret values (read only)' -Reason 'Read only.' -Block { param($a) $apps = @(Invoke-TcGraph -Tenant $a.tenant -Uri "v1.0/applications?`$select=$SelectList" -All | ForEach-Object { ConvertTo-ExportCopy $_ }) Write-TcJsonFile (Join-Path $script:TcRemediationRun['Folder'] ("exports/registrations-{0}.json" -f $a.tenant)) $apps Write-TcRemediationNote ("{0}: {1} registration(s) exported" -f $a.tenant, $apps.Count) } } $permissions = if (Test-TcDecisionFile $PermissionFile) { @(Read-TcDecisionRow $PermissionFile) } else { @() } $carriedOver = @($decisions | Where-Object { (Get-TcMember $_ 'Decision') -eq 'Carry over' }) if ($carriedOver.Count -and -not (Get-TcCount $permissions)) { Write-TcRemediationAlert 'The permission review does not exist: registrations will be created without permissions (ENT-APP-PERMISSIONS).' } $seen = @{} foreach ($d in $carriedOver) { $k = ((Get-TcText $d 'Application')).ToLowerInvariant() if ($seen[$k]) { Write-TcRemediationAlert ("{0} is carried over from several sources: only one registration will be created, from {1}" -f (Get-TcMember $d 'Application'), $seen[$k]); continue } $seen[$k] = (Get-TcMember $d 'Tenant') $chosenPolicies = @($permissions | Where-Object { (Get-TcMember $_ 'Tenant') -eq (Get-TcMember $d 'Tenant') -and (Get-TcMember $_ 'Application') -eq (Get-TcMember $d 'Application') -and (Get-TcMember $_ 'Decision') -eq 'Grant in target' } | ForEach-Object { @{ Resource = (Get-TcMember $_ 'Resource'); Permission = (Get-TcMember $_ 'Permission') } }) Add-TcBlockAction -Tenant 'Target' -Service Graph -Object (Get-TcMember $d 'Application') -Description ("Create the registration {0} in the target, from {1}, with {2} kept permission(s)" -f (Get-TcMember $d 'Application'), (Get-TcMember $d 'Tenant'), $chosenPolicies.Count) ` -Data @{ Source = (Get-TcMember $d 'Tenant'); AppId = (Get-TcMember $d 'AppId'); Name = (Get-TcMember $d 'Application'); ChosenPolicies = $chosenPolicies } ` -Test { param($a) [bool]@(Invoke-TcGraph -Tenant $a.tenant -Uri ("v1.0/applications?`$filter=displayName eq '{0}'&`$select=id" -f [uri]::EscapeDataString(($a.data.Name -replace "'", "''"))) -All).Count } ` -Block { param($a) $s = Invoke-TcGraph -Tenant $a.data.Source -Uri ("v1.0/applications(appId='{0}')?`$select={1}" -f $a.data.AppId, $SelectList) $body = @{ displayName = $a.data.Name; signInAudience = 'AzureADMyOrg'; isFallbackPublicClient = [bool](Get-TcMember $s 'isFallbackPublicClient') requiredResourceAccess = @(Get-KeptAccess $a.tenant $a.data.ChosenPolicies) web = @{ redirectUris = @((Get-TcMember (Get-TcMember $s 'web') 'redirectUris')); logoutUrl = (Get-TcMember (Get-TcMember $s 'web') 'logoutUrl'); homePageUrl = (Get-TcMember (Get-TcMember $s 'web') 'homePageUrl') } spa = @{ redirectUris = @((Get-TcMember (Get-TcMember $s 'spa') 'redirectUris')) }; publicClient = @{ redirectUris = @((Get-TcMember (Get-TcMember $s 'publicClient') 'redirectUris')) } } $n = Invoke-TcGraph -Tenant $a.tenant -Method POST -Uri 'v1.0/applications' -Body $body $a.undo = @{ type = 'Graph'; tenant = $a.tenant; method = 'DELETE'; uri = "v1.0/applications/$((Get-TcMember $n 'id'))" } Invoke-TcGraph -Tenant $a.tenant -Method POST -Uri 'v1.0/servicePrincipals' -Body @{ appId = (Get-TcMember $n 'appId') } | Out-Null Write-TcRemediationNote ("{0}: new application identifier {1}, to hand over to the owner" -f $a.data.Name, (Get-TcMember $n 'appId')) } Add-TcManualAction -Tenant 'Target' -Object (Get-TcMember $d 'Application') -Description ("Hand the application {0} over to its owner and test it" -f (Get-TcMember $d 'Application')) -Who 'Application owner' ` -Instruction @(("{0}: create a certificate or secret in the target, App registrations > Certificates and secrets. Put the value in the customer's vault." -f (Get-TcMember $d 'Application')), 'Update the application configuration: tenant identifier, application identifier, credentials. Test before the cutover.', 'Compare with exports\registrations-<tenant>.json: identifierUris, exposed roles and delegated permissions.') } Invoke-TcRemediationPlan -Verify { param($t) if ($t -eq 'Target') { return } foreach ($d in $carriedOver | Where-Object { (Get-TcMember $_ 'Tenant') -eq $t }) { $n = @(Invoke-TcGraph -Tenant 'Target' -Uri ("v1.0/applications?`$filter=displayName eq '{0}'&`$select=appId,signInAudience" -f [uri]::EscapeDataString(((Get-TcMember $d 'Application') -replace "'", "''"))) -All).Count if ($n) { New-TcRemediationResult OK ("{0} present in the target" -f (Get-TcMember $d 'Application')) } else { New-TcRemediationResult Blocking ("{0} missing from the target" -f (Get-TcMember $d 'Application')) } } } Close-TcRemediation |