Public/Get-MsecEntraTenantSecuritySetting.ps1

function Get-MsecEntraTenantSecuritySetting {
    <#
    .SYNOPSIS
        Tenant-wide Entra security settings in one summary row: security defaults,
        which security workloads are licensed, the directory's default user
        permissions, and how many people hold privileged roles.

    .DESCRIPTION
        The tenant-level counterpart to the per-object inventory cmdlets. Where
        Get-MsecEntraConditionalAccessPolicy lists the policies you HAVE, this
        answers what the tenant is CAPABLE of and how it is configured by default.

        It exists to close a specific blind spot. A posture collection that hits
        403/400 on a workload cannot tell the difference between

          - a permission the app is missing (fix the app), and
          - a workload the tenant never bought (nothing to fix - not applicable),

        and reporting the second as the first sends people chasing consent grants
        that cannot possibly help. The ServicePlan-derived capability flags below
        make that distinction explicit: ConditionalAccessAvailable = $false means
        an empty CA policy list is *expected*, because Conditional Access requires
        Entra ID premium and this tenant has none.

        Composed from four sources, each independently degradable:

          /policies/identitySecurityDefaultsEnforcementPolicy Policy.Read.All
          /policies/authorizationPolicy Policy.Read.All
          Get-MsecEntraLicense (/subscribedSkus) Organization.Read.All
          Get-MsecEntraRoleHolder (roleManagement) RoleManagement.Read.Directory
                                                               + User/Group/Application.Read.All
                                                               to name the principals

        By default a section that cannot be read leaves its properties $null and
        records why in the Notes dictionary, rather than throwing - so one missing
        permission still yields a useful row, and the caller can persist the
        reason next to the gap. Use -Strict to get the underlying exception
        instead.

    .PARAMETER Strict
        Rethrow instead of degrading. Any section that fails aborts the call with
        the original (permission-annotated) error. Use when you want a collection
        run to fail loudly rather than silently record a null.

    .EXAMPLE
        Get-MsecEntraTenantSecuritySetting | Format-List

    .EXAMPLE
        # The question that started this cmdlet: with no CA policies, is anything
        # actually enforcing MFA?
        $s = Get-MsecEntraTenantSecuritySetting
        if (-not $s.ConditionalAccessAvailable -and -not $s.SecurityDefaultsEnabled) {
            "No Conditional Access (unlicensed) AND security defaults off - " +
            "$($s.GlobalAdministratorCount) Global Admins reachable with password alone."
        }

    .EXAMPLE
        # Why is a posture domain empty? Ask the tenant, not the error code.
        Get-MsecEntraTenantSecuritySetting |
            Select-Object ConditionalAccessAvailable, IntuneProvisioned,
                          ExchangeOnlineProvisioned, DefenderForEndpointProvisioned

    .EXAMPLE
        # Slot it into the posture snapshot next to the other domains.
        $snapshot = [pscustomobject]@{
            CapturedAtUtc = (Get-Date).ToUniversalTime().ToString('u')
            TenantSecurity = Get-MsecEntraTenantSecuritySetting
            CaStats = Get-MsecEntraConditionalAccessStats -Days 7
        }

    .OUTPUTS
        One PSCustomObject. See .NOTES for every property.

    .NOTES
        PSTypeName 'MsecEntraTenantSecuritySetting'; DefaultDisplayPropertySet
        (SecurityDefaultsEnabled, ConditionalAccessAvailable,
        GlobalAdministratorCount, EntraIdPremium) is registered in Msec.psm1.

        Properties:
          TenantId the session's tenant

          SecurityDefaultsEnabled $true/$false, or $null if unreadable.
                                          Mutually exclusive with Conditional
                                          Access: enabling CA disables these, so
                                          $false is normal and correct in a
                                          premium tenant that uses CA instead.

          EntraIdPremium 'P2' / 'P1' / $null - highest tier found
          EntraIdPremiumP1 / ...P2 per-tier booleans
          ConditionalAccessAvailable = P1 or P2. When $false, CA cannot exist.
          IdentityProtectionAvailable = P2. Risk-based policies and the
                                          risky-sign-in signals need P2.
          PimAvailable = P2. Without it, every privileged
                                          assignment is permanent by definition.
          IntuneProvisioned INTUNE_A service plan present
          ExchangeOnlineProvisioned a real mailbox plan present. EXCHANGE_S_FOUNDATION
                                          does NOT count - it is a stub bundled with
                                          unrelated SKUs and grants no mailboxes.
          DefenderForEndpointProvisioned WINDEFATP plan present
          DefenderForOffice365Provisioned ATP_ENTERPRISE or THREAT_INTELLIGENCE
          ServicePlans every distinct successfully-provisioned
                                          service plan name in the tenant
          LicensedSkuCount SKUs with at least one enabled unit

          DefaultUserRoleCanCreateApps directory default: can any user register
                                          an application?
          DefaultUserRoleCanCreateSecurityGroups
          DefaultUserRoleCanReadOtherUsers
          GuestUserRoleId guest access level (GUID; see
                                          GuestUserRole for the friendly name)
          GuestUserRole 'Member-equivalent' / 'Guest' /
                                          'Restricted guest' / $null
          AllowInvitesFrom who may invite guests
          AllowEmailVerifiedUsersToJoin self-service sign-up into the tenant

          ActivatedRoleCount distinct roles with >= 1 active assignment
          GlobalAdministratorCount active, permanent Global Admins. Counted by
                                          roleTemplateId, because Graph reports that
                                          role as 'Company Administrator' on many
                                          tenants and matching the name reports zero
          HighlyPrivilegedMemberCount distinct principals in any role flagged
                                          highly privileged by
                                          Get-MsecPrivilegedRoleTemplate
          PrivilegedRoleSummary array of {RoleName, MemberCount} for the
                                          highly-privileged roles that have holders

          Notes ordered dictionary of section -> reason,
                                          populated only for sections that failed.
                                          Empty when everything was readable.

        COUNTS ARE OF PEOPLE AND APPLICATIONS, NOT ASSIGNMENTS. Roles are read via
        Get-MsecEntraRoleHolder, which expands role-assignable groups, so somebody
        who inherited Global Administrator through a group is counted - the older
        /directoryRoles view could only see the group itself. Where a group could
        not be expanded it counts as one principal rather than as zero, which
        under-states rather than invents. A principal holding several privileged
        roles, or the same role through two groups, still counts once.

        Counts come from ACTIVE, permanent assignments only - PIM-eligible holders
        are excluded, so in a tenant using PIM the true administrator population is
        larger than these numbers. That is deliberate: this row answers "what
        standing privilege exists", and PimAvailable in the same row tells you
        whether to go and ask Get-MsecEntraRoleHolder -AssignmentType Eligible the
        other half of the question.
    #>

    [CmdletBinding()]
    param(
        [Parameter()]
        [switch] $Strict
    )

    Assert-MsecSession

    $notes = [ordered]@{}

    # Run one section, returning $null and recording why on failure. -Strict turns
    # every failure back into a throw so a collection run can fail loudly.
    $section = {
        param([string] $Name, [scriptblock] $Action)
        try { & $Action }
        catch {
            if ($Strict) { throw }
            $notes[$Name] = $_.Exception.Message
            $null
        }
    }

    # ---- 1. Security defaults ------------------------------------------------
    $securityDefaults = & $section 'securityDefaults' {
        try {
            $r = Invoke-MsecGraphRequest -Path '/v1.0/policies/identitySecurityDefaultsEnforcementPolicy'
            [bool] $r.isEnabled
        }
        catch {
            if ($_.Exception.Message -match '403|Forbidden') {
                throw "Forbidden when calling /policies/identitySecurityDefaultsEnforcementPolicy. The msec app needs the 'Policy.Read.All' application permission (admin consent required). Re-run New-MsecApp to add and consent it. Original error: $($_.Exception.Message)"
            }
            throw
        }
    }

    # ---- 2. Authorization policy (directory defaults) ------------------------
    $authPolicy = & $section 'authorizationPolicy' {
        try {
            Invoke-MsecGraphRequest -Path '/v1.0/policies/authorizationPolicy'
        }
        catch {
            if ($_.Exception.Message -match '403|Forbidden') {
                throw "Forbidden when calling /policies/authorizationPolicy. The msec app needs the 'Policy.Read.All' application permission (admin consent required). Re-run New-MsecApp to add and consent it. Original error: $($_.Exception.Message)"
            }
            throw
        }
    }

    # Graph returns authorizationPolicy as a single object on some tenants and a
    # one-element collection on others; normalise before reading fields.
    if ($authPolicy -and $authPolicy.value) { $authPolicy = @($authPolicy.value)[0] }
    $defaultUserRole = $authPolicy.defaultUserRolePermissions

    # ---- 3. Licences -> capability flags ------------------------------------
    $licences = & $section 'licenses' { @(Get-MsecEntraLicense) }

    # Distinct successfully-provisioned plan names across every SKU. Membership in
    # this set is the honest "is the workload usable here" test - see
    # Get-MsecEntraLicense .NOTES on provisioningStatus.
    $plans = @($licences | ForEach-Object { $_.ServicePlans } | Sort-Object -Unique)
    $hasPlan = { param([string[]] $Names) [bool] @($plans | Where-Object { $_ -in $Names }).Count }

    $p1 = & $hasPlan @('AAD_PREMIUM')
    $p2 = & $hasPlan @('AAD_PREMIUM_P2')

    # ---- 4. Privileged roles -------------------------------------------------
    # -AssignmentType Active: this row is about STANDING privilege. Including PIM
    # eligibility would conflate "holds Global Admin permanently" with "can activate it
    # when needed", which are close to opposite findings in a governed tenant.
    #
    # The section name stays 'directoryRoles' because it is the key callers match in
    # Notes, and renaming it would break their handling of a failed read.
    $roleMembers = & $section 'directoryRoles' { @(Get-MsecEntraRoleHolder -AssignmentType Active) }

    $privileged  = @($roleMembers | Where-Object IsHighlyPrivileged)

    # Who a row is ABOUT. EffectiveId is the holder - the person or application that
    # would be remediated - and is what a headcount wants now that groups are expanded.
    # It is $null only where an assigned group could not be expanded, and such a row
    # falls back to the assignee so the assignment counts as one principal rather than
    # vanishing: under-stating is survivable, silently dropping a privilege path is not.
    $holderId = { param($Row) if ($Row.EffectiveId) { $Row.EffectiveId } else { $Row.PrincipalId } }

    $distinctHolders = {
        param($Rows)
        @($Rows | ForEach-Object { & $holderId $_ } | Where-Object { $_ } | Sort-Object -Unique).Count
    }

    # MemberCount is distinct holders per role, not row count: one person who inherits a
    # role through two groups is one administrator of it, not two.
    $roleSummary = @(
        $privileged | Group-Object RoleName | ForEach-Object {
            [PSCustomObject]@{ RoleName = $_.Name; MemberCount = (& $distinctHolders $_.Group) }
        } | Sort-Object MemberCount -Descending
    )

    # A principal in several privileged roles must count once, not once per role.
    $privilegedPrincipals = & $distinctHolders $privileged

    # Distinguish "read it, found none" from "could not read it". A tenant with no
    # licences at all is a real, meaningful answer (it says the workloads aren't
    # there); a failed call must not be reported as the same thing.
    $licencesRead = -not $notes.Contains('licenses')
    $rolesRead    = -not $notes.Contains('directoryRoles')

    [PSCustomObject]@{
        PSTypeName                              = 'MsecEntraTenantSecuritySetting'

        TenantId                                = $script:MsecSession.TenantId

        SecurityDefaultsEnabled                 = $securityDefaults

        EntraIdPremium                          = if (-not $licencesRead) { $null }
                                                  elseif ($p2) { 'P2' } elseif ($p1) { 'P1' } else { $null }
        EntraIdPremiumP1                        = if ($licencesRead) { $p1 } else { $null }
        EntraIdPremiumP2                        = if ($licencesRead) { $p2 } else { $null }
        ConditionalAccessAvailable              = if ($licencesRead) { [bool]($p1 -or $p2) } else { $null }
        IdentityProtectionAvailable             = if ($licencesRead) { $p2 } else { $null }
        PimAvailable                            = if ($licencesRead) { $p2 } else { $null }
        IntuneProvisioned                       = if ($licencesRead) { & $hasPlan @('INTUNE_A') } else { $null }
        # EXCHANGE_S_FOUNDATION is deliberately excluded. It is a stub plan bundled with
        # many unrelated SKUs (Power BI Standard, Dynamics, ...) purely to provide
        # directory scaffolding - it grants NO mailboxes and no Exchange Online
        # Protection. Counting it would report a mail estate that does not exist, and
        # then an absent emailStats domain reads as a fault to fix rather than as
        # not-applicable. Match real mailbox plans only.
        ExchangeOnlineProvisioned               = if ($licencesRead) {
                                                      [bool] @($plans | Where-Object {
                                                          $_ -like 'EXCHANGE_*' -and $_ -ne 'EXCHANGE_S_FOUNDATION'
                                                      }).Count
                                                  } else { $null }
        DefenderForEndpointProvisioned          = if ($licencesRead) { & $hasPlan @('WINDEFATP') } else { $null }
        DefenderForOffice365Provisioned         = if ($licencesRead) {
                                                      & $hasPlan @('ATP_ENTERPRISE', 'THREAT_INTELLIGENCE')
                                                  } else { $null }
        ServicePlans                            = if ($licencesRead) { $plans } else { $null }
        LicensedSkuCount                        = if ($licencesRead) {
                                                      @($licences | Where-Object Enabled -gt 0).Count
                                                  } else { $null }

        DefaultUserRoleCanCreateApps            = $defaultUserRole.allowedToCreateApps
        DefaultUserRoleCanCreateSecurityGroups  = $defaultUserRole.allowedToCreateSecurityGroups
        DefaultUserRoleCanReadOtherUsers        = $defaultUserRole.allowedToReadOtherUsers
        GuestUserRoleId                         = $authPolicy.guestUserRoleId
        GuestUserRole                           = switch ([string]$authPolicy.guestUserRoleId) {
                                                      'a0b1b346-4d3e-4e8b-98f8-753987be4970' { 'Member-equivalent' }
                                                      '10dae51f-b6af-4016-8d66-8c2a99b929b3' { 'Guest' }
                                                      '2af84b1e-32c8-42b7-82bc-daa82404023b' { 'Restricted guest' }
                                                      default { $null }
                                                  }
        AllowInvitesFrom                        = $authPolicy.allowInvitesFrom
        AllowEmailVerifiedUsersToJoin           = $authPolicy.allowEmailVerifiedUsersToJoinOrganization

        ActivatedRoleCount                      = if ($rolesRead) {
                                                      @($roleMembers | Select-Object -ExpandProperty RoleTemplateId -Unique).Count
                                                  } else { $null }
        # Matched on the template id, never the display name. Graph returns Global
        # Administrator under its legacy name 'Company Administrator' on a great many
        # tenants, so the old `RoleName -eq 'Global Administrator'` comparison reported
        # ZERO Global Admins on those - in the headline column of this very report.
        # Template ids are identical in every tenant and every cloud.
        GlobalAdministratorCount                = if ($rolesRead) {
                                                      & $distinctHolders @($roleMembers |
                                                          Where-Object RoleTemplateId -eq $script:MsecGlobalAdministratorTemplateId)
                                                  } else { $null }
        HighlyPrivilegedMemberCount             = if ($rolesRead) { $privilegedPrincipals } else { $null }
        PrivilegedRoleSummary                   = if ($rolesRead) { $roleSummary } else { $null }

        Notes                                   = $notes
    }
}