tests/Test-Assessment.21869.ps1

<#
.SYNOPSIS
    Checks that enterprise applications require explicit assignment or have scoped provisioning controls.
#>


function Test-Assessment-21869 {
    [ZtTest(
        Category = 'Application management',
        ImplementationCost = 'Medium',
        Pillar = 'Identity',
        RiskLevel = 'Medium',
        SfiPillar = 'Protect engineering systems',
        TenantType = ('Workforce','External'),
        TestId = 21869,
        Title = 'Enterprise applications must require explicit assignment or scoped provisioning',
        UserImpact = 'Medium'
    )]
    [CmdletBinding()]
    param(
        $Database
    )

    Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose

    #region Data Collection
    $activity = 'Checking enterprise applications assignment and provisioning requirements'
    Write-ZtProgress -Activity $activity -Status 'Getting service principals without assignment requirements'

    # Query 1: Get service principals that don't require assignment
    $sql = @"
SELECT
    id,
    appId,
    displayName,
    preferredSingleSignOnMode,
    accountEnabled
FROM ServicePrincipal
WHERE appRoleAssignmentRequired = 'false'
    AND preferredSingleSignOnMode IS NOT NULL
    AND preferredSingleSignOnMode IN ('password', 'saml', 'oidc')
    AND accountEnabled = true
ORDER BY LOWER(displayName) ASC
"@


    $servicePrincipals = Invoke-DatabaseQuery -Database $Database -Sql $sql -AsCustomObject

    Write-PSFMessage "Found $($servicePrincipals.Count) service principals without assignment requirements" -Level Verbose

    if ($servicePrincipals.Count -eq 0) {
        # No applications without assignment requirements - pass

        $params = @{
            TestId             = '21869'
            Status             = $true
            Result             = 'All enterprise applications have explicit assignment requirements.'
        }
        Add-ZtTestResultDetail @params
        return
    }

    # Track applications with issues
    $appsWithoutProvisioningJobs = @()
    $appsWithUnscopedProvisioning = @()

    # Query 2 & 3: Check provisioning jobs and scoping for each service principal
    $totalApps = $servicePrincipals.Count
    $currentApp = 0

    foreach ($sp in $servicePrincipals) {
        $currentApp++
        Write-ZtProgress -Activity $activity -Status "Checking provisioning for $($sp.displayName) ($currentApp of $totalApps)"

        Write-PSFMessage "Checking provisioning jobs for: $($sp.displayName) (ID: $($sp.id))" -Level Verbose

        # Query 2: Get provisioning jobs for this service principal
        $provisioningJobsUri = "servicePrincipals/$($sp.id)/synchronization/jobs"

        try {
            $provisioningJobs = Invoke-ZtGraphRequest -RelativeUri $provisioningJobsUri -ApiVersion beta

            if (-not $provisioningJobs -or $provisioningJobs.Count -eq 0) {
                # No provisioning jobs configured - this is a failure
                Write-PSFMessage "No provisioning jobs found for $($sp.displayName)" -Level Verbose

                $appsWithoutProvisioningJobs += @{
                    ServicePrincipal = $sp
                    Reason = 'No provisioning jobs configured'
                }
                continue
            }

            Write-PSFMessage "Found $($provisioningJobs.Count) provisioning job(s) for $($sp.displayName)" -Level Verbose

            # Query 3: Check scoping filters for each provisioning job
            $jobsWithoutScoping = @()

            foreach ($job in $provisioningJobs) {
                Write-PSFMessage "Checking scoping for job: $($job.id) - $($job.templateId)" -Level Verbose

                # Get the provisioning schema to check scoping filters
                $schemaUri = "servicePrincipals/$($sp.id)/synchronization/jobs/$($job.id)/schema"

                try {
                    $schema = Invoke-ZtGraphRequest -RelativeUri $schemaUri -ApiVersion beta

                    $scopingInfo = 'Scoping configured'
                    $hasScopingFilter = $true

                    # Check if ALL objectMappings have a scope configured
                    if ($schema -and $schema.synchronizationRules) {
                        Write-PSFMessage "Analyzing $($schema.synchronizationRules.Count) synchronization rule(s)" -Level Verbose

                        foreach ($rule in $schema.synchronizationRules) {
                            if ($rule.objectMappings) {
                                # Check if any mapping lacks a scope
                                $mappingWithoutScope = $rule.objectMappings | Where-Object { -not $_.scope }

                                if ($mappingWithoutScope) {
                                    $hasScopingFilter = $false
                                    $scopingInfo = 'No scoping configured'
                                    Write-PSFMessage "Found objectMapping without scope in rule: $($rule.name)" -Level Verbose
                                    break  # Exit early - we found a mapping without scope
                                }
                            }
                        }
                    } else {
                        Write-PSFMessage 'No synchronization rules found in schema' -Level Verbose
                        $hasScopingFilter = $false
                        $scopingInfo = 'No synchronization rules found'
                    }

                    # Only track jobs WITHOUT proper scoping
                    if (-not $hasScopingFilter) {
                        $jobsWithoutScoping += @{
                            JobId = $job.id
                            JobName = $job.templateId
                            Scoping = $scopingInfo
                        }
                    }

                } catch {
                    Write-PSFMessage "Error checking schema for job $($job.id): $_" -Level Verbose
                    $jobsWithoutScoping += @{
                        JobId = $job.id
                        JobName = $job.templateId
                        Scoping = 'Error retrieving scoping information'
                    }
                }
            }

            # If any jobs lack proper scoping, add to failures
            if ($jobsWithoutScoping.Count -gt 0) {
                Write-PSFMessage "Found $($jobsWithoutScoping.Count) job(s) without proper scoping for $($sp.displayName)" -Level Verbose

                $appsWithUnscopedProvisioning += @{
                    ServicePrincipal = $sp
                    Jobs = $jobsWithoutScoping
                    Reason = 'Provisioning jobs lack proper scoping filters'
                }
            } else {
                Write-PSFMessage "All provisioning jobs have proper scoping for $($sp.displayName)" -Level Verbose
            }

        } catch {
            Write-PSFMessage "Error checking provisioning jobs for $($sp.displayName): $_" -Level Verbose
            # If we can't check provisioning, treat as no provisioning
            $appsWithoutProvisioningJobs += @{
                ServicePrincipal = $sp
                Reason = 'Error checking provisioning configuration'
            }
        }
    }
    #endregion Data Collection

    #region Assessment Logic
    $totalIssues = $appsWithoutProvisioningJobs.Count + $appsWithUnscopedProvisioning.Count

    Write-PSFMessage "Assessment data: TotalApps=$totalApps, IssuesFound=$totalIssues" -Level Verbose

    if ($totalIssues -eq 0) {
        $passed = $true
        $testResultMarkdown = 'All enterprise applications require explicit assignment or have scoped provisioning controls.'
    } else {
        $passed = $false
        $testResultMarkdown = 'Found enterprise applications that lack both assignment requirements and provisioning scoping.'
    }
    #endregion Assessment Logic

    #region Report Generation
    # Build markdown output
    $mdInfo = ""

    if ($totalIssues -gt 0) {
        # Applications without provisioning jobs
        if ($appsWithoutProvisioningJobs.Count -gt 0) {
            $mdInfo += "`n## Applications without provisioning jobs ($($appsWithoutProvisioningJobs.Count))`n`n"
            $mdInfo += "| Display name | Reason |`n"
            $mdInfo += "| :----------- | :----- |`n"

            foreach ($app in $appsWithoutProvisioningJobs) {
                $sp = $app.ServicePrincipal
                $spLink = "https://entra.microsoft.com/#view/Microsoft_AAD_IAM/ManagedAppMenuBlade/~/Overview/objectId/$($sp.id)/appId/$($sp.appId)"
                $displayName = $sp.displayName
                $displayNameLink = "[$displayName]($spLink)"

                $mdInfo += "| $displayNameLink | $($app.Reason) |`n"
            }
            $mdInfo += "`n"
        }

        # Applications with unscoped provisioning
        if ($appsWithUnscopedProvisioning.Count -gt 0) {
            $mdInfo += "## Applications with unscoped provisioning ($($appsWithUnscopedProvisioning.Count))`n`n"
            $mdInfo += "These applications do not require assignment and have provisioning jobs without proper scoping filters.`n`n"

            foreach ($app in $appsWithUnscopedProvisioning) {
                $sp = $app.ServicePrincipal
                $spLink = "https://entra.microsoft.com/#view/Microsoft_AAD_IAM/ManagedAppMenuBlade/~/Overview/objectId/$($sp.id)/appId/$($sp.appId)"
                $displayName = $sp.displayName
                $displayNameLink = "[$displayName]($spLink)"

                $mdInfo += "### $displayNameLink`n`n"
                $mdInfo += "**Display name:** $displayNameLink`n`n"
                $mdInfo += "**Provisioning jobs:**`n`n"
                $mdInfo += "| Job id | Job name | Job scoping |`n"
                $mdInfo += "| :----- | :------- | :---------- |`n"

                foreach ($job in $app.Jobs) {
                    $mdInfo += "| ``$($job.JobId)`` | $($job.JobName) | $($job.Scoping) |`n"
                }
                $mdInfo += "`n"
                $mdInfo += "**Reason for fail:** Enterprise application does not require assignment and provisioning is not properly scoped`n`n"
            }
        }
    }

    # Append details to the test result
    $testResultMarkdown += $mdInfo
    #endregion Report Generation

    $params = @{
        TestId             = '21869'
        Status             = $passed
        Result             = $testResultMarkdown
    }

    Add-ZtTestResultDetail @params
}