Public/Initialize-TcAccess.ps1

function Initialize-TcAccess {
    <#
    .SYNOPSIS
    Creates the read-only application in the tenants of a project, for assessments without a sign-in window.
 
    .DESCRIPTION
    For each tenant, once per tenant with a Global Administrator:
 
      1. creates a self-signed certificate in the store of the current user, once, with a CSP key that
         cannot be exported, because Exchange Online refuses CNG keys;
      2. creates the TenantConverge Read-Only application, or reuses the one this project created, and
         adds the certificate;
      3. requests read-only permissions: Microsoft Graph, and Exchange.ManageAsApp for Exchange Online
         and Purview;
      4. with -GrantAdminConsent only: grants those permissions and the Global Reader role;
      5. saves the identifiers in <project>.access.local.json, never in project.json.
 
    It stops, and changes nothing in that tenant, when an application of that name exists that this
    project did not create, or when the application holds a credential TenantConverge did not add.
 
    Extended access needs the written agreement of the customer:
 
      SharePoint Sites.FullControl.All. It is the only application permission that opens SharePoint
                     administration. TenantConverge only reads with it.
      PowerPlatform Registers the application for Power Platform management. One more sign-in per tenant.
      Azure Reader role on each subscription the account can see. One more sign-in per tenant.
 
    It writes to the tenant: the application, its certificate, its permissions and its role, nothing else.
    Apart from this command and Remove-TcAccess, only Invoke-TcRemediation and Undo-TcRemediation write to a
    tenant, when you run them with -Apply. Use -WhatIf to see what it would do, -Verify to test connections
    without creating anything, and Remove-TcAccess at the end of the project.
 
    .PARAMETER Project
    The project file.
 
    .PARAMETER Tenant
    The roles to prepare. The default is the target and every source.
 
    .PARAMETER ExtendedAccess
    SharePoint, PowerPlatform, Azure, or None. Without this parameter, a tenant keeps what it has.
 
    .PARAMETER CertificateMonths
    How long the certificate stays valid. The default is 6 months.
 
    .PARAMETER GrantAdminConsent
    Grants the permissions on behalf of the organization. Without it, the application is created without
    consent, and the command shows the consent address for an administrator.
 
    .PARAMETER DelegatedPnP
    Registers a delegated PnP application. Use it when the customer refuses SharePoint extended access:
    each SharePoint read then opens a sign-in window. In a Browser project, that is all this command does:
    no certificate and no read-only application. In an Application project, it comes on top of them.
 
    .PARAMETER UseDeviceCode
    Signs in with a device code instead of a browser window.
 
    .PARAMETER Verify
    Only tests the connection to each service, tenant by tenant.
 
    .PARAMETER Service
    The services -Verify tests.
 
    .EXAMPLE
    Initialize-TcAccess -Project ./project.json -GrantAdminConsent
 
    Creates the application in every tenant and grants its read permissions.
 
    .EXAMPLE
    Initialize-TcAccess -Project ./project.json -ExtendedAccess SharePoint, PowerPlatform -GrantAdminConsent
 
    Adds the extended access the customer agreed to.
 
    .EXAMPLE
    Initialize-TcAccess -Project ./project.json -Verify
 
    Tests each connection and creates nothing.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)][string]$Project,
        [ValidateSet('Target', 'S1', 'S2', 'S3')][string[]]$Tenant,
        [ValidateSet('SharePoint', 'PowerPlatform', 'Azure', 'None')][string[]]$ExtendedAccess,
        [ValidateRange(1, 24)][int]$CertificateMonths = 6,
        [switch]$GrantAdminConsent,
        [switch]$DelegatedPnP,
        [switch]$UseDeviceCode,
        [switch]$Verify,
        [ValidateSet('Graph', 'Exo', 'Ipps', 'Teams', 'Pnp')][string[]]$Service = @('Graph', 'Exo', 'Ipps', 'Teams', 'Pnp')
    )

    $projectPath = Resolve-TcPath -Path $Project
    $projectData = Read-TcProjectFile -Path $projectPath
    Initialize-TcSession -Project $projectData -Access (Read-TcAccessFile -ProjectPath $projectPath)
    $chosen = @(if ($Tenant) { $Tenant | Where-Object { $script:TcTenants.Contains($_) } } else { $script:TcTenants.Keys })
    if ($chosen.Count -eq 0) { throw 'No tenant of this project matches -Tenant.' }

    if ($Verify) {
        foreach ($role in $chosen) {
            $entry = $script:TcTenants[$role]
            if (-not $entry['tenantId']) { $entry['tenantId'] = Get-TcProperty $entry['access'] 'tenantId' }
            Test-TcTenantAccess -Tenant $entry -Service $Service
        }
        return
    }

    $requested = $null
    if ($PSBoundParameters.ContainsKey('ExtendedAccess')) { $requested = @($ExtendedAccess | Where-Object { $_ -ne 'None' }) }

    # A browser project wants no application of its own, only the PnP one that SharePoint needs.
    if ($DelegatedPnP -and (Get-TcProperty (Get-TcProperty $projectData 'access') 'mode') -ne 'Application') {
        Write-TcMessage -Message "Browser mode: only the delegated application '$script:TcPnpApplicationName' is registered, with no certificate and no read-only application."
        foreach ($role in $chosen) {
            $entry = $script:TcTenants[$role]
            if ($PSCmdlet.ShouldProcess("$($entry['displayName']) ($($entry['primaryDomain']))", "Register the application '$script:TcPnpApplicationName'")) {
                Initialize-TcDelegatedAccess -ProjectPath $projectPath -Role $role -Tenant $entry -UseDeviceCode:$UseDeviceCode
            }
        }
        return
    }

    $names = ($chosen | ForEach-Object { '{0} ({1})' -f $script:TcTenants[$_]['displayName'], $script:TcTenants[$_]['primaryDomain'] }) -join ', '
    Write-TcMessage -Message "Tenants: $names"
    Write-TcMessage -Message "Application: '$script:TcApplicationName', read-only, Global Reader role"
    Write-TcMessage -Message "Microsoft Graph (application): $((Get-TcApplicationPermission) -join ', ')"
    Write-TcMessage -Message 'Office 365 Exchange Online and Microsoft Exchange Online Protection: Exchange.ManageAsApp'

    $certificate = Get-TcReadCertificate -Months $CertificateMonths
    $succeeded = [System.Collections.Generic.List[string]]::new()

    foreach ($role in $chosen) {
        $entry = $script:TcTenants[$role]
        $label = "$($entry['displayName']) ($($entry['primaryDomain']))"
        if (-not $PSCmdlet.ShouldProcess($label, "Create or reuse the application '$script:TcApplicationName'")) { continue }

        try {
            $extended = if ($null -ne $requested) { $requested } else {
                @(Get-TcProperty $entry['access'] 'grants') | Where-Object { $_ -in 'SharePoint', 'PowerPlatform', 'Azure' }
            }
            $result = Initialize-TcTenantApplication -ProjectPath $projectPath -Role $role -Tenant $entry -Certificate $certificate -Extended @($extended) `
                -GrantAdminConsent:$GrantAdminConsent -UseDeviceCode:$UseDeviceCode
            $succeeded.Add($role)
            if ($DelegatedPnP) { Register-TcDelegatedPnP -ProjectPath $projectPath -Role $role -Tenant $entry }
            Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
            $result
        }
        catch {
            Write-TcMessage -Message "$($entry['displayName']): $(Get-TcErrorText $_)" -Level Warning
            Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
            [pscustomobject][ordered]@{ Role = $role; Tenant = $entry['displayName']; ApplicationId = $null; Grants = @(); Consent = 'Failed' }
        }
    }

    if ($GrantAdminConsent -and $succeeded.Count -gt 0) {
        Write-TcMessage -Message 'Permissions take a few minutes to spread. Checking each service in a new session.'
        $exitCode = Invoke-TcAccessVerification -ProjectPath $projectPath -Role @($succeeded)
        if ($exitCode) { Write-TcMessage -Message "A service is not ready yet. Run Initialize-TcAccess -Project '$Project' -Verify in 15 minutes." -Level Warning }
    }
}

function Connect-TcGraphAdmin {
    <#
    .SYNOPSIS
    Delegated sign-in of a Global Administrator, limited to the scopes that register the application.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][System.Collections.IDictionary]$Tenant,
        [Parameter(Mandatory)][string]$TenantId,
        [string[]]$Scope = @('Application.ReadWrite.All', 'AppRoleAssignment.ReadWrite.All', 'RoleManagement.ReadWrite.Directory', 'Organization.Read.All'),
        [switch]$UseDeviceCode
    )

    Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
    Write-TcMessage -Message "Sign in to $($Tenant['displayName']) ($($Tenant['primaryDomain'])) with a Global Administrator account." -Level Warning
    Write-TcMessage -Message "The window can open behind this one. Do not tick 'Consent on behalf of your organization' in it: -GrantAdminConsent is how consent is given."

    $parameters = @{ TenantId = $TenantId; Scopes = $Scope; NoWelcome = $true; ContextScope = 'Process'; ErrorAction = 'Stop' }
    if ($UseDeviceCode) { $parameters['UseDeviceCode'] = $true }
    Connect-MgGraph @parameters

    $organization = @(Get-TcProperty (Invoke-TcAccessGraph -Uri 'v1.0/organization') 'value') | Select-Object -First 1
    if ([string](Get-TcProperty $organization 'id') -ne $TenantId) {
        throw "Connected to the wrong tenant: $(Get-TcProperty $organization 'id') instead of $TenantId."
    }

    $domains = @(Get-TcProperty $organization 'verifiedDomains')
    if ($Tenant['primaryDomain'] -notin @($domains | ForEach-Object { Get-TcProperty $_ 'name' })) {
        throw "$($Tenant['primaryDomain']) is not a verified domain of the connected tenant."
    }
    $initial = $domains | Where-Object { Get-TcProperty $_ 'isInitial' $false } | Select-Object -First 1
    if ($initial) { $Tenant['initialDomain'] = Get-TcProperty $initial 'name' }

    Write-TcMessage -Message "Connected: $(Get-TcProperty (Get-MgContext) 'Account')" -Level Success
}

function Invoke-TcAccessGraph {
    <#
    .SYNOPSIS
    The one Graph call of the application setup that may write.
    #>

    [CmdletBinding()]
    param(
        [ValidateSet('GET', 'POST', 'PATCH', 'DELETE')][string]$Method = 'GET',
        [Parameter(Mandatory)][string]$Uri,
        [AllowNull()]$Body
    )

    $parameters = @{ Method = $Method; Uri = "https://graph.microsoft.com/$Uri"; OutputType = 'Hashtable'; ErrorAction = 'Stop' }
    if ($null -ne $Body) {
        $parameters['Body'] = ConvertTo-Json -InputObject $Body -Depth 10
        $parameters['ContentType'] = 'application/json'
    }
    Invoke-MgGraphRequest @parameters
}

function Get-TcAccessGraphList {
    <#
    .SYNOPSIS
    Reads every page of a Graph collection during the application setup.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Uri
    )

    $items = [System.Collections.Generic.List[object]]::new()
    $response = Invoke-TcAccessGraph -Uri $Uri
    while ($true) {
        foreach ($value in @(Get-TcProperty $response 'value')) {
            if ($null -ne $value) { $items.Add($value) }
        }
        $next = Get-TcProperty $response '@odata.nextLink'
        if (-not $next) { break }
        $response = Invoke-MgGraphRequest -Method GET -Uri $next -OutputType Hashtable -ErrorAction Stop
    }
    $items.ToArray()
}

function Get-TcAccessGraphFilter {
    <#
    .SYNOPSIS
    Reads a filtered Graph collection during the application setup.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Collection,
        [Parameter(Mandatory)][string]$Filter
    )

    @(Get-TcAccessGraphList -Uri ('v1.0/{0}?$filter={1}' -f $Collection, [uri]::EscapeDataString($Filter)))
}

function Test-TcCspCertificate {
    <#
    .SYNOPSIS
    Exchange Online only accepts keys from a CSP provider. A name ending in Key Storage Provider is a CNG key.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)]$Certificate
    )

    try {
        $key = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($Certificate)
        $provider = if ($key -is [System.Security.Cryptography.RSACng]) { $key.Key.Provider.Provider }
        elseif ($key -is [System.Security.Cryptography.RSACryptoServiceProvider]) { $key.CspKeyContainerInfo.ProviderName }
        [bool]($provider -and $provider -notmatch 'Key Storage Provider')
    }
    catch { $false }
}

function Get-TcReadCertificate {
    <#
    .SYNOPSIS
    Returns a certificate valid for at least 30 more days, or creates one. Under -WhatIf it creates nothing.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)][int]$Months
    )

    $existing = Get-ChildItem Cert:\CurrentUser\My |
        Where-Object { $_.Subject -eq $script:TcCertificateSubject -and $_.HasPrivateKey -and $_.NotAfter -gt (Get-Date).AddDays(30) -and (Test-TcCspCertificate -Certificate $_) } |
        Sort-Object NotAfter -Descending | Select-Object -First 1
    if ($existing) {
        Write-TcMessage -Message ('Existing certificate, expires {0:yyyy-MM-dd}.' -f $existing.NotAfter)
        return $existing
    }

    if (-not $PSCmdlet.ShouldProcess('Cert:\CurrentUser\My', "Create the certificate '$script:TcCertificateSubject' for $Months months")) { return }

    $certificate = New-SelfSignedCertificate -Subject $script:TcCertificateSubject -CertStoreLocation 'Cert:\CurrentUser\My' -KeyAlgorithm RSA -KeyLength 2048 -HashAlgorithm SHA256 `
        -KeySpec KeyExchange -Provider 'Microsoft Enhanced RSA and AES Cryptographic Provider' -KeyExportPolicy NonExportable `
        -KeyUsage DigitalSignature, KeyEncipherment -NotAfter (Get-Date).AddMonths($Months) -FriendlyName $script:TcApplicationName
    if (-not (Test-TcCspCertificate -Certificate $certificate)) { throw 'The certificate is not in a CSP provider: Exchange Online would refuse it.' }
    Write-TcMessage -Message ('Certificate created, expires {0:yyyy-MM-dd}, key not exportable.' -f $certificate.NotAfter) -Level Success
    $certificate
}

function Get-TcResourcePrincipal {
    <#
    .SYNOPSIS
    Finds the service principal of a Microsoft resource, and creates it when the tenant has none yet.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][System.Collections.IDictionary]$Resource
    )

    $principal = @(Get-TcAccessGraphFilter -Collection 'servicePrincipals' -Filter "appId eq '$($Resource['AppId'])'") | Select-Object -First 1
    if ($principal) { return $principal }

    # A Microsoft application can be missing from a tenant, often Exchange Online Protection.
    try { Invoke-TcAccessGraph -Method POST -Uri 'v1.0/servicePrincipals' -Body @{ appId = $Resource['AppId'] } }
    catch { Write-TcMessage -Message "$($Resource['Name']) was not found in this tenant: $(Get-TcErrorText $_)" -Level Warning }
}

function Get-TcApplicationRoleId {
    <#
    .SYNOPSIS
    Reads the IDs of application roles from a resource, so no ID is hard-coded.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [AllowNull()]$Principal,
        [string[]]$Value,
        [string]$ResourceName
    )

    $roles = @{}
    foreach ($name in $Value) {
        $role = @(Get-TcProperty $Principal 'appRoles') |
            Where-Object { (Get-TcProperty $_ 'value') -eq $name -and @(Get-TcProperty $_ 'allowedMemberTypes') -contains 'Application' } |
            Select-Object -First 1
        if ($role) { $roles[$name] = Get-TcProperty $role 'id' }
        else { Write-TcMessage -Message "$ResourceName has no application permission $name. Skipped." -Level Warning }
    }
    $roles
}

function Invoke-TcAdminProcess {
    <#
    .SYNOPSIS
    Runs a Power Platform, Azure or PnP setup in its own process, which has its own sign-in.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Code,
        [Parameter(Mandatory)][string]$Executable,
        [Parameter(Mandatory)][hashtable]$Arguments
    )

    $file = Join-Path ([System.IO.Path]::GetTempPath()) ('tc-access-{0}.ps1' -f [guid]::NewGuid())
    $json = "$file.json"
    try {
        Set-Content -LiteralPath $file -Value $Code -Encoding UTF8
        $all = @('-NoProfile')
        if ($Executable -eq 'powershell.exe') { $all += @('-ExecutionPolicy', 'Bypass') }
        $all += @('-File', $file, '-OutFile', $json)
        foreach ($key in $Arguments.Keys) { $all += @("-$key", [string]$Arguments[$key]) }
        & $Executable @all | Out-Host
        if (-not (Test-Path -LiteralPath $json)) { throw 'No result: the sign-in was cancelled or the module failed.' }
        Read-TcJsonFile -Path $json
    }
    finally {
        Remove-Item -LiteralPath $file, $json -ErrorAction SilentlyContinue
    }
}

function Get-TcProjectApplication {
    <#
    .SYNOPSIS
    The read-only application this project created in a tenant, or nothing. Stops on one it cannot vouch for.
 
    .DESCRIPTION
    A name proves nothing. Anyone allowed to register an application can give one this name, and this
    command goes on to give it read access to the whole tenant. So only the ID in the access file counts,
    and a credential TenantConverge never adds stops the run too.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][System.Collections.IDictionary]$Tenant
    )

    $named = @(Get-TcAccessGraphFilter -Collection 'applications' -Filter "displayName eq '$script:TcApplicationName'")
    if (-not $named.Count) { return }

    $known = [string](Get-TcProperty (Get-TcProperty $Tenant 'access') 'clientId')
    $application = @($named | Where-Object { $known -and $_['appId'] -eq $known }) | Select-Object -First 1
    if (-not $application) {
        $found = @($named | ForEach-Object { $_['appId'] }) -join ', '
        throw "An application named '$script:TcApplicationName' already exists ($found), and this project did not create it. Anyone can register that name, and this command would give it read access to the whole tenant, so nothing was changed. Find out who created it, delete it in Entra ID, then run this command again."
    }

    $details = Invoke-TcAccessGraph -Uri "v1.0/applications/$($application['id'])?`$select=keyCredentials,passwordCredentials"
    $foreign = @(Get-TcProperty $details 'keyCredentials' @()) | Where-Object { (Get-TcProperty $_ 'displayName') -ne $script:TcCertificateSubject }
    if (@(Get-TcProperty $details 'passwordCredentials' @()).Count -or @($foreign).Count) {
        throw "The application '$script:TcApplicationName' ($known) holds a credential TenantConverge did not add. Whoever has it can read what this application reads, so nothing was changed. Remove that credential in Entra ID, or delete the application, then run this command again."
    }

    $application
}

function Save-TcApplicationEntry {
    <#
    .SYNOPSIS
    Saves the read-only application of a tenant in the local access file, with what the file already holds for PnP.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$ProjectPath,
        [Parameter(Mandatory)][string]$Role,
        [Parameter(Mandatory)][System.Collections.IDictionary]$Tenant,
        [Parameter(Mandatory)][string]$TenantId,
        [Parameter(Mandatory)][string]$ClientId,
        [Parameter(Mandatory)]$Certificate,
        [AllowEmptyCollection()][string[]]$Grants = @()
    )

    $entry = [ordered]@{
        tenantId      = $TenantId
        initialDomain = $Tenant['initialDomain']
        clientId      = $ClientId
        thumbprint    = $Certificate.Thumbprint
        application   = $script:TcApplicationName
        grants        = @($Grants)
        expires       = $Certificate.NotAfter.ToString('yyyy-MM-dd')
        initialized   = (Get-Date).ToString('yyyy-MM-dd HH:mm')
    }
    $previous = Get-TcProperty $Tenant 'access'
    foreach ($kept in 'pnpClientId', 'sharePointAdminUrl') {
        $value = Get-TcProperty $previous $kept
        if ($value) { $entry[$kept] = $value }
    }
    Write-TcAccessEntry -ProjectPath $ProjectPath -Role $Role -Entry $entry
    $Tenant['access'] = $entry
}

function Initialize-TcTenantApplication {
    <#
    .SYNOPSIS
    Creates or updates the application in one tenant and saves its identifiers.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)][string]$ProjectPath,
        [Parameter(Mandatory)][string]$Role,
        [Parameter(Mandatory)][System.Collections.IDictionary]$Tenant,
        [Parameter(Mandatory)]$Certificate,
        [AllowEmptyCollection()][string[]]$Extended = @(),
        [switch]$GrantAdminConsent,
        [switch]$UseDeviceCode
    )

    $resources = [ordered]@{
        Graph      = @{ AppId = '00000003-0000-0000-c000-000000000000'; Name = 'Microsoft Graph' }
        Exchange   = @{ AppId = '00000002-0000-0ff1-ce00-000000000000'; Name = 'Office 365 Exchange Online' }
        Protection = @{ AppId = '00000007-0000-0ff1-ce00-000000000000'; Name = 'Microsoft Exchange Online Protection' }
        SharePoint = @{ AppId = '00000003-0000-0ff1-ce00-000000000000'; Name = 'Office 365 SharePoint Online' }
    }
    $sitesPermission = 'Sites.FullControl.All'
    $globalReader = 'f2ef992c-3afb-46b9-b7cf-a126ee74c451'

    $tenantId = Resolve-TcTenantId -Tenant $Tenant
    Write-TcMessage -Message "Tenant: $($Tenant['displayName']) ($tenantId)"
    Connect-TcGraphAdmin -Tenant $Tenant -TenantId $tenantId -UseDeviceCode:$UseDeviceCode

    # 1. The application and its service principal.
    $application = Get-TcProjectApplication -Tenant $Tenant
    if ($application) {
        Write-TcMessage -Message "Existing application reused: $($application['appId'])"
    }
    else {
        $application = Invoke-TcAccessGraph -Method POST -Uri 'v1.0/applications' -Body @{
            displayName    = $script:TcApplicationName
            signInAudience = 'AzureADMyOrg'
            notes          = 'TenantConverge read-only assessment. Remove it at the end of the project with Remove-TcAccess.'
        }
        Write-TcMessage -Message "Application created: $($application['appId'])" -Level Success
        # Saved at once: the next run must know this application if a step below fails.
        Save-TcApplicationEntry -ProjectPath $ProjectPath -Role $Role -Tenant $Tenant -TenantId $tenantId -ClientId $application['appId'] -Certificate $Certificate -Grants @()
    }

    $principal = @(Get-TcAccessGraphFilter -Collection 'servicePrincipals' -Filter "appId eq '$($application['appId'])'") | Select-Object -First 1
    if (-not $principal) { $principal = Invoke-TcAccessGraph -Method POST -Uri 'v1.0/servicePrincipals' -Body @{ appId = $application['appId'] } }

    # 2. Requested permissions and certificate. Existing keys only come back on $select.
    $wanted = [ordered]@{ Graph = @(Get-TcApplicationPermission); Exchange = @('Exchange.ManageAsApp'); Protection = @('Exchange.ManageAsApp') }
    if ('SharePoint' -in $Extended) { $wanted['SharePoint'] = @($sitesPermission) }

    $principals = @{}
    $roles = @{}
    foreach ($key in @($wanted.Keys) + @('SharePoint') | Sort-Object -Unique) {
        $principals[$key] = Get-TcResourcePrincipal -Resource $resources[$key]
        $roles[$key] = if ($principals[$key]) {
            Get-TcApplicationRoleId -Principal $principals[$key] -Value @(Get-TcProperty $wanted $key) -ResourceName $resources[$key]['Name']
        }
        else { @{} }
    }

    $access = @(foreach ($key in $wanted.Keys) {
            if ($roles[$key].Count) {
                @{ resourceAppId = $resources[$key]['AppId']; resourceAccess = @($roles[$key].Values | ForEach-Object { @{ id = $_; type = 'Role' } }) }
            }
        })

    $keys = @(Get-TcProperty (Invoke-TcAccessGraph -Uri "v1.0/applications/$($application['id'])?`$select=keyCredentials") 'keyCredentials' @())
    $hash = [Convert]::ToBase64String($Certificate.GetCertHash())
    $raw = [Convert]::ToBase64String($Certificate.RawData)
    if (-not @($keys | Where-Object { (Get-TcProperty $_ 'customKeyIdentifier') -eq $hash -or (Get-TcProperty $_ 'key') -eq $raw }).Count) {
        $keys += @{ type = 'AsymmetricX509Cert'; usage = 'Verify'; key = $raw; displayName = $script:TcCertificateSubject }
        Write-TcMessage -Message 'Certificate added to the application.' -Level Success
    }
    Invoke-TcAccessGraph -Method PATCH -Uri "v1.0/applications/$($application['id'])" -Body @{ requiredResourceAccess = $access; keyCredentials = $keys } | Out-Null

    # 3. Admin consent, one assignment per application role, only when the administrator asks for it.
    $grants = [System.Collections.Generic.List[string]]::new()
    $consent = 'Pending'
    if ($GrantAdminConsent) {
        $existing = @(Get-TcAccessGraphList -Uri "v1.0/servicePrincipals/$($principal['id'])/appRoleAssignments")
        $granted = 0
        foreach ($key in $wanted.Keys) {
            foreach ($roleId in $roles[$key].Values) {
                if (@($existing | Where-Object { $_['appRoleId'] -eq $roleId -and $_['resourceId'] -eq $principals[$key]['id'] }).Count) { continue }
                Invoke-TcAccessGraph -Method POST -Uri "v1.0/servicePrincipals/$($principal['id'])/appRoleAssignments" `
                    -Body @{ principalId = $principal['id']; resourceId = $principals[$key]['id']; appRoleId = $roleId } | Out-Null
                $granted++
            }
        }
        Write-TcMessage -Message "Admin consent: $granted permissions granted, $($existing.Count) already in place." -Level Success

        if ('SharePoint' -notin $Extended -and $principals['SharePoint']) {
            $sitesRole = (Get-TcApplicationRoleId -Principal $principals['SharePoint'] -Value $sitesPermission -ResourceName 'SharePoint').Values
            foreach ($assignment in @($existing | Where-Object { $_['resourceId'] -eq $principals['SharePoint']['id'] -and $_['appRoleId'] -in $sitesRole })) {
                Invoke-TcAccessGraph -Method DELETE -Uri "v1.0/servicePrincipals/$($principal['id'])/appRoleAssignments/$($assignment['id'])" | Out-Null
                Write-TcMessage -Message "$sitesPermission removed: SharePoint extended access is not given." -Level Warning
            }
        }

        # 4. The Global Reader role.
        $assignments = @(Get-TcAccessGraphFilter -Collection 'roleManagement/directory/roleAssignments' -Filter "principalId eq '$($principal['id'])'" |
                Where-Object { $_['roleDefinitionId'] -eq $globalReader })
        if ($assignments.Count) {
            Write-TcMessage -Message 'Global Reader role already assigned.'
        }
        else {
            Invoke-TcAccessGraph -Method POST -Uri 'v1.0/roleManagement/directory/roleAssignments' -Body @{ principalId = $principal['id']; roleDefinitionId = $globalReader; directoryScopeId = '/' } | Out-Null
            Write-TcMessage -Message 'Global Reader role assigned to the application.' -Level Success
        }

        foreach ($name in 'Graph', 'Exchange', 'Purview', 'Teams') { $grants.Add($name) }
        if ('SharePoint' -in $Extended -and $roles['SharePoint'].Count) { $grants.Add('SharePoint') }
        $consent = 'Granted'

        # 5. Extended access, each in its own process with its own sign-in.
        if ('PowerPlatform' -in $Extended) {
            Write-TcMessage -Message 'Power Platform: sign in with Windows PowerShell 5.1, then the management application is registered.' -Level Warning
            try {
                [void](Invoke-TcAdminProcess -Code (Get-TcPowerPlatformSetupCode) -Executable 'powershell.exe' -Arguments @{ TenantId = $tenantId; AppId = $application['appId'] })
                $grants.Add('PowerPlatform')
                Write-TcMessage -Message 'Power Platform management application registered.' -Level Success
            }
            catch { Write-TcMessage -Message "Power Platform: $(Get-TcErrorText $_). This part keeps its sign-in window." -Level Warning }
        }

        if ('Azure' -in $Extended) {
            Write-TcMessage -Message 'Azure: sign in, then the Reader role is given on each subscription this account can see.' -Level Warning
            try {
                $azure = Invoke-TcAdminProcess -Code (Get-TcAzureSetupCode) -Executable 'pwsh' -Arguments @{ TenantId = $tenantId; ObjectId = $principal['id'] }
                foreach ($problem in @(Get-TcProperty $azure 'errors' @())) { Write-TcMessage -Message "Azure: $problem" -Level Warning }
                $subscriptions = @(Get-TcProperty $azure 'subscriptions' @())
                if ($subscriptions.Count) {
                    $grants.Add('Azure')
                    Write-TcMessage -Message "Reader role: $($subscriptions -join ', ')" -Level Success
                }
                else {
                    Write-TcMessage -Message "No subscription is visible to this account, so there is nothing to assign. Turn on 'Access management for Azure resources' if the tenant has any." -Level Warning
                }
            }
            catch { Write-TcMessage -Message "Azure: $(Get-TcErrorText $_)" -Level Warning }
        }
    }
    else {
        Write-TcMessage -Message "Consent not given. An administrator can give it at https://login.microsoftonline.com/$tenantId/adminconsent?client_id=$($application['appId']), or run this command with -GrantAdminConsent." -Level Warning
    }

    # 6. The local access file.
    Save-TcApplicationEntry -ProjectPath $ProjectPath -Role $Role -Tenant $Tenant -TenantId $tenantId -ClientId $application['appId'] -Certificate $Certificate -Grants @($grants)
    Write-TcMessage -Message "Saved in the local access file: application $($application['appId']), access $(@($grants) -join ', ')." -Level Success

    [pscustomobject][ordered]@{ Role = $Role; Tenant = $Tenant['displayName']; ApplicationId = $application['appId']; Grants = @($grants); Consent = $consent }
}

function Initialize-TcDelegatedAccess {
    <#
    .SYNOPSIS
    Registers the delegated PnP application of a tenant, and nothing else.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$ProjectPath,
        [Parameter(Mandatory)][string]$Role,
        [Parameter(Mandatory)][System.Collections.IDictionary]$Tenant,
        [switch]$UseDeviceCode
    )

    try {
        $Tenant['tenantId'] = Resolve-TcTenantId -Tenant $Tenant
        # This sign-in only finds the initial domain, which PnP needs. The registration signs in by itself.
        Connect-TcGraphAdmin -Tenant $Tenant -TenantId $Tenant['tenantId'] -Scope 'Organization.Read.All' -UseDeviceCode:$UseDeviceCode
        Register-TcDelegatedPnP -ProjectPath $ProjectPath -Role $Role -Tenant $Tenant
        $id = Get-TcProperty (Get-TcProperty $Tenant 'access') 'pnpClientId'
        [pscustomobject][ordered]@{ Role = $Role; Tenant = $Tenant['displayName']; ApplicationId = $id; Grants = @(); Consent = $(if ($id) { 'Delegated' } else { 'Not registered' }) }
    }
    catch {
        Write-TcMessage -Message "$($Tenant['displayName']): $(Get-TcErrorText $_)" -Level Warning
        [pscustomobject][ordered]@{ Role = $Role; Tenant = $Tenant['displayName']; ApplicationId = $null; Grants = @(); Consent = 'Failed' }
    }
    finally { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null }
}

function Register-TcDelegatedPnP {
    <#
    .SYNOPSIS
    Registers a delegated PnP application for a tenant and saves its ID. PnP runs in its own process.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$ProjectPath,
        [Parameter(Mandatory)][string]$Role,
        [Parameter(Mandatory)][System.Collections.IDictionary]$Tenant
    )

    $code = @'
param($Domain, $OutFile, $ApplicationName)
$ErrorActionPreference = 'Stop'
Import-Module PnP.PowerShell
$result = Register-PnPEntraIDAppForInteractiveLogin -ApplicationName $ApplicationName -Tenant $Domain `
    -SharePointDelegatePermissions 'AllSites.FullControl', 'TermStore.Read.All', 'User.Read.All' `
    -GraphDelegatePermissions 'Group.Read.All', 'User.Read.All', 'Sites.Read.All'
$id = $null
foreach ($item in @($result)) {
    if ($null -eq $item) { continue }
    foreach ($property in $item.PSObject.Properties) {
        if ($property.Name -match 'ClientId|AppId' -and [string]$property.Value -match '^[0-9a-f\-]{36}$') { $id = [string]$property.Value }
    }
    if (-not $id -and [string]$item -match '([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})') { $id = $Matches[1] }
}
@{ clientId = $id } | ConvertTo-Json | Set-Content -LiteralPath $OutFile -Encoding UTF8
'@


    Write-TcMessage -Message "$($Tenant['displayName']): sign in, then accept the consent for the PnP application." -Level Warning
    $id = $null
    try {
        $id = Get-TcProperty (Invoke-TcAdminProcess -Code $code -Executable 'pwsh' -Arguments @{ Domain = $Tenant['initialDomain']; ApplicationName = $script:TcPnpApplicationName }) 'clientId'
    }
    catch { Write-TcMessage -Message "Registration failed: $(Get-TcErrorText $_)" -Level Warning }

    while (-not $id) {
        $answer = (Read-TcAnswer -Prompt "Client ID of the PnP application of $($Tenant['displayName']), or Enter to skip").Trim()
        if (-not $answer) { break }
        if ($answer -match '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$') { $id = $answer }
        else { Write-TcMessage -Message 'Expected format: 00000000-0000-0000-0000-000000000000' -Level Warning }
    }

    if (-not $id) {
        Write-TcMessage -Message "$($Tenant['displayName']): without a PnP application, SharePoint will not be collected for this tenant." -Level Warning
        return
    }

    $entry = [ordered]@{}
    $current = Get-TcProperty (Read-TcAccessFile -ProjectPath $ProjectPath) $Role
    if ($current) { foreach ($key in @($current.Keys)) { $entry[$key] = $current[$key] } }
    # Graph stays out of the process that reads SharePoint, so the run needs these two from here.
    foreach ($known in 'tenantId', 'initialDomain') {
        if (-not $entry.Contains($known) -and $Tenant[$known]) { $entry[$known] = $Tenant[$known] }
    }
    $entry['pnpClientId'] = $id
    Write-TcAccessEntry -ProjectPath $ProjectPath -Role $Role -Entry $entry
    $Tenant['access'] = $entry
    Write-TcMessage -Message "$($Tenant['displayName']): PnP application saved in the local access file." -Level Success
}

function Get-TcPowerPlatformSetupCode {
    <#
    .SYNOPSIS
    Registers the application for Power Platform management, in Windows PowerShell 5.1.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param()

    @'
param($TenantId, $AppId, $OutFile)
$ErrorActionPreference = 'Stop'
Import-Module Microsoft.PowerApps.Administration.PowerShell
Add-PowerAppsAccount -Endpoint prod -TenantID $TenantId | Out-Null
$existing = $null
try { $existing = Get-PowerAppManagementApp -ApplicationId $AppId } catch { $existing = $null }
if (-not $existing -or -not $existing.applicationId) { New-PowerAppManagementApp -ApplicationId $AppId | Out-Null }
@{ ok = $true; existing = [bool]($existing -and $existing.applicationId) } | ConvertTo-Json | Set-Content -LiteralPath $OutFile -Encoding UTF8
'@

}

function Get-TcAzureSetupCode {
    <#
    .SYNOPSIS
    Gives the application the Reader role on each subscription the account can see.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param()

    @'
param($TenantId, $ObjectId, $OutFile)
$ErrorActionPreference = 'Stop'
Import-Module Az.Accounts, Az.Resources
Connect-AzAccount -Tenant $TenantId -WarningAction SilentlyContinue | Out-Null
$result = [ordered]@{ subscriptions = @(); errors = @() }
foreach ($subscription in @(Get-AzSubscription -TenantId $TenantId -WarningAction SilentlyContinue)) {
    $scope = "/subscriptions/$($subscription.Id)"
    try {
        if (-not @(Get-AzRoleAssignment -ObjectId $ObjectId -RoleDefinitionName 'Reader' -Scope $scope -WarningAction SilentlyContinue).Count) {
            New-AzRoleAssignment -ObjectId $ObjectId -RoleDefinitionName 'Reader' -Scope $scope -WarningAction SilentlyContinue | Out-Null
        }
        $result.subscriptions += $subscription.Name
    }
    catch { $result.errors += "$($subscription.Name): $($_.Exception.Message)" }
}
$result | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $OutFile -Encoding UTF8
'@

}

function Invoke-TcAccessVerification {
    <#
    .SYNOPSIS
    Tests each service in a new process, so the sign-ins of the setup are not reused. Returns its exit code.
    #>

    [CmdletBinding()]
    [OutputType([int])]
    param(
        [Parameter(Mandatory)][string]$ProjectPath,
        [Parameter(Mandatory)][string[]]$Role
    )

    Start-Sleep -Seconds 30
    $module = (Join-Path $PSScriptRoot '..' 'TenantConverge.psd1') -replace "'", "''"
    $project = $ProjectPath -replace "'", "''"
    $command = "Import-Module '$module'; `$rows = @(Initialize-TcAccess -Project '$project' -Tenant $($Role -join ',') -Verify); `$rows | Format-Table -AutoSize | Out-Host; if (@(`$rows | Where-Object Result -NotLike 'OK*').Count) { exit 1 }"
    & (Get-Process -Id $PID).Path -NoProfile -Command $command
    $LASTEXITCODE
}