Remediation/EntraID/ENT-APP-PERMISSIONS.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
    Has the broad permissions reviewed, then grants or removes them in the target according to the decisions (procedure ENT-APP-PERMISSIONS).
 
.DESCRIPTION
    Data read: the sensitive permissions of the plan for each tenant, the list CON-APPLICATIONS.csv and the service principals of the
    target. In the target, an application permission covers the whole tenant, including the other business units.
 
    decisions\ENT-APP-PERMISSIONS.csv: one row per application, resource and permission.
      - row of a source: Grant in target or Do not carry over;
      - row of the target: Keep or Remove.
    An application marked Drop or Replace in CON-APPLICATIONS is proposed as Do not carry over.
 
    -Apply run: only the written decision counts. A permission to grant needs the application to exist already in the target: run
    ENT-APP-REGISTRATIONS and ENT-THIRD-PARTY-APPS, refresh the snapshot, then run this script again.
 
    Limits: warning the publisher and confirming that it works stay human steps. Delegated permissions are carried over by
    ENT-THIRD-PARTY-APPS with the admin consent.
#>

$c = Open-TcRemediation

$ApplicationFile = 'CON-APPLICATIONS'
$OwnApps = (Get-TcRemediationOption 'ProtectedApplicationPattern')

function Find-ServicePrincipal {
    <# Service principal by AppId, otherwise by name: a registration recreated in the target keeps its name, not its AppId. #>
    param([string]$Tenant, [string]$AppId, [string]$Name)
    $filters = @(); if ($AppId) { $filters += "appId eq '$AppId'" }; if ($Name) { $filters += "displayName eq '{0}'" -f [uri]::EscapeDataString(($Name -replace "'", "''")) }
    foreach ($f in $filters) {
        $l = @(Invoke-TcGraph -Tenant $Tenant -Uri ("v1.0/servicePrincipals?`$filter={0}&`$select=id,appId,displayName,appRoles" -f $f) -All)
        if ($l.Count) { return $l[0] }
    }
    return $null
}

function Find-RoleAssignment {
    <# Returns the client, the resource, the role and the existing assignment; the check before the write and the write share this read. #>
    param($A)
    $d = $A.data
    $client = Find-ServicePrincipal -Tenant $A.tenant -AppId (Get-TcMember $d 'AppId') -Name (Get-TcMember $d 'Application')
    $resource = Find-ServicePrincipal -Tenant $A.tenant -Name (Get-TcMember $d 'Resource')
    $role = if ($resource) { @((Get-TcMember $resource 'appRoles') | Where-Object { (Get-TcMember $_ 'value') -eq (Get-TcMember $d 'Permission') }) | Select-Object -First 1 }
    $att = $null
    if ($client -and $resource -and $role) { $att = @(Invoke-TcGraph -Tenant $A.tenant -Uri ("v1.0/servicePrincipals/{0}/appRoleAssignments" -f (Get-TcMember $client 'id')) -All | Where-Object { (Get-TcMember $_ 'appRoleId') -eq (Get-TcMember $role 'id') -and (Get-TcMember $_ 'resourceId') -eq (Get-TcMember $resource 'id') }) | Select-Object -First 1 }
    return @{ Client = $client; Resource = $resource; Role = $role; Attribution = $att }
}

$applications = if (Test-TcDecisionFile $ApplicationFile) { @(Read-TcDecisionRow $ApplicationFile) } else { @() }
if (-not (Get-TcCount $applications)) { Write-TcRemediationAlert 'The single list does not exist yet: run CON-APPLICATIONS first. The proposals do not take it into account.' }
$appDecision = @{}; foreach ($l in $applications) { $appDecision[((Get-TcText $l 'Application')).ToLowerInvariant()] = $l }

$proposals = foreach ($t in $c.Tenants) {
    Write-TcRemediationTenant $t 'Sensitive permissions read'
    $items = @(Get-TcRemediationPlanAction -Procedure 'ENT-APP-PERMISSIONS' -Tenant $t | ForEach-Object { (Get-TcMember $_ 'items') } | Where-Object { $_ })
    Write-TcRemediationNote ("{0} sensitive permission(s)" -f $items.Count)
    $principals = @{}; foreach ($x in Get-TcRemediationTable $t 'servicePrincipals') { $principals[([string]$x['Name']).ToLowerInvariant()] = $x }
    foreach ($e in $items) {
        if ([string]$e -notmatch '^(?<a>.*) - (?<r>.+?) - (?<p>.+)$') { Write-TcRemediationAlert "Unreadable plan item: $e"; continue }
        $name = $Matches['a']; $result = $Matches['r']; $permissionRow = $Matches['p']
        $k = $name.ToLowerInvariant()
        $app = $appDecision[$k]
        $appChoice = if ($app) { (Get-TcText $app 'Decision') } else { '' }
        $prop = if ($t -eq 'Target') { $(if ($name -match $OwnApps) { 'Keep' } else { '' }) } elseif ($name -match $OwnApps -or $appChoice -in 'Drop', 'Replace') { 'Do not carry over' } else { '' }
        [ordered]@{ Tenant = $t; Application = $name; AppId = $(if ($principals[$k]) { $principals[$k]['AppId'] }); Resource = $result; Permission = $permissionRow; 'Application decision' = $appChoice; Proposal = $prop }
    }
}

$decisions = @(Get-TcDecision -Name 'ENT-APP-PERMISSIONS' -Proposal @($proposals | Where-Object { $_ }) -Key 'Tenant', 'Application', 'Resource', 'Permission' -Choice 'Grant in target', 'Do not carry over', 'Keep', 'Remove')

$inTargetTenant = @{}; foreach ($x in Get-TcRemediationTable 'Target' 'servicePrincipals') { $inTargetTenant[([string]$x['AppId']).ToLowerInvariant()] = 1; $inTargetTenant[([string]$x['Name']).ToLowerInvariant()] = 1 }
$seen = @{}
foreach ($d in $decisions | Where-Object { (Get-TcMember $_ 'Decision') -eq 'Grant in target' -and (Get-TcMember $_ 'Tenant') -ne 'Target' }) {
    $k = ('{0}|{1}|{2}' -f (Get-TcMember $d 'Application'), (Get-TcMember $d 'Resource'), (Get-TcMember $d 'Permission')).ToLowerInvariant()
    if ($seen[$k]) { continue }
    $seen[$k] = 1
    $appChoice = if ($appDecision[((Get-TcText $d 'Application')).ToLowerInvariant()]) { (Get-TcText ($appDecision[((Get-TcText $d 'Application')).ToLowerInvariant()]) 'Decision') } else { '' }
    if ($appChoice -in 'Drop', 'Replace') { Write-TcRemediationAlert ("{0}: decision '{1}' in the single list, the permission {2} is not granted" -f (Get-TcMember $d 'Application'), $appChoice, (Get-TcMember $d 'Permission')); continue }
    if (-not ($inTargetTenant[((Get-TcText $d 'AppId')).ToLowerInvariant()] -or $inTargetTenant[((Get-TcText $d 'Application')).ToLowerInvariant()])) {
        Write-TcRemediationAlert ("{0} is not yet in the target: {1} waits for ENT-APP-REGISTRATIONS or ENT-THIRD-PARTY-APPS, then a new snapshot" -f (Get-TcMember $d 'Application'), (Get-TcMember $d 'Permission')); continue
    }
    Add-TcBlockAction -Tenant 'Target' -Service Graph -Object (Get-TcMember $d 'Application') -Description ("Grant {0} ({1}) to {2}" -f (Get-TcMember $d 'Permission'), (Get-TcMember $d 'Resource'), (Get-TcMember $d 'Application')) `
        -Data @{ Application = (Get-TcMember $d 'Application'); AppId = (Get-TcMember $d 'AppId'); Resource = (Get-TcMember $d 'Resource'); Permission = (Get-TcMember $d 'Permission') } `
        -Test { param($a) [bool](Get-TcMember (Find-RoleAssignment $a) 'Attribution') } `
        -Block {
            param($a)
            $x = Find-RoleAssignment $a
            if (-not (Get-TcMember $x 'Client')) { throw "Application missing from the target: $($a.data.Application)" }
            if (-not (Get-TcMember $x 'Role')) { throw "Permission not found on the resource: $($a.data.Permission)" }
            $uri = "v1.0/servicePrincipals/$((Get-TcMember (Get-TcMember $x 'Resource') 'id'))/appRoleAssignedTo"
            $r = Invoke-TcGraph -Tenant $a.tenant -Method POST -Uri $uri -Body @{ principalId = (Get-TcMember (Get-TcMember $x 'Client') 'id'); resourceId = (Get-TcMember (Get-TcMember $x 'Resource') 'id'); appRoleId = (Get-TcMember (Get-TcMember $x 'Role') 'id') }
            $a.undo = @{ type = 'Graph'; tenant = $a.tenant; method = 'DELETE'; uri = "$uri/$((Get-TcMember $r 'id'))" }
        }
}

foreach ($d in $decisions | Where-Object { (Get-TcMember $_ 'Decision') -eq 'Remove' -and (Get-TcMember $_ 'Tenant') -eq 'Target' }) {
    Add-TcBlockAction -Tenant 'Target' -Service Graph -Object (Get-TcMember $d 'Application') -Description ("Remove {0} ({1}) from {2}" -f (Get-TcMember $d 'Permission'), (Get-TcMember $d 'Resource'), (Get-TcMember $d 'Application')) `
        -Data @{ Application = (Get-TcMember $d 'Application'); AppId = (Get-TcMember $d 'AppId'); Resource = (Get-TcMember $d 'Resource'); Permission = (Get-TcMember $d 'Permission') } `
        -Test { param($a) -not (Get-TcMember (Find-RoleAssignment $a) 'Attribution') } `
        -Block {
            param($a)
            $x = Find-RoleAssignment $a
            $uri = "v1.0/servicePrincipals/$((Get-TcMember (Get-TcMember $x 'Resource') 'id'))/appRoleAssignedTo"
            $a.undo = @{ type = 'Graph'; tenant = $a.tenant; method = 'POST'; uri = $uri; body = @{ principalId = (Get-TcMember (Get-TcMember $x 'Client') 'id'); resourceId = (Get-TcMember (Get-TcMember $x 'Resource') 'id'); appRoleId = (Get-TcMember (Get-TcMember $x 'Role') 'id') } }
            Invoke-TcGraph -Tenant $a.tenant -Method DELETE -Uri "$uri/$((Get-TcMember (Get-TcMember $x 'Attribution') 'id'))"
        }
}

$granted = @($decisions | Where-Object { (Get-TcMember $_ 'Decision') -eq 'Grant in target' -and (Get-TcMember $_ 'Tenant') -ne 'Target' })
$toWarn = @($granted | Group-Object Application)
if ($toWarn.Count) {
    Export-TcRemediationReport -Name 'publishers-to-notify' -Rows @($toWarn | ForEach-Object { [ordered]@{ Application = (Get-TcMember $_ 'Name'); Sources = (@((Get-TcMember $_ 'Group') | ForEach-Object { (Get-TcMember $_ 'Tenant') } | Sort-Object -Unique) -join ', '); Permissions = (@((Get-TcMember $_ 'Group') | ForEach-Object { '{0} ({1})' -f (Get-TcMember $_ 'Permission'), (Get-TcMember $_ 'Resource') } | Sort-Object -Unique) -join ' ; ') } }) | Out-Null
    Add-TcManualAction -Tenant 'Target' -Object 'Publishers' -Description ("Notify the publishers of the {0} carried-over application(s) and get their procedure" -f $toWarn.Count) -Who 'Application owner' `
        -Instruction @('Backup, SOC and archiving first: an application that is not carried over leaves a protection gap from the cutover on.', 'List: exports\publishers-to-notify.csv in the log folder.')
}

Invoke-TcRemediationPlan -Verify {
    param($t)
    if ($t -ne 'Target') { return }
    $expected = @($granted | Select-Object Application, AppId, Resource, Permission -Unique)
    $missingItems = @($expected | Where-Object { -not (Get-TcMember (Find-RoleAssignment @{ tenant = 'Target'; data = @{ Application = (Get-TcMember $_ 'Application'); AppId = (Get-TcMember $_ 'AppId'); Resource = (Get-TcMember $_ 'Resource'); Permission = (Get-TcMember $_ 'Permission') } }) 'Attribution') })
    $extra = @($decisions | Where-Object { (Get-TcMember $_ 'Decision') -eq 'Remove' -and (Get-TcMember $_ 'Tenant') -eq 'Target' } | Where-Object { (Get-TcMember (Find-RoleAssignment @{ tenant = 'Target'; data = @{ Application = (Get-TcMember $_ 'Application'); AppId = (Get-TcMember $_ 'AppId'); Resource = (Get-TcMember $_ 'Resource'); Permission = (Get-TcMember $_ 'Permission') } }) 'Attribution') })
    New-TcRemediationResult $(if ($missingItems.Count -or $extra.Count) { 'Warning' } else { 'OK' }) ("{0} permission(s) kept: {1} missing; {2} to remove still present" -f $expected.Count, $missingItems.Count, $extra.Count)
}
Close-TcRemediation