Checks/Licensing/ARG.LIC.008.ps1

Register-ArgusCheck @{
    Id          = 'ARG.LIC.008'
    Title       = 'Paid licences assigned directly to users'
    Area        = 'Licensing'
    Category    = 'Licence management'
    Severity    = 'Low'
    Description = 'Users with at least one paid licence assigned directly (`licenseAssignmentStates` entry without `assignedByGroup`) instead of through group-based licensing. Free, viral and developer SKUs (the IgnoredSkus list), SKUs with **{FreeSeatThreshold}** or more seats and trial SKUs (`isTrial` in `/directory/subscriptions`) are not counted: they are usually self-service sign-ups. AlsoFromGroup lists the direct licences the user also receives from a group, where the direct assignment is redundant.'
    Rationale   = 'Direct assignments are not driven by group membership: they are not removed when a person changes role or leaves, and they are hard to audit at scale. Group-based licensing keeps paid licences consistent with joiner/mover/leaver processes.'
    Remediation = @{
        Steps      = "1. Create (or reuse) a security group per licence profile and assign the licences to the group.`n2. Add the users to the group and wait for the group assignment to be active.`n3. Remove the direct assignments, starting with the redundant ones listed in AlsoFromGroup."
        PowerShell = "# Sample only - review before running. Removes one directly assigned licence after the group assignment is active.`nConnect-MgGraph -Scopes 'User.ReadWrite.All'`nSet-MgUserLicense -UserId '<ObjectId>' -AddLicenses @() -RemoveLicenses @('<SkuId>')"
        PortalUrl  = 'https://entra.microsoft.com/#view/Microsoft_AAD_IAM/LicensesMenuBlade/~/Products'
    }
    References  = @(
        @{ Title = 'What is group-based licensing in Microsoft Entra ID?'; Url = 'https://learn.microsoft.com/en-us/entra/fundamentals/concept-group-based-licensing' }
        @{ Title = 'Migrate users from individual licenses to group-based licenses'; Url = 'https://learn.microsoft.com/en-us/entra/identity/users/licensing-groups-migrate-users' }
    )
    Requires    = @{
        Services    = @('Graph')
        GraphScopes = @('User.Read.All')
        DataSets    = @('Users', 'SubscribedSkus', 'Subscriptions')
    }
    Parameters  = @{
        FreeSeatThreshold = 10000
        IgnoredSkus       = @($script:ArgusFreeSkus)
    }
    Tags        = @('Licensing', 'Groups')
    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)
        # SKUs no longer in subscribedSkus are unknown: treat them as paid.
        $isPaid = { param([string] $SkuId) $category = $categories[$SkuId.ToLowerInvariant()]; -not $category -or $category -eq 'Paid' }

        $licensed = @($Context.Data['Users'] | Where-Object { @(Get-ArgusProperty $_ 'licenseAssignmentStates' @()).Count -gt 0 })
        $redundant = 0
        $freeOnly = 0
        $findings = foreach ($user in $licensed) {
            $states = @(Get-ArgusProperty $user 'licenseAssignmentStates' @())
            $direct = @($states | Where-Object { -not (Get-ArgusProperty $_ 'assignedByGroup' '') } | ForEach-Object { [string](Get-ArgusProperty $_ 'skuId' '') } | Select-Object -Unique)
            if ($direct.Count -eq 0) { continue }
            $paid = @($direct | Where-Object { & $isPaid $_ })
            if ($paid.Count -eq 0) { $freeOnly++; continue }
            $fromGroup = @($states | Where-Object { Get-ArgusProperty $_ 'assignedByGroup' '' } | ForEach-Object { [string](Get-ArgusProperty $_ 'skuId' '') })
            $both = @($paid | Where-Object { $_ -in $fromGroup })
            if ($both.Count -gt 0) { $redundant++ }
            [pscustomobject]@{
                DisplayName       = Get-ArgusProperty $user 'displayName' ''
                UserPrincipalName = Get-ArgusProperty $user 'userPrincipalName' ''
                DirectLicences    = @(Get-ArgusSkuName -SkuId $paid -SubscribedSku $skus)
                AlsoFromGroup     = @(Get-ArgusSkuName -SkuId $both -SubscribedSku $skus)
                AccountEnabled    = [bool](Get-ArgusProperty $user 'accountEnabled' $true)
                UserType          = Get-ArgusProperty $user 'userType' ''
                ObjectId          = Get-ArgusProperty $user 'id' ''
            }
        }
        $findings = @($findings | Sort-Object @{ Expression = { @($_.AlsoFromGroup).Count }; Descending = $true }, UserPrincipalName)

        $note = if ($freeOnly) { " $freeOnly more have only free or trial licences assigned directly (not counted)." } else { '' }
        if ($findings.Count -eq 0) {
            return New-ArgusResult -Status Passed -EvaluatedCount $licensed.Count -Summary ('No paid licence is assigned directly ({EvaluatedCount} licensed users evaluated).' + $note)
        }
        New-ArgusResult -Status Failed -Findings $findings -EvaluatedCount $licensed.Count `
            -Summary ("{AffectedCount} of {EvaluatedCount} licensed users have paid licences assigned directly; $redundant of them also receive the same licence from a group." + $note)
    }
}