Public/New-ZTAssessAppRegistration.ps1

#Requires -Version 7.0

function New-ZTAssessAppRegistration {
    <#
    .SYNOPSIS
    Provisions the EntraZTAssess app registration with read-only Graph permissions.

    .DESCRIPTION
    Creates an Entra ID application registration and service principal for the
    EntraZTAssess assessment toolkit, grants it the least-privilege, read-only
    Microsoft Graph application permissions required by the selected assessment
    modules, uploads the assessment certificate as a credential, and writes a
    non-secret JSON configuration for Connect-ZTAssessment to consume.

    This is an admin-run, one-time setup step. It uses the Microsoft.Graph SDK
    directly and therefore performs Graph WRITE operations, which is why it lives
    in the repo-local EntraZTAssess.Provisioning module rather than in the
    read-only assessment module under source/.

    The interactive sign-in for THIS function requests elevated setup scopes,
    requested only as needed: Application.ReadWrite.All is always requested to
    create the application and service principal; AppRoleAssignment.ReadWrite.All
    is additionally requested only when -GrantAdminConsent is supplied, since it
    is only exercised when creating app role assignments directly. Those elevated
    scopes are used only for this provisioning step; they are NOT the scopes the
    assessment itself uses. The assessment runs exclusively with the read-only
    application permissions granted to the new app.

    By default the function does NOT grant admin consent programmatically. It emits
    an admin-consent URL that a Privileged Role Administrator (or Global
    Administrator) must approve, since every permission granted is a Microsoft
    Graph application permission and Application Administrator / Cloud
    Application Administrator cannot consent to those. Supply -GrantAdminConsent
    to create the app role assignments directly (also requires a Privileged Role
    Administrator or Global Administrator session).

    Re-running this function against a tenant that already has an application
    registered under the same -DisplayName does not silently create a duplicate.
    It stops with an actionable error naming the existing application's AppId
    unless -Force is supplied, in which case a new application is created
    alongside the existing one.

    .PARAMETER TenantId
    The directory (tenant) ID or domain name in which to create the application.

    .PARAMETER Modules
    The assessment modules whose read-only Graph scopes should be granted to the
    application. Defaults to every non-optional module in the assessment
    catalogue. The always-included Core module is added automatically.

    .PARAMETER CertificatePath
    Path to the public certificate (.cer) produced by New-ZTAssessCertificate.
    Its public key is uploaded to the application as a verification credential and
    its thumbprint is recorded in the output configuration.

    .PARAMETER DisplayName
    The display name of the application registration. Defaults to
    'EntraZTAssess-Assessment'.

    .PARAMETER Environment
    The national cloud to provision in. Valid values are Global, USGov, and China.
    Defaults to Global.

    .PARAMETER UseDeviceCode
    Uses the device code flow for the one-time interactive Microsoft Graph
    sign-in this function performs, instead of the default interactive
    browser sign-in. Use this when a local browser is unavailable, or when
    the tenant's Conditional Access policy blocks interactive browser
    sign-in and requires device code authentication instead.

    .PARAMETER GrantAdminConsent
    Grants the read-only application permissions programmatically by creating app
    role assignments on the new service principal. Requires a Privileged Role
    Administrator or Global Administrator session. When omitted, an admin-consent
    URL is emitted for a Privileged Role Administrator (or Global Administrator)
    to approve instead.

    .PARAMETER Force
    Creates a new application registration even when one with the same
    -DisplayName already exists in the tenant. Without this switch, the function
    stops with an actionable error naming the existing application's AppId rather
    than silently creating a duplicate.

    .PARAMETER ConfigOutputPath
    Path of the non-secret JSON configuration file to write for
    Connect-ZTAssessment. Defaults to ~/.ztassess/auth.json. No password or secret
    is ever written to this file.

    .EXAMPLE
    New-ZTAssessAppRegistration -TenantId 'contoso.onmicrosoft.com' -CertificatePath ~/.ztassess/EntraZTAssess.cer

    Creates the application with the default read-only scopes, uploads the
    certificate, writes ~/.ztassess/auth.json, and prints an admin-consent URL for
    a Privileged Role Administrator (or Global Administrator) to approve.

    .EXAMPLE
    New-ZTAssessAppRegistration -TenantId '00000000-0000-0000-0000-000000000000' -CertificatePath ./EntraZTAssess.cer -Modules Identity, ConditionalAccess, Devices -GrantAdminConsent

    Creates the application scoped to three assessment modules and grants admin
    consent programmatically.

    .EXAMPLE
    New-ZTAssessAppRegistration -TenantId 'contoso.onmicrosoft.com' -CertificatePath ~/.ztassess/EntraZTAssess.cer -UseDeviceCode

    Creates the application using the device code flow for the interactive
    sign-in, for tenants whose Conditional Access policy blocks interactive
    browser sign-in.

    .OUTPUTS
    PSCustomObject
    A summary with ClientId, TenantId, CertificateThumbprint, ConfigPath,
    ConsentUrl, and FailedGrants (an empty array unless -GrantAdminConsent
    was supplied and one or more app role assignments failed).

    .NOTES
    Requires the Microsoft.Graph SDK modules (Microsoft.Graph.Authentication,
    Microsoft.Graph.Applications). All permissions granted to the created
    application are read-only Graph application permissions and require admin
    consent before the assessment can run.

    Supports -WhatIf/-Confirm. The Microsoft Graph sign-in itself is gated by
    ShouldProcess because it can create or refresh a delegated consent grant in
    the directory; under -WhatIf the function reports what would happen and
    returns without connecting.

    If Microsoft Graph returns a transient throttling (429) or service (5xx)
    error while creating the application, service principal, credential, or an
    app role assignment, rerun this function. If a partial application
    registration was already created before the failure, supply -Force to
    proceed rather than stopping on the idempotency guard.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive admin-run provisioning function; coloured console guidance is intentional and not pipeline output.')]
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        [ValidatePattern(
            '^([0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}|[a-zA-Z0-9][a-zA-Z0-9-]*(\.[a-zA-Z0-9][a-zA-Z0-9-]*)+)$',
            ErrorMessage = 'TenantId must be a directory (tenant) GUID or a verified domain name, e.g. contoso.onmicrosoft.com.'
        )]
        [string]$TenantId,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string[]]$Modules,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$CertificatePath,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]$DisplayName = 'EntraZTAssess-Assessment',

        [Parameter()]
        [ValidateSet('Global', 'USGov', 'China')]
        [string]$Environment = 'Global',

        [Parameter()]
        [switch]$UseDeviceCode,

        [Parameter()]
        [switch]$GrantAdminConsent,

        [Parameter()]
        [switch]$Force,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]$ConfigOutputPath = (Join-Path -Path $HOME -ChildPath '.ztassess/auth.json')
    )

    $ErrorActionPreference = 'Stop'

    # The well-known Microsoft Graph resource application ID. Application permission
    # (app role) GUIDs are resolved from this service principal at runtime.
    $graphAppId = '00000003-0000-0000-c000-000000000000'

    # Elevated scopes used ONLY for this one-time provisioning step, not by the
    # assessment itself. Requested least-privilege: AppRoleAssignment.ReadWrite.All
    # is only needed when -GrantAdminConsent will create app role assignments.
    $setupScopes = [System.Collections.Generic.List[string]]::new()
    $setupScopes.Add('Application.ReadWrite.All')
    if ($GrantAdminConsent) {
        $setupScopes.Add('AppRoleAssignment.ReadWrite.All')
    }

    # --- Preconditions --------------------------------------------------------

    # Fail fast with an actionable message if the Graph SDK is not installed.
    $requiredCommands = @(
        'Connect-MgGraph'
        'Get-MgApplication'
        'Get-MgServicePrincipal'
        'New-MgApplication'
        'New-MgServicePrincipal'
        'New-MgServicePrincipalAppRoleAssignment'
    )
    $missingCommands = @($requiredCommands | Where-Object { -not (Get-Command -Name $_ -ErrorAction SilentlyContinue) })
    if ($missingCommands.Count -gt 0) {
        throw ('The Microsoft Graph SDK is required but these commands were not found: {0}. Install it with: Install-Module Microsoft.Graph.Authentication, Microsoft.Graph.Applications -Scope CurrentUser' -f ($missingCommands -join ', '))
    }

    if (-not (Test-Path -LiteralPath $CertificatePath)) {
        throw ('Certificate file not found: {0}. Run New-ZTAssessCertificate first.' -f $CertificatePath)
    }

    # --- Compute the read-only scope union ------------------------------------

    # Bundled with this module at Settings/permissions.psd1 (a sibling of
    # Public/), sourced from source/Settings/permissions.psd1 in the main
    # repo - see Settings/permissions.psd1's header. Resolved relative to the
    # module's own root so it works from a PSGallery install, not just a git
    # checkout.
    $permissionsPath = Join-Path -Path $PSScriptRoot -ChildPath '../Settings/permissions.psd1'
    if (-not (Test-Path -LiteralPath $permissionsPath)) {
        throw ('Permission catalogue not found at {0}.' -f $permissionsPath)
    }

    $catalogue = Import-PowerShellDataFile -Path $permissionsPath
    $moduleCatalogue = $catalogue.Modules

    # Default to every non-optional module that is not already always-included.
    if (-not $Modules) {
        $Modules = @(
            $moduleCatalogue.GetEnumerator() |
                Where-Object { -not $_.Value.Optional -and -not $_.Value.AlwaysIncluded } |
                    Select-Object -ExpandProperty Key
        )
    }

    # Validate the requested module names against the catalogue.
    $unknownModules = @($Modules | Where-Object { -not $moduleCatalogue.ContainsKey($_) })
    if ($unknownModules.Count -gt 0) {
        throw ('Unknown module(s): {0}. Valid modules: {1}.' -f ($unknownModules -join ', '), (($moduleCatalogue.Keys | Sort-Object) -join ', '))
    }

    # Always include the AlwaysIncluded (Core) modules, then union the selected.
    $alwaysIncluded = @(
        $moduleCatalogue.GetEnumerator() |
            Where-Object { $_.Value.AlwaysIncluded } |
                Select-Object -ExpandProperty Key
    )

    $scopeSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    foreach ($moduleName in (@($alwaysIncluded) + @($Modules))) {
        foreach ($scope in $moduleCatalogue[$moduleName].Scopes) {
            $null = $scopeSet.Add($scope)
        }
    }
    $requiredScopes = @($scopeSet | Sort-Object)

    if ($requiredScopes.Count -eq 0) {
        throw 'No read-only Graph scopes were resolved for the selected modules.'
    }

    Write-Host ('Required read-only Graph application permissions ({0}):' -f $requiredScopes.Count) -ForegroundColor Cyan
    $requiredScopes | ForEach-Object { Write-Host (' {0}' -f $_) }
    Write-Host ''

    # --- Connect with elevated setup scopes -----------------------------------

    Write-Host 'Connecting to Microsoft Graph with elevated one-time setup scopes...' -ForegroundColor Cyan
    Write-Host (' Setup scopes (not assessment scopes): {0}' -f ($setupScopes -join ', '))

    # The sign-in itself can create or refresh a delegated consent grant in the
    # directory, so it is a write operation and is gated by ShouldProcess. Under
    # -WhatIf, report what would happen and stop before connecting, since every
    # subsequent step depends on an authenticated Graph context.
    if (-not $PSCmdlet.ShouldProcess($TenantId, 'Connect to Microsoft Graph with elevated setup scopes')) {
        return
    }

    # Splatted rather than called with fixed parameters so -UseDeviceCode can
    # be added only when requested, and -NoWelcome only when the installed
    # Connect-MgGraph actually declares it - older Microsoft.Graph.Authentication
    # releases predate that parameter and would otherwise fail to bind it.
    $connectParameters = @{
        TenantId    = $TenantId
        Scopes      = $setupScopes
        Environment = $Environment
        ErrorAction = 'Stop'
    }
    if ($UseDeviceCode) {
        $connectParameters['UseDeviceCode'] = $true
    }
    if ((Get-Command -Name 'Connect-MgGraph').Parameters.ContainsKey('NoWelcome')) {
        $connectParameters['NoWelcome'] = $true
    }

    try {
        Connect-MgGraph @connectParameters
    } catch {
        throw ('Failed to connect to Microsoft Graph: {0}' -f $_.Exception.Message)
    }

    # --- Resolve Graph app role GUIDs at runtime ------------------------------

    try {
        $graphServicePrincipal = Get-MgServicePrincipal -Filter "appId eq '$graphAppId'" -ErrorAction Stop
    } catch {
        throw ('Failed to resolve the Microsoft Graph service principal: {0}' -f $_.Exception.Message)
    }

    if (-not $graphServicePrincipal) {
        throw 'The Microsoft Graph service principal could not be found in this tenant.'
    }

    # For each required scope string, find the Application app role by Value.
    $resolvedRoles = [System.Collections.Generic.List[object]]::new()
    $unmatchedScopes = [System.Collections.Generic.List[string]]::new()
    foreach ($scope in $requiredScopes) {
        $appRole = $graphServicePrincipal.AppRoles |
            Where-Object { $_.Value -eq $scope -and $_.AllowedMemberTypes -contains 'Application' } |
                Select-Object -First 1

        if ($appRole) {
            $resolvedRoles.Add([pscustomobject]@{ Scope = $scope; Id = $appRole.Id })
        } else {
            $unmatchedScopes.Add($scope)
        }
    }

    if ($unmatchedScopes.Count -gt 0) {
        Write-Warning ('No matching Application app role was found for the following scope(s); they will be skipped: {0}' -f ($unmatchedScopes -join ', '))
    }

    if ($resolvedRoles.Count -eq 0) {
        throw 'No application app roles could be resolved for the required scopes.'
    }

    # --- Read the certificate bytes -------------------------------------------

    # X509CertificateLoader (the non-obsolete replacement for the path-based
    # X509Certificate2 constructor, SYSLIB0057) is only available on newer .NET
    # runtimes. Prefer it when present; fall back to the constructor so this
    # still runs on the project's PowerShell 7.0 minimum.
    $certificateLoaderType = [type]::GetType('System.Security.Cryptography.X509Certificates.X509CertificateLoader')
    $certificate = if ($certificateLoaderType) {
        $certificateLoaderType::LoadCertificateFromFile($CertificatePath)
    } else {
        [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($CertificatePath)
    }
    $certThumbprint = $certificate.Thumbprint
    $certRawBytes = $certificate.GetRawCertData()
    # Thumbprint and raw bytes are captured; the handle is not used again.
    $certificate.Dispose()

    # --- Build the application resource access + key credential ----------------

    $resourceAccess = @($resolvedRoles | ForEach-Object {
            @{ Id = $_.Id; Type = 'Role' }
        })

    $requiredResourceAccess = @(
        @{
            ResourceAppId  = $graphAppId
            ResourceAccess = $resourceAccess
        }
    )

    $keyCredential = @{
        Type  = 'AsymmetricX509Cert'
        Usage = 'Verify'
        Key   = $certRawBytes
    }

    # --- Idempotency guard: refuse to silently duplicate an existing app -------

    try {
        $escapedDisplayName = $DisplayName.Replace("'", "''")
        $existingApplication = Get-MgApplication -Filter "displayName eq '$escapedDisplayName'" -ErrorAction Stop | Select-Object -First 1
    } catch {
        throw ("Failed to check for an existing application registration named '{0}': {1}" -f $DisplayName, $_.Exception.Message)
    }

    if ($existingApplication -and -not $Force) {
        throw ("An application registration named '{0}' already exists (appId {1}). Re-running this function would create a duplicate and orphan the existing application's consent grants. Supply -Force to create a new application anyway, or reuse the existing appId." -f $DisplayName, $existingApplication.AppId)
    }

    if ($existingApplication -and $Force) {
        Write-Warning ("An application registration named '{0}' already exists (appId {1}); -Force was supplied, so a new, separate application will be created." -f $DisplayName, $existingApplication.AppId)
    }

    # --- Create the application ------------------------------------------------

    $application = $null
    if ($PSCmdlet.ShouldProcess($DisplayName, 'Create Entra ID application registration')) {
        try {
            $application = New-MgApplication -DisplayName $DisplayName `
                -SignInAudience 'AzureADMyOrg' `
                -RequiredResourceAccess $requiredResourceAccess `
                -KeyCredentials @($keyCredential) `
                -ErrorAction Stop
        } catch {
            throw ('Failed to create the application registration: {0}' -f $_.Exception.Message)
        }

        Write-Host ("Created application '{0}' (appId {1})." -f $application.DisplayName, $application.AppId) -ForegroundColor Green
    }

    # --- Create the service principal -----------------------------------------

    $servicePrincipal = $null
    if ($application -and $PSCmdlet.ShouldProcess($application.AppId, 'Create service principal')) {
        try {
            $servicePrincipal = New-MgServicePrincipal -AppId $application.AppId -ErrorAction Stop
        } catch {
            throw ('Failed to create the service principal: {0}' -f $_.Exception.Message)
        }

        Write-Host ('Created service principal (objectId {0}).' -f $servicePrincipal.Id) -ForegroundColor Green
    }

    # --- Admin consent ---------------------------------------------------------

    # National-cloud aware admin-consent host.
    $consentHost = switch ($Environment) {
        'USGov' { 'login.microsoftonline.us' }
        'China' { 'login.partner.microsoftonline.cn' }
        default { 'login.microsoftonline.com' }
    }
    $consentUrl = if ($application) {
        ('https://{0}/{1}/adminconsent?client_id={2}' -f $consentHost, $TenantId, $application.AppId)
    } else {
        ('https://{0}/{1}/adminconsent?client_id=<appId>' -f $consentHost, $TenantId)
    }

    $failedGrants = [System.Collections.Generic.List[pscustomobject]]::new()
    if ($GrantAdminConsent) {
        if ($servicePrincipal -and $PSCmdlet.ShouldProcess($DisplayName, 'Grant admin consent (create app role assignments)')) {
            foreach ($role in $resolvedRoles) {
                try {
                    $null = New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $servicePrincipal.Id `
                        -PrincipalId $servicePrincipal.Id `
                        -ResourceId $graphServicePrincipal.Id `
                        -AppRoleId $role.Id `
                        -ErrorAction Stop
                    Write-Host (' Granted: {0}' -f $role.Scope) -ForegroundColor Green
                } catch {
                    Write-Warning ("Failed to grant '{0}': {1}" -f $role.Scope, $_.Exception.Message)
                    $failedGrants.Add([pscustomobject]@{ Scope = $role.Scope; Error = $_.Exception.Message })
                }
            }
        }
    } else {
        Write-Host ''
        Write-Host 'Admin consent is required before the assessment can run.' -ForegroundColor Yellow
        Write-Host 'Ask a Privileged Role Administrator (or Global Administrator) to approve this URL:' -ForegroundColor Yellow
        Write-Host (' {0}' -f $consentUrl)
    }

    # --- Write the non-secret configuration ------------------------------------

    # Prefer the sibling .pfx (cross-platform private key) if present, else the .cer.
    $pfxSibling = [System.IO.Path]::ChangeExtension($CertificatePath, '.pfx')
    $configCertPath = if (Test-Path -LiteralPath $pfxSibling) { $pfxSibling } else { $CertificatePath }

    $config = [ordered]@{
        TenantId              = $TenantId
        ClientId              = if ($application) { $application.AppId } else { $null }
        CertificateThumbprint = $certThumbprint
        CertificatePath       = $configCertPath
        Environment           = $Environment
    }

    $configDir = Split-Path -Path $ConfigOutputPath -Parent
    if ($configDir -and -not (Test-Path -LiteralPath $configDir)) {
        if ($PSCmdlet.ShouldProcess($configDir, 'Create configuration directory')) {
            $null = New-Item -Path $configDir -ItemType Directory -Force
        }
    }

    if ($PSCmdlet.ShouldProcess($ConfigOutputPath, 'Write non-secret connection configuration')) {
        $json = $config | ConvertTo-Json -Depth 4
        Set-Content -Path $ConfigOutputPath -Value $json -Encoding utf8
        Write-Host ('Wrote connection configuration to {0}' -f $ConfigOutputPath) -ForegroundColor Green
    }

    # --- Final summary ---------------------------------------------------------

    $summary = [pscustomobject]@{
        PSTypeName            = 'ZTAssess.AppRegistration'
        ClientId              = $config.ClientId
        TenantId              = $TenantId
        CertificateThumbprint = $certThumbprint
        ConfigPath            = $ConfigOutputPath
        ConsentUrl            = $consentUrl
        FailedGrants          = @($failedGrants)
    }

    Write-Host ''
    Write-Host 'Provisioning summary:' -ForegroundColor Cyan
    Write-Host (' ClientId : {0}' -f $summary.ClientId)
    Write-Host (' TenantId : {0}' -f $summary.TenantId)
    Write-Host (' Thumbprint : {0}' -f $summary.CertificateThumbprint)
    Write-Host (' Config : {0}' -f $summary.ConfigPath)
    Write-Host (' Consent URL: {0}' -f $summary.ConsentUrl)
    if ($summary.FailedGrants.Count -gt 0) {
        Write-Host (' Failed grants: {0}' -f (($summary.FailedGrants | ForEach-Object { $_.Scope }) -join ', ')) -ForegroundColor Yellow
    }
    Write-Host ''

    return $summary
}