Setup/New-TenantReportsAppRegistration.ps1

#Requires -Modules Microsoft.Graph.Authentication, Microsoft.Graph.Applications, Microsoft.Graph.Identity.DirectoryManagement

<#
.SYNOPSIS
    Creates an Azure App Registration for Microsoft 365/Azure security reporting.
 
.DESCRIPTION
    This script creates an Azure App Registration specifically designed for generating periodic security reports
    for Microsoft 365/Azure tenants. It configures both Microsoft Graph and Office 365 Exchange Online
    Application permissions and grants admin consent automatically.
 
    The script supports:
    - Creating new app registrations or updating existing ones
    - Automatic admin consent for all configured API permissions
    - Optional client secret generation with configurable expiration
    - Optional certificate credential upload (existing or self-signed)
    - Optional Azure AD directory role assignment for Exchange Online management
    - Intelligent connection handling (reuses existing sessions when possible)
    - Idempotent permission grants (safe to re-run)
 
    Configured Microsoft Graph permissions include:
    - Directory, User, Group, and Organization read access
    - Audit logs and security events
    - Identity risk and protection data
    - Intune device management (read-only)
    - Mail and calendar access (read-only)
    - Reports and policies
 
    Configured Exchange Online permissions include:
    - Exchange.ManageAsApp for application-based management
    - Mailbox settings and calendar read access
 
.PARAMETER ApplicationName
    The display name for the Azure App Registration.
    Default: "TenantReports"
 
.PARAMETER TenantId
    The Azure AD Tenant ID where the app registration will be created.
    This parameter is mandatory.
 
.PARAMETER CreateClientSecret
    Switch parameter to create a client secret for service principal authentication.
    If specified, a client secret will be generated and included in the output.
     
    IMPORTANT: The secret value is only displayed once and cannot be retrieved later.
 
.PARAMETER ClientSecretDescription
    Description for the client secret, visible in the Azure portal.
    Default: "TenantReports Client Secret"
 
.PARAMETER ClientSecretExpirationMonths
    Number of months until the client secret expires.
    Valid range: 1-24 months.
    Default: 24 months
 
.PARAMETER AssignDirectoryRoles
    Switch parameter to assign Azure AD directory roles to the service principal.
    Required for Exchange Online management operations.
 
.PARAMETER DirectoryRoles
    Array of directory role names to assign when -AssignDirectoryRoles is specified.
    Default: @('Exchange Administrator')
 
    Common roles for security reporting:
    - 'Exchange Administrator' - Required for Exchange Online management
    - 'Security Administrator' - Access to security-related features
    - 'Security Reader' - Read-only access to security features
    - 'Global Reader' - Read-only access to all administrative features
 
.PARAMETER CertificateThumbprint
    Thumbprint of an existing certificate to upload to the app registration.
    The certificate must exist in Cert:\CurrentUser\My or Cert:\LocalMachine\My.
    Mutually exclusive with -CreateSelfSignedCertificate.
 
.PARAMETER CreateSelfSignedCertificate
    Switch parameter to create a new self-signed certificate and upload it to the app registration.
    The certificate is created in Cert:\CurrentUser\My with RSA 2048-bit key and SHA256 hash.
    Mutually exclusive with -CertificateThumbprint.
 
.PARAMETER CertificateSubject
    Subject name for the self-signed certificate.
    Default: "CN=TenantReports"
    Can only be used with -CreateSelfSignedCertificate.
 
.PARAMETER CertificateExpirationMonths
    Number of months until the self-signed certificate expires.
    Valid range: 1-24 months.
    Default: 24 months
    Can only be used with -CreateSelfSignedCertificate.
 
.EXAMPLE
    .\New-TenantReportsAppRegistration.ps1 -TenantId "12345678-1234-1234-1234-123456789012"
 
    Creates an app registration named "TenantReports" with default permissions.
    No client secret is created.
 
.EXAMPLE
    .\New-TenantReportsAppRegistration.ps1 -ApplicationName "Contoso-SecurityReports" -TenantId "12345678-1234-1234-1234-123456789012" -CreateClientSecret
 
    Creates an app registration with a custom name and generates a client secret
    that expires in 24 months (default).
 
.EXAMPLE
    .\New-TenantReportsAppRegistration.ps1 -TenantId "12345678-1234-1234-1234-123456789012" -CreateClientSecret -ClientSecretExpirationMonths 12
 
    Creates an app registration with a client secret that expires in 12 months.
 
.EXAMPLE
    .\New-TenantReportsAppRegistration.ps1 -TenantId "12345678-1234-1234-1234-123456789012" -AssignDirectoryRoles
 
    Creates an app registration and assigns the default 'Exchange Administrator' role
    to the service principal.
 
.EXAMPLE
    .\New-TenantReportsAppRegistration.ps1 -TenantId "12345678-1234-1234-1234-123456789012" -AssignDirectoryRoles -DirectoryRoles @('Exchange Administrator', 'Security Reader')
 
    Creates an app registration and assigns multiple directory roles.
 
.EXAMPLE
    $result = .\New-TenantReportsAppRegistration.ps1 -TenantId "12345678-1234-1234-1234-123456789012" -CreateClientSecret
    $result | Export-Csv -Path ".\AppRegistration.csv" -NoTypeInformation
 
    Creates an app registration and exports the results to a CSV file for documentation.
 
.EXAMPLE
    .\New-TenantReportsAppRegistration.ps1 -TenantId "12345678-1234-1234-1234-123456789012" -CertificateThumbprint "A1B2C3D4E5F6..."
 
    Creates an app registration and uploads an existing certificate from the local certificate store.
 
.EXAMPLE
    .\New-TenantReportsAppRegistration.ps1 -TenantId "12345678-1234-1234-1234-123456789012" -CreateSelfSignedCertificate
 
    Creates an app registration and generates a self-signed certificate (CN=TenantReports, 24 months expiry)
    in Cert:\CurrentUser\My, then uploads it to the app registration.
 
.EXAMPLE
    .\New-TenantReportsAppRegistration.ps1 -TenantId "12345678-1234-1234-1234-123456789012" -CreateSelfSignedCertificate -CertificateSubject "CN=Contoso-Reports" -CertificateExpirationMonths 12
 
    Creates an app registration with a self-signed certificate using a custom subject name
    and 12-month expiration.
 
.EXAMPLE
    .\New-TenantReportsAppRegistration.ps1 -TenantId "12345678-1234-1234-1234-123456789012" -CreateClientSecret -CreateSelfSignedCertificate
 
    Creates an app registration with both a client secret and a self-signed certificate,
    allowing users to choose either authentication method.
 
.EXAMPLE
    .\New-TenantReportsAppRegistration.ps1 -TenantId "12345678-1234-1234-1234-123456789012" -WhatIf
 
    Shows what actions would be performed without making any changes.
 
.OUTPUTS
    PSCustomObject
        Returns an object containing:
        - ApplicationName: Display name of the app registration
        - ApplicationId: The Application (client) ID
        - ObjectId: The Object ID of the app registration
        - TenantId: The Azure AD tenant ID
        - ServicePrincipal: The Service Principal Object ID
        - ClientSecret: The client secret value (if created)
        - SecretExpires: Client secret expiration date (if created)
        - CertificateThumbprint: The certificate thumbprint (if uploaded)
        - CertificateExpires: Certificate expiration date (if uploaded)
        - GraphPermissions: Count of granted/total Graph permissions
        - ExoPermissions: Count of granted/total Exchange Online permissions
        - DirectoryRoles: Comma-separated list of assigned directory roles
 
.NOTES
    Author: Tom de Leeuw
    Website: https://systom.dev
 
    Prerequisites:
    - Microsoft Graph PowerShell SDK modules:
        - Microsoft.Graph.Authentication
        - Microsoft.Graph.Applications
        - Microsoft.Graph.Identity.DirectoryManagement
     
    Required Permissions (for the user running this script):
    - Application.ReadWrite.All
    - AppRoleAssignment.ReadWrite.All
    - RoleManagement.ReadWrite.Directory (if using -AssignDirectoryRoles)
 
    The script automatically handles:
    - Connection to Microsoft Graph (or reuses existing connection)
    - Service principal creation for the app registration
    - Admin consent for all API permissions
    - Directory role activation if not already active
 
.LINK
    https://systom.dev
 
.LINK
    https://learn.microsoft.com/en-us/graph/permissions-reference
 
.LINK
    https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/permissions-reference
#>

[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
param(
    [Parameter(Position = 0, ValueFromPipelineByPropertyName = $true)]
    [ValidateNotNullOrEmpty()]
    [Alias('Name', 'DisplayName')]
    [string]$ApplicationName = 'TenantReports',

    [Parameter(Mandatory = $true, Position = 1, ValueFromPipelineByPropertyName = $true)]
    [ValidateNotNullOrEmpty()]
    [ValidatePattern('^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')]
    [Alias('Tenant')]
    [string]$TenantId,

    [Parameter(ValueFromPipelineByPropertyName = $true)]
    [switch]$CreateClientSecret,

    [Parameter(ValueFromPipelineByPropertyName = $true)]
    [string]$ClientSecretDescription = 'TenantReports Client Secret',

    [Parameter(ValueFromPipelineByPropertyName = $true)]
    [ValidateRange(1, 24)]
    [int]$ClientSecretExpirationMonths = 24,

    [Parameter(ValueFromPipelineByPropertyName = $true)]
    [switch]$AssignDirectoryRoles,

    [Parameter(ValueFromPipelineByPropertyName = $true)]
    [string[]]$DirectoryRoles = @('Exchange Administrator'),

    [Parameter(ValueFromPipelineByPropertyName = $true)]
    [ValidateNotNullOrEmpty()]
    [string]$CertificateThumbprint,

    [Parameter(ValueFromPipelineByPropertyName = $true)]
    [switch]$CreateSelfSignedCertificate,

    [Parameter(ValueFromPipelineByPropertyName = $true)]
    [ValidateNotNullOrEmpty()]
    [string]$CertificateSubject = 'CN=TenantReports',

    [Parameter(ValueFromPipelineByPropertyName = $true)]
    [ValidateRange(1, 24)]
    [int]$CertificateExpirationMonths = 24
)

begin {
    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    if ($CertificateThumbprint -and $CreateSelfSignedCertificate) {
        throw '-CertificateThumbprint and -CreateSelfSignedCertificate are mutually exclusive.'
    }
    if (-not $CreateSelfSignedCertificate) {
        if ($PSBoundParameters.ContainsKey('CertificateSubject')) {
            throw '-CertificateSubject can only be used with -CreateSelfSignedCertificate.'
        }
        if ($PSBoundParameters.ContainsKey('CertificateExpirationMonths')) {
            throw '-CertificateExpirationMonths can only be used with -CreateSelfSignedCertificate.'
        }
    }

    $GraphAppId = '00000003-0000-0000-c000-000000000000'
    $ExoAppId   = '00000002-0000-0ff1-ce00-000000000000'

    $Permissions = @{
        $GraphAppId = @(
            '7ab1d382-f21e-4acd-a863-ba3e13f7da61' # Directory.Read.All
            'df021288-bdef-4463-88db-98f22de89214' # User.Read.All
            'b0afded3-3588-46d8-8b3d-9842eff778da' # AuditLog.Read.All
            '19dbc75e-c2e2-444c-a770-ec69d8559fc7' # DeviceManagementConfiguration.Read.All
            '5b567255-7703-4780-807c-7be8301ae99b' # Group.Read.All
            '483bed4a-2ad3-4361-a73b-c83ccdbdc53c' # RoleManagement.Read.All
            '6e472fd1-ad78-48da-a0f0-97ab2c6b769e' # IdentityRiskEvent.Read.All
            '607c7344-0eed-41e5-823a-9695ebe1b7b0' # IdentityRiskyServicePrincipal.Read.All
            'dc5007c0-2d7d-4c42-879c-2dab87571379' # IdentityRiskyUser.Read.All
            'bf394140-e372-4bf9-a898-299cfc7564e5' # SecurityEvents.Read.All
            '246dd0d5-5bd0-4def-940b-0421030a5b68' # Policy.Read.All
            '197ee4e9-b993-4066-898f-d6aecc55125b' # ThreatIndicators.Read.All
            'f8f035bb-2cce-47fb-8bf5-7baf3ecbee48' # ThreatAssessment.Read.All
            'd72bdbf4-a59b-405c-8b04-5995895819ac' # ThreatSubmission.ReadWrite.All
            '5e0edab9-c148-49d0-b423-ac253e121825' # SecurityActions.Read.All
            '810c84a8-4a9e-49e6-bf7d-12d183f40d01' # Mail.Read
            '2f51be20-0bb4-4fed-bf7b-db946066c75e' # DeviceManagementManagedDevices.Read.All
            '45cc0394-e837-488b-a098-1918f48d186c' # SecurityIncident.Read.All
            '38d9df27-64da-44fd-b7c5-a6fbac20248f' # UserAuthenticationMethod.Read.All
            '06a5fe6d-c49d-46a7-b082-56b1b14103c7' # DeviceManagementServiceConfig.Read.All
            '8ba4a692-bc31-4128-9094-475872af8a53' # Calendars.ReadBasic.All
            '230c1aed-a721-4c5d-9cb4-a90514e508ef' # Reports.Read.All
            '498476ce-e0fe-48b0-b801-37ba7e2685c6' # Organization.Read.All
        )
        $ExoAppId   = @(
            'dc50a0fb-09a3-484d-be87-e023b12c6440' # Exchange.ManageAsApp
            '2dfdc6dc-2fa7-4a2c-a922-dbd4f85d17be' # Calendars.Read
            'd45fa9f8-36e5-4cd2-b601-b063c7cf9ac2' # MailboxSettings.Read
            'bf24470f-10c1-436d-8d53-7b997eb473be' # User.Read.All
            '15f260d6-f874-4366-8672-6b3658c5a09b' # Organization.Read.All
        )
    }
}

process {
    #region Connection
    $Context = Get-MgContext -ErrorAction SilentlyContinue
    if (-not $Context -or $Context.TenantId -ne $TenantId) {
        Write-Host '[*] Connecting to tenant: ' -ForegroundColor Cyan -NoNewline
        Write-Host $TenantId -ForegroundColor White
        Connect-MgGraph -TenantId $TenantId -Scopes 'Application.ReadWrite.All', 'AppRoleAssignment.ReadWrite.All', 'RoleManagement.ReadWrite.Directory' -NoWelcome
    }
    else {
        Write-Host '[*] Using existing connection: ' -ForegroundColor Cyan -NoNewline
        Write-Host $TenantId -ForegroundColor White
    }
    #endregion

    #region App Registration
    $App = Get-MgApplication -Filter "displayName eq '$ApplicationName'" -ErrorAction SilentlyContinue

    # Build requiredResourceAccess using camelCase keys for -BodyParameter serialization.
    # The SDK's expanded parameter binding fails to convert nested hashtable collections
    # into IMicrosoftGraphResourceAccess[], so we bypass it with -BodyParameter.
    $RequiredAccess = @(
        foreach ($AppId in $Permissions.Keys) {
            @{
                resourceAppId  = $AppId
                resourceAccess = @(
                    foreach ($PermId in $Permissions[$AppId]) {
                        @{ id = $PermId; type = 'Role' }
                    }
                )
            }
        }
    )

    if ($App) {
        Write-Host "[!] App '$ApplicationName' exists - updating permissions" -ForegroundColor Yellow
        Update-MgApplication -ApplicationId $App.Id -BodyParameter @{ requiredResourceAccess = $RequiredAccess }
    }
    elseif ($PSCmdlet.ShouldProcess($ApplicationName, 'Create App Registration')) {
        Write-Host '[*] Creating app registration: ' -ForegroundColor Cyan -NoNewline
        Write-Host $ApplicationName -ForegroundColor White
        $App = New-MgApplication -BodyParameter @{
            displayName            = $ApplicationName
            signInAudience         = 'AzureADMyOrg'
            requiredResourceAccess = $RequiredAccess
        }
    }
    #endregion

    #region Service Principal
    $Sp = Get-MgServicePrincipal -Filter "appId eq '$($App.AppId)'" -ErrorAction SilentlyContinue
    if (-not $Sp) {
        Write-Host '[*] Creating service principal...' -ForegroundColor Cyan
        $Sp = New-MgServicePrincipal -AppId $App.AppId
        Start-Sleep -Seconds 10
    }
    #endregion

    #region Client Secret
    $Secret = $null
    if ($CreateClientSecret -and $PSCmdlet.ShouldProcess('Client Secret', 'Create')) {
        Write-Host '[*] Creating client secret...' -ForegroundColor Cyan
        $Secret = Add-MgApplicationPassword -ApplicationId $App.Id -PasswordCredential @{
            DisplayName = $ClientSecretDescription
            EndDateTime = (Get-Date).AddMonths($ClientSecretExpirationMonths)
        }
    }
    #endregion

    #region Certificate
    $CertificateInfo = $null
    if ($CreateSelfSignedCertificate -and $PSCmdlet.ShouldProcess('Self-Signed Certificate', 'Create')) {
        Write-Host '[*] Creating self-signed certificate...' -ForegroundColor Cyan

        $CertParams = @{
            Subject           = $CertificateSubject
            CertStoreLocation = 'Cert:\CurrentUser\My'
            KeyExportPolicy   = 'Exportable'
            KeySpec           = 'Signature'
            KeyLength         = 2048
            KeyAlgorithm      = 'RSA'
            HashAlgorithm     = 'SHA256'
            NotAfter          = (Get-Date).AddMonths($CertificateExpirationMonths)
        }
        $Certificate = New-SelfSignedCertificate @CertParams

        $CertificateInfo = @{
            Thumbprint = $Certificate.Thumbprint
            Expires    = $Certificate.NotAfter
        }
    }
    elseif ($CertificateThumbprint) {
        # Normalize thumbprint: remove whitespace, uppercase
        $NormalizedThumbprint = ($CertificateThumbprint -replace '\s', '').ToUpperInvariant()

        # Find certificate in local stores
        $Certificate = $null
        foreach ($StoreLocation in @('CurrentUser', 'LocalMachine')) {
            $CertPath = "Cert:\$StoreLocation\My\$NormalizedThumbprint"
            $Certificate = Get-Item -Path $CertPath -ErrorAction SilentlyContinue
            if ($Certificate) {
                Write-Host "[*] Found certificate in $StoreLocation store" -ForegroundColor Cyan
                break
            }
        }

        if (-not $Certificate) {
            throw "Certificate with thumbprint '$NormalizedThumbprint' not found in Cert:\CurrentUser\My or Cert:\LocalMachine\My."
        }

        $CertificateInfo = @{
            Thumbprint = $Certificate.Thumbprint
            Expires    = $Certificate.NotAfter
        }
    }

    # Upload public key to app registration
    if ($CertificateInfo -and $PSCmdlet.ShouldProcess('Certificate Key Credential', 'Upload to App Registration')) {
        Write-Host '[*] Uploading certificate to app registration...' -ForegroundColor Cyan

        $KeyCredential = @{
            Type               = 'AsymmetricX509Cert'
            Usage              = 'Verify'
            Key                = $Certificate.RawData
            DisplayName        = $Certificate.Subject
            StartDateTime      = $Certificate.NotBefore
            EndDateTime        = $Certificate.NotAfter
        }
        Update-MgApplication -ApplicationId $App.Id -KeyCredentials @($KeyCredential)

        Write-Host '[+] Certificate uploaded: ' -ForegroundColor Green -NoNewline
        Write-Host $CertificateInfo.Thumbprint -ForegroundColor White
    }
    #endregion

    #region Grant Permissions
    $GrantResults = @{
        Graph    = @{ Granted = 0; Failed = 0 }
        Exchange = @{ Granted = 0; Failed = 0 }
    }

    if ($PSCmdlet.ShouldProcess('API Permissions', 'Grant Admin Consent')) {
        Write-Host '[*] Granting admin consent...' -ForegroundColor Cyan

        foreach ($ResourceAppId in $Permissions.Keys) {
            $ResourceSp = Get-MgServicePrincipal -Filter "appId eq '$ResourceAppId'" -ErrorAction SilentlyContinue
            if (-not $ResourceSp) { continue }

            $KeyName = if ($ResourceAppId -eq $GraphAppId) { 'Graph' } else { 'Exchange' }
            $Existing = Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $Sp.Id -All -ErrorAction SilentlyContinue

            foreach ($RoleId in $Permissions[$ResourceAppId]) {
                if (@($Existing).Where({ $_.AppRoleId -eq $RoleId -and $_.ResourceId -eq $ResourceSp.Id }).Count -gt 0) {
                    $GrantResults[$KeyName].Granted++
                    continue
                }
                try {
                    $null = New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $Sp.Id -PrincipalId $Sp.Id -ResourceId $ResourceSp.Id -AppRoleId $RoleId -ErrorAction Stop
                    $GrantResults[$KeyName].Granted++
                }
                catch {
                    $GrantResults[$KeyName].Failed++
                    Write-Verbose "Failed to grant $RoleId : $_"
                }
            }
        }

        Write-Host '[+] Graph: ' -ForegroundColor Green -NoNewline
        Write-Host "$($GrantResults.Graph.Granted) granted, $($GrantResults.Graph.Failed) failed" -ForegroundColor White
        Write-Host '[+] Exchange: ' -ForegroundColor Green -NoNewline
        Write-Host "$($GrantResults.Exchange.Granted) granted, $($GrantResults.Exchange.Failed) failed" -ForegroundColor White
    }
    #endregion

    #region Directory Roles
    $RoleResults = @{
        Assigned = [System.Collections.Generic.List[string]]::new()
        Failed   = [System.Collections.Generic.List[string]]::new()
    }

    if ($AssignDirectoryRoles -and $PSCmdlet.ShouldProcess('Directory Roles', 'Assign')) {
        Write-Host '[*] Assigning directory roles...' -ForegroundColor Cyan

        foreach ($RoleName in $DirectoryRoles) {
            try {
                $Role = Get-MgDirectoryRole -Filter "displayName eq '$RoleName'" -ErrorAction SilentlyContinue
                if (-not $Role) {
                    $Template = Get-MgDirectoryRoleTemplate -All | Where-Object { $_.DisplayName -eq $RoleName }
                    if (-not $Template) { throw "Directory Role Template '$RoleName' not found." }
                    $Role = New-MgDirectoryRole -RoleTemplateId $Template.Id
                    Start-Sleep -Seconds 5
                }

                # Fail-Soft Strategy: Attempt assignment and handle "Already Exists" error
                # This is more robust than checking membership which can fail due to API paging or object property mapping.
                try {
                    $null = New-MgDirectoryRoleMemberByRef -DirectoryRoleId $Role.Id -BodyParameter @{
                        '@odata.id' = "https://graph.microsoft.com/v1.0/directoryObjects/$($Sp.Id)"
                    } -ErrorAction Stop
                    
                    Write-Host "[+] $RoleName : " -ForegroundColor Green -NoNewline
                    Write-Host 'Assigned' -ForegroundColor White
                    [void]$RoleResults.Assigned.Add($RoleName)
                }
                catch {
                    # 400 BadRequest with "already exist" is a success state in an idempotent script
                    if ($_.Exception.Message -like "*already exist*" -or $_.Exception.Message -like "*400*") {
                        Write-Host "[+] $RoleName : " -ForegroundColor Green -NoNewline
                        Write-Host 'Already assigned' -ForegroundColor White
                        [void]$RoleResults.Assigned.Add($RoleName)
                    }
                    else {
                        # Re-throw genuine errors (Access Denied, etc.)
                        throw $_
                    }
                }
            }
            catch {
                Write-Host "[-] $RoleName : " -ForegroundColor Red -NoNewline
                Write-Host "Failed - $($_.Exception.Message)" -ForegroundColor White
                [void]$RoleResults.Failed.Add($RoleName)
            }
        }
    }
    #endregion

    #region Output
    [PSCustomObject]@{
        ApplicationName  = $App.DisplayName
        ApplicationId    = $App.AppId
        ObjectId         = $App.Id
        TenantId         = $TenantId
        ServicePrincipal = $Sp.Id
        ClientSecret          = if ($Secret) { $Secret.SecretText } else { $null }
        SecretExpires         = if ($Secret) { $Secret.EndDateTime } else { $null }
        CertificateThumbprint = if ($CertificateInfo) { $CertificateInfo.Thumbprint } else { $null }
        CertificateExpires    = if ($CertificateInfo) { $CertificateInfo.Expires } else { $null }
        GraphPermissions = "$($GrantResults.Graph.Granted)/$($Permissions[$GraphAppId].Count)"
        ExoPermissions   = "$($GrantResults.Exchange.Granted)/$($Permissions[$ExoAppId].Count)"
        DirectoryRoles   = if ($RoleResults.Assigned) { $RoleResults.Assigned -join ', ' } else { $null }
    }

    if ($Secret) {
        Write-Host "`n[!] SAVE YOUR SECRET NOW - it won't be shown again!" -ForegroundColor Yellow -BackgroundColor DarkRed
    }

    if ($CertificateInfo -and $CreateSelfSignedCertificate) {
        Write-Host "`n[!] Self-signed certificate created:" -ForegroundColor Yellow
        Write-Host " Thumbprint : $($CertificateInfo.Thumbprint)" -ForegroundColor Yellow
        Write-Host " Expires : $($CertificateInfo.Expires)" -ForegroundColor Yellow
        Write-Host " Store : Cert:\CurrentUser\My" -ForegroundColor Yellow
        Write-Host " Remember to export the certificate (.pfx) for backup or deployment to other machines." -ForegroundColor Yellow
    }
    elseif ($CertificateInfo) {
        Write-Host "`n[+] Existing certificate uploaded:" -ForegroundColor Green
        Write-Host " Thumbprint : $($CertificateInfo.Thumbprint)" -ForegroundColor Green
        Write-Host " Expires : $($CertificateInfo.Expires)" -ForegroundColor Green
    }
    #endregion
}

end {
    $null = Disconnect-MgGraph
}