src/collect/ConvertTo-ScoutEntraDerivedEvidence.ps1

#Requires -Version 7.0
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

<#
.SYNOPSIS
    Derive review-ready Entra posture rows from retained Microsoft Graph evidence.
 
.DESCRIPTION
    Produces correlation rows for report-only Conditional Access impact, legacy authentication,
    emergency-access candidates, and privileged-role assignment schedules. Source responses remain
    untouched in raw-inventory.json; every derived row names the retained input datasets it used.
#>

function ConvertTo-ScoutEntraDerivedEvidence {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][AllowEmptyCollection()][object[]] $EntraResources,
        [Parameter(Mandatory)][string] $TenantID
    )

    function Get-Value {
        param([AllowNull()]$Object, [string]$Name)
        if ($null -eq $Object) { return $null }
        $property = $Object.PSObject.Properties[$Name]
        if ($property) { return $property.Value }
        return $null
    }

    function New-DerivedRow {
        param([string]$Type, [string]$Id, [string]$Name, $Properties, [string[]]$Inputs)
        [pscustomobject][ordered]@{
            id         = $Id
            name       = $Name
            TYPE       = $Type
            tenantId   = $TenantID
            properties = [pscustomobject]$Properties
            AZSC       = [pscustomobject][ordered]@{
                Source      = 'Derived from retained Microsoft Graph evidence'
                Dataset     = $Type
                Operation   = 'Correlation'
                Inputs      = @($Inputs)
                CollectedAt = (Get-Date).ToString('o')
            }
        }
    }

    function Mask-Upn {
        param([string]$Upn)
        if ([string]::IsNullOrWhiteSpace($Upn) -or $Upn -notmatch '@') { return '[masked]' }
        $parts = $Upn -split '@', 2
        $local = $parts[0]
        $masked = if ($local.Length -le 2) { ('*' * $local.Length) }
                  else { $local.Substring(0, 1) + ('*' * ($local.Length - 2)) + $local.Substring($local.Length - 1, 1) }
        return "$masked@$($parts[1])"
    }

    $rows = [System.Collections.Generic.List[object]]::new()
    $byType = @{}
    foreach ($resource in @($EntraResources)) {
        if ($null -eq $resource -or -not $resource.PSObject.Properties['TYPE']) { continue }
        $key = ([string]$resource.TYPE).ToLowerInvariant()
        if (-not $byType.ContainsKey($key)) { $byType[$key] = [System.Collections.Generic.List[object]]::new() }
        $byType[$key].Add($resource)
    }
    function Get-TypeRows {
        param([string]$Type)
        if ($byType.ContainsKey($Type)) { return @($byType[$Type]) }
        return @()
    }

    $policies = @(Get-TypeRows 'entra/conditionalaccesspolicies')
    $signIns = @(Get-TypeRows 'entra/signins')
    foreach ($policy in @($policies | Where-Object { (Get-Value $_.properties 'state') -eq 'enabledForReportingButNotEnforced' })) {
        $policyId = [string](Get-Value $policy.properties 'id')
        $impact = @{ reportOnlyFailure = 0; reportOnlySuccess = 0; reportOnlyInterrupted = 0; other = 0 }
        foreach ($signIn in $signIns) {
            foreach ($applied in @((Get-Value $signIn.properties 'appliedConditionalAccessPolicies'))) {
                if ([string](Get-Value $applied 'id') -ne $policyId) { continue }
                $result = [string](Get-Value $applied 'result')
                if ($impact.ContainsKey($result)) { $impact[$result]++ } else { $impact.other++ }
            }
        }
        $rows.Add((New-DerivedRow -Type 'entra/conditionalaccessreportonlyimpact' -Id $policyId -Name ([string]$policy.name) -Inputs @('entra/conditionalaccesspolicies', 'entra/signins') -Properties ([ordered]@{
                    policyId = $policyId
                    displayName = $policy.name
                    reportOnlyFailure = $impact.reportOnlyFailure
                    reportOnlySuccess = $impact.reportOnlySuccess
                    reportOnlyInterrupted = $impact.reportOnlyInterrupted
                    otherResult = $impact.other
                    observedSignIns = $impact.reportOnlyFailure + $impact.reportOnlySuccess + $impact.reportOnlyInterrupted + $impact.other
                    windowDays = 30
                })))
    }

    $legacyClients = @('Exchange ActiveSync', 'IMAP', 'POP', 'SMTP', 'MAPI', 'Other clients')
    $legacyGroups = @($signIns | Where-Object {
            [string](Get-Value $_.properties 'clientAppUsed') -in $legacyClients -and
            [int](Get-Value (Get-Value $_.properties 'status') 'errorCode') -eq 0
        } | Group-Object { [string](Get-Value $_.properties 'userPrincipalName') } | Sort-Object Count -Descending)
    foreach ($group in $legacyGroups) {
        $upn = [string]$group.Name
        $lastDate = @($group.Group | ForEach-Object { Get-Value $_.properties 'createdDateTime' } | Sort-Object -Descending | Select-Object -First 1)
        $rows.Add((New-DerivedRow -Type 'entra/legacyauthsummary' -Id $upn -Name (Mask-Upn $upn) -Inputs @('entra/signins') -Properties ([ordered]@{
                    maskedUserPrincipalName = Mask-Upn $upn
                    successfulSignIns = $group.Count
                    clientApps = @($group.Group | ForEach-Object { Get-Value $_.properties 'clientAppUsed' } | Sort-Object -Unique)
                    lastSignInDateTime = if ($lastDate.Count -gt 0) { $lastDate[0] } else { $null }
                    windowDays = 30
                })))
    }

    $users = @(Get-TypeRows 'entra/users')
    $registrations = @(Get-TypeRows 'entra/authenticationmethodregistrations')
    $activeSchedules = @(Get-TypeRows 'entra/roleassignmentschedules')
    $eligibleSchedules = @(Get-TypeRows 'entra/roleeligibilityschedules')
    $legacyAssignments = @(Get-TypeRows 'entra/pimassignments')
    $principalRows = @($users + @(Get-TypeRows 'entra/groups') + @(Get-TypeRows 'entra/serviceprincipals'))
    $principalById = @{}
    foreach ($principal in $principalRows) {
        if ($principal.id) { $principalById[[string]$principal.id] = $principal }
    }
    $registrationById = @{}
    foreach ($registration in $registrations) {
        if ($registration.id) { $registrationById[[string]$registration.id] = $registration }
    }

    $enabledUsers = @($users | Where-Object { (Get-Value $_.properties 'accountEnabled') -ne $false })
    $enabledRegistered = @($enabledUsers | Where-Object {
            $registrationById.ContainsKey([string]$_.id) -and
            (Get-Value $registrationById[[string]$_.id].properties 'isMfaRegistered') -eq $true
        })
    $adminRegistrations = @($registrations | Where-Object { (Get-Value $_.properties 'isAdmin') -eq $true })
    $adminRegistered = @($adminRegistrations | Where-Object { (Get-Value $_.properties 'isMfaRegistered') -eq $true })
    $phishingResistant = @($registrations | Where-Object {
            @((Get-Value $_.properties 'methodsRegistered') | Where-Object {
                    [string]$_ -match '(?i)fido|passkey|windowsHello|certificate'
                }).Count -gt 0
        })
    if ($users.Count -gt 0 -or $registrations.Count -gt 0) {
        $rows.Add((New-DerivedRow -Type 'entra/mfaregistrationsummary' -Id $TenantID -Name 'MFA registration summary' -Inputs @('entra/users', 'entra/authenticationmethodregistrations') -Properties ([ordered]@{
                    enabledUserCount = $enabledUsers.Count
                    enabledMfaRegisteredCount = $enabledRegistered.Count
                    enabledMfaRegisteredPercent = if ($enabledUsers.Count -gt 0) { [math]::Round(100 * $enabledRegistered.Count / $enabledUsers.Count, 2) } else { $null }
                    adminCount = $adminRegistrations.Count
                    adminMfaRegisteredCount = $adminRegistered.Count
                    adminMfaRegisteredPercent = if ($adminRegistrations.Count -gt 0) { [math]::Round(100 * $adminRegistered.Count / $adminRegistrations.Count, 2) } else { $null }
                    phishingResistantRegisteredCount = $phishingResistant.Count
                })))
    }

    $privilegedRows = [System.Collections.Generic.List[object]]::new()
    foreach ($set in @(
            [pscustomobject]@{ Rows = $activeSchedules; AssignmentType = 'Active' },
            [pscustomobject]@{ Rows = $eligibleSchedules; AssignmentType = 'Eligible' }
        )) {
        foreach ($assignment in @($set.Rows)) {
            $p = $assignment.properties
            $principalId = [string](Get-Value $p 'principalId')
            $principal = if ($principalById.ContainsKey($principalId)) { $principalById[$principalId] } else { $null }
            $roleDefinition = Get-Value $p 'roleDefinition'
            $end = Get-Value $p 'endDateTime'
            $effectiveType = if ($set.AssignmentType -eq 'Eligible') { 'Eligible' }
                             elseif ($null -eq $end -or [string]::IsNullOrWhiteSpace([string]$end)) { 'Permanent' }
                             else { 'Active time-bound' }
            $lastSignIn = @($signIns | Where-Object { [string](Get-Value $_.properties 'userId') -eq $principalId } | Sort-Object {
                    $date = Get-Value $_.properties 'createdDateTime'
                    if ($date) { [datetime]$date } else { [datetime]::MinValue }
                } -Descending | Select-Object -First 1)
            $lastDate = if ($lastSignIn.Count -gt 0) { Get-Value $lastSignIn[0].properties 'createdDateTime' } else { $null }
            $principalType = if ($principal) { [string]$principal.TYPE -replace '^entra/', '' } else { [string](Get-Value $p 'principalType') }
            $privilegedRow = New-DerivedRow -Type 'entra/privilegedassignments' -Id ([string](Get-Value $p 'id')) -Name ([string](Get-Value $roleDefinition 'displayName')) -Inputs @('entra/roleassignmentschedules', 'entra/roleeligibilityschedules', 'entra/users', 'entra/groups', 'entra/serviceprincipals', 'entra/signins') -Properties ([ordered]@{
                        roleName = Get-Value $roleDefinition 'displayName'
                        roleDefinitionId = Get-Value $p 'roleDefinitionId'
                        principalId = $principalId
                        principalType = $principalType
                        principalName = if ($principal) { $principal.name } else { $null }
                        assignmentType = $effectiveType
                        membership = if ($principalType -match '(?i)group') { 'Group' } else { [string](Get-Value $p 'memberType') }
                        startDateTime = Get-Value $p 'startDateTime'
                        endDateTime = $end
                        accountEnabled = if ($principal) { Get-Value $principal.properties 'accountEnabled' } else { $null }
                        lastSignInDateTime = $lastDate
                        stalePermanent90Days = $effectiveType -eq 'Permanent' -and ($null -eq $lastDate -or [datetime]$lastDate -lt (Get-Date).ToUniversalTime().AddDays(-90))
                    })
            $rows.Add($privilegedRow)
            $privilegedRows.Add($privilegedRow)
        }
    }
    foreach ($roleGroup in @($privilegedRows | Group-Object { [string]$_.properties.roleName })) {
        $rows.Add((New-DerivedRow -Type 'entra/privilegedassignmentsummary' -Id ([string]$roleGroup.Name) -Name ([string]$roleGroup.Name) -Inputs @('entra/privilegedassignments') -Properties ([ordered]@{
                    roleName = $roleGroup.Name
                    permanentCount = @($roleGroup.Group | Where-Object { $_.properties.assignmentType -eq 'Permanent' }).Count
                    eligibleCount = @($roleGroup.Group | Where-Object { $_.properties.assignmentType -eq 'Eligible' }).Count
                    activeTimeBoundCount = @($roleGroup.Group | Where-Object { $_.properties.assignmentType -eq 'Active time-bound' }).Count
                    stalePermanent90DaysCount = @($roleGroup.Group | Where-Object { $_.properties.stalePermanent90Days -eq $true }).Count
                })))
    }

    $enabledPolicies = @($policies | Where-Object { (Get-Value $_.properties 'state') -eq 'enabled' })
    if ($enabledPolicies.Count -gt 0) {
        $excludedSets = @()
        foreach ($policy in $enabledPolicies) {
            $conditions = Get-Value $policy.properties 'conditions'
            $userCondition = Get-Value $conditions 'users'
            $excludedSets += ,@((Get-Value $userCondition 'excludeUsers') | Where-Object { $_ -and $_ -notin @('GuestsOrExternalUsers', 'All') })
        }
        $directCandidates = if ($excludedSets.Count -gt 0) {
            @($excludedSets[0] | Where-Object {
                    $candidate = [string]$_
                    @($excludedSets | Where-Object { $_ -notcontains $candidate }).Count -eq 0
                } | Sort-Object -Unique)
        }
        else { @() }

        foreach ($principalId in $directCandidates) {
            if (-not $principalById.ContainsKey([string]$principalId)) { continue }
            $user = $principalById[[string]$principalId]
            if ($user.TYPE -ne 'entra/users') { continue }
            $registration = if ($registrationById.ContainsKey([string]$principalId)) { $registrationById[[string]$principalId] } else { $null }
            $userSignIns = @($signIns | Where-Object { [string](Get-Value $_.properties 'userId') -eq [string]$principalId } | Sort-Object {
                    $date = Get-Value $_.properties 'createdDateTime'
                    if ($date) { [datetime]$date } else { [datetime]::MinValue }
                } -Descending)
            $assignedRoles = @($legacyAssignments | Where-Object { [string](Get-Value $_.properties 'principalId') -eq [string]$principalId } | ForEach-Object { Get-Value (Get-Value $_.properties 'roleDefinition') 'displayName' } | Where-Object { $_ } | Sort-Object -Unique)
            $rows.Add((New-DerivedRow -Type 'entra/breakglasscandidates' -Id ([string]$principalId) -Name (Mask-Upn ([string]$user.name)) -Inputs @('entra/conditionalaccesspolicies', 'entra/users', 'entra/authenticationmethodregistrations', 'entra/signins', 'entra/pimassignments') -Properties ([ordered]@{
                        maskedUserPrincipalName = Mask-Upn ([string]$user.name)
                        cloudOnly = -not [bool](Get-Value $user.properties 'onPremisesSyncEnabled')
                        accountEnabled = Get-Value $user.properties 'accountEnabled'
                        lastSignInDateTime = if ($userSignIns.Count -gt 0) { Get-Value $userSignIns[0].properties 'createdDateTime' } else { $null }
                        isMfaRegistered = if ($registration) { Get-Value $registration.properties 'isMfaRegistered' } else { $null }
                        methodsRegistered = if ($registration) { @(Get-Value $registration.properties 'methodsRegistered') } else { @() }
                        assignedRoles = $assignedRoles
                        excludedFromEnabledPolicyCount = $enabledPolicies.Count
                        evidenceScope = 'Direct user exclusions only; group and directory-role exclusions are not inferred.'
                    })))
        }
    }

    return $rows.ToArray()
}