Checks/Licensing/ARG.LIC.003.ps1

Register-ArgusCheck @{
    Id          = 'ARG.LIC.003'
    Title       = 'Paid licences assigned to guest users'
    Area        = 'Licensing'
    Category    = 'Licence hygiene'
    Severity    = 'Medium'
    Description = 'Guest users (`userType` = Guest) with at least one paid licence. Free, viral and trial SKUs are ignored (`IgnoredSkus`, trial subscriptions from `/directory/subscriptions`), as in ARG.LIC.006. Guests normally work with the licences of their home organisation or with the B2B allowance; a paid seat on a guest is usually a mistake or a leftover.'
    Rationale   = 'Licences assigned to guests cost a full seat, are rarely reviewed and often stay after the collaboration ends. When a guest really needs a licence, it should be a documented decision with a sponsor, not the side effect of a group-based assignment.'
    Remediation = @{
        Steps      = "1. Check why each guest has the licence (direct assignment or a licensing group that includes guests).`n2. Remove the licence, or exclude guests from the licensing group (for example with a dynamic group rule on userType).`n3. When a guest needs a licence on purpose, record the decision and the sponsor."
        PowerShell = "# Sample only - review before running.`nConnect-MgGraph -Scopes 'User.ReadWrite.All'`nSet-MgUserLicense -UserId '<guest id>' -AddLicenses @() -RemoveLicenses @('<skuId>')"
        PortalUrl  = 'https://admin.microsoft.com/#/licenses'
    }
    References  = @(
        @{ Title = 'Microsoft Entra External ID B2B licensing'; Url = 'https://learn.microsoft.com/en-us/entra/external-id/external-identities-pricing' }
        @{ Title = 'Assign or remove licenses for user accounts'; Url = 'https://learn.microsoft.com/en-us/entra/fundamentals/license-users-groups' }
    )
    Requires    = @{
        Services    = @('Graph')
        GraphScopes = @('User.Read.All', 'LicenseAssignment.Read.All')
        DataSets    = @('Users', 'SubscribedSkus', 'Subscriptions')
    }
    Parameters  = @{
        FreeSeatThreshold = 10000
        IgnoredSkus       = @($script:ArgusFreeSkus)
    }
    Tags        = @('Licensing', 'Guests', 'Cost')
    Test        = {
        param($Context)
        $skus = @($Context.Data['SubscribedSkus'])
        $categories = Get-ArgusSkuCategory -SubscribedSku $skus -Subscription @($Context.Data['Subscriptions']) `
            -IgnoredSku ([string[]]@($Context.Parameters.IgnoredSkus)) -FreeSeatThreshold ([int]$Context.Parameters.FreeSeatThreshold)
        $guests = @($Context.Data['Users'] | Where-Object { (Get-ArgusProperty $_ 'userType' '') -eq 'Guest' })
        $findings = @($guests | ForEach-Object {
                $assigned = @(Get-ArgusProperty $_ 'assignedLicenses' @() | ForEach-Object { [string](Get-ArgusProperty $_ 'skuId' '') })
                $paid = @($assigned | Where-Object { $_ -and $categories[$_.ToLowerInvariant()] -eq 'Paid' })
                if ($paid.Count -eq 0) { return }
                [pscustomobject]@{
                    DisplayName       = Get-ArgusProperty $_ 'displayName' ''
                    UserPrincipalName = Get-ArgusProperty $_ 'userPrincipalName' ''
                    Licences          = @(Get-ArgusSkuName -SkuId $paid -SubscribedSku $skus)
                    AccountEnabled    = [bool](Get-ArgusProperty $_ 'accountEnabled' $false)
                    ObjectId          = Get-ArgusProperty $_ 'id' ''
                }
            } | Sort-Object UserPrincipalName)
        $status = if ($findings.Count -gt 0) { 'Failed' } else { 'Passed' }
        New-ArgusResult -Status $status -Findings $findings -EvaluatedCount $guests.Count `
            -Summary '{AffectedCount} of {EvaluatedCount} guest users have a paid licence.'
    }
}