Public/Grant-ZTAssessExchangeOnlineRole.ps1

#Requires -Version 7.0

function Grant-ZTAssessExchangeOnlineRole {
    <#
    .SYNOPSIS
    Grants the Exchange Online / Security & Compliance (IPPS) roles required
    by the selected assessment modules to the app registration's service
    principal.

    .DESCRIPTION
    Connects to Exchange Online / Security & Compliance as the calling
    Exchange administrator (interactive delegated sign-in — never as the
    assessment app itself), ensures the EntraZTAssess app registration has a
    corresponding Exchange Online service principal (creating one with
    New-ServicePrincipal if it does not already exist), and grants the app
    each Exchange Online / Security & Compliance entry required by the
    selected modules (resolved from the same catalogue used by
    Get-ZTAssessExchangeOnlineRoleGuidance).

    Connecting as the calling administrator rather than as the app is
    deliberate: the app being granted roles may not yet be authorized to
    connect to Exchange Online / IPPS at all (this is exactly the gap this
    function exists to close), so authenticating as the app here would be
    circular. Run this as yourself, signed in with an account that already
    holds sufficient Exchange Online / Security & Compliance administrative
    rights (for example Exchange Administrator or a member of Organization
    Management).

    Each catalogue entry is granted with whichever mechanism actually
    works for it in the connected tenant — these mechanisms are NOT
    interchangeable, and getting this wrong is a documented risk of this
    function, not a hypothetical one. Verified against a live tenant
    across the full permission catalogue:

    - A **role group** (only 'Security Reader', of the entries this
      catalogue currently uses) is granted with Add-RoleGroupMember, adding
      the app's Exchange Online service principal as a member.
    - A **management role** that supports direct application assignment
      ('View-Only Configuration' and 'View-Only Recipients', visible in
      the Exchange Online session) is granted directly to the app with
      New-ManagementRoleAssignment -App, which does not require the app to
      be a member of any role group. Despite reading like role-group
      names, neither of these is a role group in Exchange Online RBAC.
    - A **Security & Compliance-only management role that does NOT
      support direct application assignment** ('View-Only Retention
      Management' and 'View-Only DLP Compliance Management' — confirmed
      live: these are visible via Get-ManagementRole only after
      Connect-IPPSSession, and New-ManagementRoleAssignment -App fails
      with "management role can't be found" against both the Exchange
      Online and the IPPS connection) is instead granted through a
      dedicated role group scoped to exactly that one role, created with
      New-RoleGroup -Roles <role> and then populated with
      Add-RoleGroupMember — the Microsoft-documented workaround for a
      management role that direct -App assignment does not support.
      New-RoleGroup's own -Members parameter is deliberately not used:
      confirmed live to reject a service principal identity with
      "Couldn't find object" even though the identical identity is
      accepted moments later by Add-RoleGroupMember for a different role
      group, so membership is always added as a separate step using the
      mechanism already proven to work. The role group is named
      'EntraZTAssess - <role name>' and is reused, not recreated, on
      later runs.

    For each entry this function tries, in order: the role-group path;
    the direct management-role-assignment path against the same Exchange
    Online connection; the same direct assignment again against a
    lazily-established Connect-IPPSSession; and finally the dedicated
    role-group workaround (creating it via IPPS if that connection was
    established, otherwise via Exchange Online). It stops at the first
    mechanism that succeeds for that entry and records a failure only if
    every mechanism fails.

    Re-running this function is safe: it skips creating the service
    principal if one already exists for the AppId, skips creating a
    dedicated role group that already exists (adding to it instead), and
    treats an "already granted" response from any mechanism as success
    rather than a failure.

    .PARAMETER AppId
    The application (client) ID of the EntraZTAssess app registration.

    .PARAMETER ServicePrincipalObjectId
    The Entra ID object ID of the app's service principal, required only
    when no Exchange Online service principal exists yet for -AppId. Obtain
    it from New-ZTAssessAppRegistration's output, the Entra admin center, or
    Get-MgServicePrincipal. Not needed when an EXO service principal for
    -AppId already exists, and not needed at all for entries granted via
    the management-role path, which targets -AppId directly.

    .PARAMETER Modules
    Assessment modules to resolve required Exchange Online / IPPS entries
    for. Defaults to every module that requires Exchange Online / IPPS
    (SecurityCompliance, Collaboration, DataProtection, ThreatProtection).

    .PARAMETER Organization
    The verified domain of the tenant, for example contoso.onmicrosoft.com.

    .PARAMETER UserPrincipalName
    The calling Exchange administrator's sign-in name, passed to
    Connect-ExchangeOnline to skip the account-picker prompt. Optional; when
    omitted, Connect-ExchangeOnline prompts interactively for the signing-in
    account.

    .PARAMETER DisplayName
    Display name to use if a new Exchange Online service principal must be
    created. Defaults to 'EntraZTAssess-Assessment'.

    .EXAMPLE
    Grant-ZTAssessExchangeOnlineRole -AppId '11111111-1111-1111-1111-111111111111' `
        -ServicePrincipalObjectId '22222222-2222-2222-2222-222222222222' `
        -Organization 'contoso.onmicrosoft.com' -UserPrincipalName 'admin@contoso.onmicrosoft.com' `
        -Modules ThreatProtection

    Signs in interactively as the Exchange administrator, creates the
    Exchange Online service principal for the app if it does not already
    exist, and grants ThreatProtection's required entries using whichever
    mechanism (role group or management role) matches each one.

    .OUTPUTS
    PSCustomObject
    A summary with AppId, Organization, ServicePrincipalCreated,
    RoleGroupsGranted, RoleGroupsAlreadyMember, and FailedGrants (an empty
    array unless one or more grants failed against both Exchange Online and
    IPPS).

    .NOTES
    Requires the ExchangeOnlineManagement module. Must be run by an account
    that already holds sufficient Exchange Online / Security & Compliance
    administrative rights to grant role groups, assign management roles,
    and create role groups — this function does not elevate or grant that
    right to the caller.

    Get-ServicePrincipal, New-ServicePrincipal, Get-RoleGroup,
    New-RoleGroup, Get-RoleGroupMember, Add-RoleGroupMember, and
    New-ManagementRoleAssignment are dynamic RBAC proxy commands that
    ExchangeOnlineManagement only injects into the session AFTER
    Connect-ExchangeOnline succeeds; this function checks for
    Connect-ExchangeOnline / Connect-IPPSSession / Disconnect-ExchangeOnline
    before connecting and checks for the RBAC commands only after
    connecting, so it never reports the module missing just because those
    commands are not yet resolvable.

    Exact role and role-group names vary by tenant license and national
    cloud; confirm the entries in source/Settings/permissions.psd1 against
    Get-RoleGroup / Get-ManagementRole in the target tenant if a grant
    fails. A failure for a specific entry is recorded in FailedGrants with
    the underlying Exchange Online error rather than aborting the run, so
    one unresolvable entry never prevents the rest from being granted.

    Supports -WhatIf/-Confirm. The Exchange Online sign-in itself is gated
    by ShouldProcess because obtaining a session is a prerequisite for every
    write this function performs; under -WhatIf the function reports what
    would happen and returns without connecting.
    #>

    [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}$',
            ErrorMessage = 'AppId must be a GUID.'
        )]
        [string]$AppId,

        [Parameter()]
        [ValidatePattern(
            '^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$',
            ErrorMessage = 'ServicePrincipalObjectId must be a GUID.'
        )]
        [string]$ServicePrincipalObjectId,

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

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

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

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

    $ErrorActionPreference = 'Stop'

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

    # Connect-ExchangeOnline / Connect-IPPSSession / Disconnect-ExchangeOnline
    # are static, always-exported commands from the ExchangeOnlineManagement
    # module, so they can be checked before connecting. The RBAC commands
    # (Get-ServicePrincipal, New-ServicePrincipal, Get-RoleGroupMember,
    # Add-RoleGroupMember, New-ManagementRoleAssignment) are dynamic proxy
    # commands that the module only injects into the session AFTER a
    # successful connection -- checking for them here would always report
    # them missing even when ExchangeOnlineManagement is correctly
    # installed, so that check happens after Connect-ExchangeOnline below.
    $requiredPreConnectCommands = @(
        'Connect-ExchangeOnline'
        'Connect-IPPSSession'
        'Disconnect-ExchangeOnline'
    )
    $missingPreConnectCommands = @($requiredPreConnectCommands | Where-Object { -not (Get-Command -Name $_ -ErrorAction SilentlyContinue) })
    if ($missingPreConnectCommands.Count -gt 0) {
        throw ('The ExchangeOnlineManagement module is required but these commands were not found: {0}. Install it with: Install-Module ExchangeOnlineManagement -Scope CurrentUser' -f ($missingPreConnectCommands -join ', '))
    }

    # --- Resolve required entries from the same catalogue used by ----------
    # --- Get-ZTAssessExchangeOnlineRoleGuidance -----------------------------

    $guidance = @(Get-ZTAssessExchangeOnlineRoleGuidance -Modules $Modules)
    if ($guidance.Count -eq 0) {
        throw 'None of the selected modules require Exchange Online / Security & Compliance role groups.'
    }

    $requiredEntries = @($guidance.ExchangeOnlineRoles | Sort-Object -Unique)

    Write-Host ('Required Exchange Online / Security & Compliance entries ({0}):' -f $requiredEntries.Count) -ForegroundColor Cyan
    $requiredEntries | ForEach-Object { Write-Host (' {0}' -f $_) }
    Write-Host ''

    # --- Connect to Exchange Online as the calling administrator -----------

    # Connecting as the app being granted roles would be circular: it may
    # not yet be authorized to connect at all, which is the gap this
    # function closes. Obtaining a session is a prerequisite for every
    # subsequent write, so the sign-in itself is gated by ShouldProcess.
    # Under -WhatIf, report what would happen and stop before connecting.
    if (-not $PSCmdlet.ShouldProcess($Organization, 'Connect to Exchange Online as the calling administrator')) {
        return
    }

    $connectParams = @{
        Organization = $Organization
        ShowBanner   = $false
        ErrorAction  = 'Stop'
    }
    if ($UserPrincipalName) {
        $connectParams.UserPrincipalName = $UserPrincipalName
    }

    try {
        Connect-ExchangeOnline @connectParams
    } catch {
        throw ('Failed to connect to Exchange Online: {0}' -f $_.Exception.Message)
    }

    # Lazily established only if an entry cannot be resolved against the
    # Exchange Online connection above (some Purview retention/DLP roles
    # exist only in the Security & Compliance / IPPS RBAC namespace).
    $ippsConnected = $false

    try {
        # --- Verify the RBAC proxy commands are now available -------------

        $requiredPostConnectCommands = @(
            'Get-ServicePrincipal'
            'New-ServicePrincipal'
            'Get-RoleGroup'
            'New-RoleGroup'
            'Get-RoleGroupMember'
            'Add-RoleGroupMember'
            'New-ManagementRoleAssignment'
        )
        $missingPostConnectCommands = @($requiredPostConnectCommands | Where-Object { -not (Get-Command -Name $_ -ErrorAction SilentlyContinue) })
        if ($missingPostConnectCommands.Count -gt 0) {
            throw ('Connected to Exchange Online, but these commands were not available in the session: {0}. This usually means the signed-in account lacks sufficient Exchange Online / Security & Compliance administrative rights, or the ExchangeOnlineManagement module version is outdated.' -f ($missingPostConnectCommands -join ', '))
        }

        # --- Ensure an Exchange Online service principal exists for AppId ---

        $servicePrincipal = Get-ServicePrincipal -Identity $AppId -ErrorAction SilentlyContinue
        $servicePrincipalCreated = $false

        if (-not $servicePrincipal) {
            if (-not $ServicePrincipalObjectId) {
                throw ("No Exchange Online service principal exists yet for AppId '{0}', and -ServicePrincipalObjectId was not supplied. Provide the app's Entra ID service principal object ID (from New-ZTAssessAppRegistration's output, the Entra admin center, or Get-MgServicePrincipal) so New-ServicePrincipal can create it." -f $AppId)
            }

            if ($PSCmdlet.ShouldProcess($AppId, 'Create Exchange Online service principal')) {
                try {
                    $servicePrincipal = New-ServicePrincipal -AppId $AppId -ObjectId $ServicePrincipalObjectId -DisplayName $DisplayName -ErrorAction Stop
                    $servicePrincipalCreated = $true
                    Write-Host ("Created Exchange Online service principal for AppId '{0}'." -f $AppId) -ForegroundColor Green
                } catch {
                    throw ('Failed to create the Exchange Online service principal: {0}' -f $_.Exception.Message)
                }
            }
        }

        # Role-group membership is keyed on the Exchange Online-side service
        # principal identity, not the Entra AppId — resolve whichever
        # identity-bearing property the SDK populated.
        $spIdentity = @($servicePrincipal.Identity, $servicePrincipal.Guid, $servicePrincipal.ObjectId, $servicePrincipal.DisplayName) |
            Where-Object { $_ } | Select-Object -First 1
        if (-not $spIdentity) {
            $spIdentity = $DisplayName
        }

        # --- Grant each required entry, trying role-group then ------------
        # --- management-role mechanisms, then IPPS as a last resort -------

        $granted = [System.Collections.Generic.List[string]]::new()
        $alreadyMember = [System.Collections.Generic.List[string]]::new()
        $failedGrants = [System.Collections.Generic.List[pscustomobject]]::new()

        foreach ($entryName in $requiredEntries) {
            $outcome = $null
            $lastError = $null

            # 1. Try as a role group (Add-RoleGroupMember).
            try {
                $existingMembers = @(Get-RoleGroupMember -Identity $entryName -ErrorAction Stop)
                $isAlreadyMember = $existingMembers | Where-Object {
                    $_.Identity -eq $spIdentity -or $_.Guid -eq $spIdentity -or $_.DisplayName -eq $DisplayName -or $_.Name -eq $DisplayName
                }

                if ($isAlreadyMember) {
                    $outcome = 'AlreadyGranted'
                } elseif ($PSCmdlet.ShouldProcess($entryName, ('Add {0} as a role group member' -f $DisplayName))) {
                    Add-RoleGroupMember -Identity $entryName -Member $spIdentity -Confirm:$false -ErrorAction Stop
                    $outcome = 'Granted'
                } else {
                    $outcome = 'SkippedWhatIf'
                }
            } catch {
                # Not resolvable as a role group in this connection; fall
                # through and try the management-role mechanism instead.
                $lastError = $_.Exception.Message
            }

            # 2. Fall back to a direct management-role assignment.
            if (-not $outcome) {
                try {
                    if ($PSCmdlet.ShouldProcess($entryName, ('Assign management role to AppId {0}' -f $AppId))) {
                        New-ManagementRoleAssignment -Role $entryName -App $AppId -ErrorAction Stop
                        $outcome = 'Granted'
                    } else {
                        $outcome = 'SkippedWhatIf'
                    }
                } catch {
                    if ($_.Exception.Message -match 'already') {
                        $outcome = 'AlreadyGranted'
                    } else {
                        $lastError = $_.Exception.Message
                    }
                }
            }

            # 3. Last resort: some Purview retention/DLP entries only exist
            # in the Security & Compliance (IPPS) RBAC namespace.
            if (-not $outcome) {
                if (-not $ippsConnected) {
                    if ($PSCmdlet.ShouldProcess($Organization, 'Connect to Security & Compliance (IPPS) as the calling administrator')) {
                        try {
                            Connect-IPPSSession @connectParams
                            $ippsConnected = $true
                        } catch {
                            $lastError = ('{0} (also failed to connect to IPPS: {1})' -f $lastError, $_.Exception.Message)
                        }
                    }
                }

                if ($ippsConnected) {
                    try {
                        New-ManagementRoleAssignment -Role $entryName -App $AppId -ErrorAction Stop
                        $outcome = 'Granted'
                    } catch {
                        if ($_.Exception.Message -match 'already') {
                            $outcome = 'AlreadyGranted'
                        } else {
                            $lastError = $_.Exception.Message
                        }
                    }
                }
            }

            # 4. Final fallback: a direct -App role assignment is not
            # supported for every management role (confirmed on a live
            # tenant for the two Purview retention/DLP roles, which
            # fail identically even against the IPPS connection). The
            # Microsoft-documented workaround is a role group scoped to
            # exactly that one management role, with the service
            # principal as a member -- create it once, then add to it
            # on re-runs.
            if (-not $outcome) {
                $customRoleGroupName = ('EntraZTAssess - {0}' -f $entryName)
                try {
                    $existingCustomGroup = Get-RoleGroup -Identity $customRoleGroupName -ErrorAction SilentlyContinue

                    if ($existingCustomGroup) {
                        $existingCustomMembers = @(Get-RoleGroupMember -Identity $customRoleGroupName -ErrorAction Stop)
                        $isAlreadyCustomMember = $existingCustomMembers | Where-Object {
                            $_.Identity -eq $spIdentity -or $_.Guid -eq $spIdentity -or $_.DisplayName -eq $DisplayName -or $_.Name -eq $DisplayName
                        }

                        if ($isAlreadyCustomMember) {
                            $outcome = 'AlreadyGranted'
                        } elseif ($PSCmdlet.ShouldProcess($customRoleGroupName, ('Add {0} as a member' -f $DisplayName))) {
                            Add-RoleGroupMember -Identity $customRoleGroupName -Member $spIdentity -Confirm:$false -ErrorAction Stop
                            $outcome = 'Granted'
                        } else {
                            $outcome = 'SkippedWhatIf'
                        }
                    } elseif ($PSCmdlet.ShouldProcess($customRoleGroupName, ('Create a role group scoped to {0}' -f $entryName))) {
                        # New-RoleGroup's own -Members parameter has been
                        # observed on a live tenant to reject a service
                        # principal identity with "Couldn't find object"
                        # even though the identical identity is accepted by
                        # Add-RoleGroupMember moments later for a different
                        # role group -- so the group is created without
                        # -Members and the member is added as a separate
                        # step using the mechanism already proven to work.
                        # -DisplayName is passed explicitly: New-RoleGroup
                        # has been observed to leave DisplayName empty when
                        # it is not supplied, and the subsequent
                        # Add-RoleGroupMember write then fails validation
                        # with "DisplayName: The property DisplayName can't
                        # be empty."
                        New-RoleGroup -Name $customRoleGroupName -DisplayName $customRoleGroupName -Roles $entryName -ErrorAction Stop
                        Add-RoleGroupMember -Identity $customRoleGroupName -Member $spIdentity -Confirm:$false -ErrorAction Stop
                        $outcome = 'Granted'
                    } else {
                        $outcome = 'SkippedWhatIf'
                    }
                } catch {
                    $lastError = $_.Exception.Message
                }
            }

            switch ($outcome) {
                'AlreadyGranted' { $alreadyMember.Add($entryName) }
                'Granted' {
                    $granted.Add($entryName)
                    Write-Host (' Granted: {0}' -f $entryName) -ForegroundColor Green
                }
                'SkippedWhatIf' { }
                default {
                    Write-Warning ("Failed to grant '{0}' to '{1}': {2}" -f $entryName, $DisplayName, $lastError)
                    $failedGrants.Add([pscustomobject]@{ RoleGroup = $entryName; Error = $lastError })
                }
            }
        }

        $summary = [pscustomobject]@{
            PSTypeName              = 'ZTAssess.ExchangeOnlineRoleGrant'
            AppId                   = $AppId
            Organization            = $Organization
            ServicePrincipalCreated = $servicePrincipalCreated
            RoleGroupsGranted       = @($granted)
            RoleGroupsAlreadyMember = @($alreadyMember)
            FailedGrants            = @($failedGrants)
        }

        Write-Host ''
        Write-Host 'Role grant summary:' -ForegroundColor Cyan
        Write-Host (' AppId : {0}' -f $summary.AppId)
        Write-Host (' Organization : {0}' -f $summary.Organization)
        Write-Host (' SP created : {0}' -f $summary.ServicePrincipalCreated)
        Write-Host (' Granted : {0}' -f (($summary.RoleGroupsGranted) -join ', '))
        Write-Host (' Already member of : {0}' -f (($summary.RoleGroupsAlreadyMember) -join ', '))
        if ($summary.FailedGrants.Count -gt 0) {
            Write-Host (' Failed : {0}' -f (($summary.FailedGrants | ForEach-Object { $_.RoleGroup }) -join ', ')) -ForegroundColor Yellow
        }
        Write-Host ''

        return $summary
    } finally {
        Disconnect-ExchangeOnline -Confirm:$false -ErrorAction SilentlyContinue
    }
}