public/Get-MsecPurviewRetention.ps1

function Get-MsecPurviewRetention {
    <#
    .SYNOPSIS
        Retention labels and retention policies, with whether either is actually in force.

    .DESCRIPTION
        Records management has two halves that are easy to mistake for one. A retention LABEL
        says what to do with an item; a retention POLICY says where the rule applies. A label
        with no policy publishing it does nothing at all, and the label list gives no hint of
        that - which is how a tenant ends up appearing to have retention while retaining
        nothing.

        AN EMPTY POLICY LIST DOES NOT MEAN THERE ARE NO POLICIES. Get-RetentionCompliancePolicy
        is scoped by the connecting identity's compliance role groups, and an identity without
        retention rights gets an EMPTY SET rather than access-denied. Measured directly: an
        app-only identity holding Global Reader returned 4 DLP policies, 3 sensitivity labels,
        2 label policies, 60 alert policies and 1 retention LABEL - and 0 retention policies,
        while the portal showed one. The cmdlet existed, ran, and reported nothing.

        That is the worst failure shape available: indistinguishable from a tenant with no
        retention at all, and the conclusion it invites - "nothing is retained" - is exactly
        the one a records-management report must not get wrong. So a zero here always warns.
        Reading retention policies needs a role group such as View-Only Retention Management;
        Global Reader is not enough.

        BOTH KINDS COME BACK IN ONE STREAM, tagged by Kind ('Label' or 'Policy'), because the
        question is almost always "what retention do we have" rather than one or the other. Use
        -Kind to take a side. IsInForce is the column that matters: false on an unpublished
        label, false on a disabled policy.

        AN EMPTY RESULT IS NOT AN ERROR AND THE COMMAND DOES NOT THROW ON ONE - but for the
        policy half it is not a finding either. A report should say "no retention policies were
        visible to this identity", naming the limit, rather than "no retention is configured".
        The label half is different: Get-ComplianceTag is readable under Global Reader, so an
        empty label list can be reported as measured.

    .PARAMETER Kind
        Limit to 'Label' or 'Policy'. Both by default.

    .EXAMPLE
        Connect-Msec -KeyVaultName kv-msec
        Get-MsecPurviewRetention | Format-Table Kind, Name, Action, Duration, IsInForce

    .EXAMPLE
        # Labels that exist but are published by no policy, so retain nothing.
        Get-MsecPurviewRetention -Kind Label | Where-Object { -not $_.IsInForce }

    .OUTPUTS
        One PSCustomObject per label or policy, PSTypeName 'MsecPurviewRetention'.

    .NOTES
        Needs Connect-Msec. The compliance session is opened automatically on first use - that
        handshake takes a few seconds and imports a few hundred cmdlets, so it is reported rather
        than done silently. Call Connect-MsecPurview yourself to control -Organization, or to
        choose when those 102 cmdlet names land in your runspace.

        EXCHANGE MRM RETENTION IS A DIFFERENT SYSTEM AND IS NOT READ HERE. Get-RetentionPolicy
        and Get-RetentionPolicyTag live in the Exchange Online session, not the compliance one,
        and govern mailboxes independently of everything above. A tenant can show no Purview
        retention while Exchange is deleting mail on a schedule, so "no retention" drawn from
        this command alone is a statement about Purview only.

        Read-only.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter()]
        [ValidateSet('Label', 'Policy')]
        [string] $Kind
    )

    Initialize-MsecExoSession -Endpoint Compliance

    # Checked per half, and only for the half actually being read: a tenant that exposes one and
    # not the other can still answer -Kind for the side that works, and the message says so.
    if (-not $Kind -or $Kind -eq 'Label') {
        Assert-MsecExoCmdlet -Name 'Get-ComplianceTag' -Feature 'retention labels' `
            -Hint 'Use -Kind Policy to read the half that is available.'
    }
    if (-not $Kind -or $Kind -eq 'Policy') {
        Assert-MsecExoCmdlet -Name 'Get-RetentionCompliancePolicy' -Feature 'retention policies' `
            -Hint 'Use -Kind Label to read the half that is available.'
    }

    if (-not $Kind -or $Kind -eq 'Label') {
        foreach ($tag in @(Get-ComplianceTag -ErrorAction Stop)) {
            [PSCustomObject]@{
                PSTypeName    = 'MsecPurviewRetention'
                Kind          = 'Label'
                Name          = [string] $tag.Name
                Action        = [string] $tag.RetentionAction
                Duration      = [string] $tag.RetentionDuration
                RetentionType = [string] $tag.RetentionType
                IsRecordLabel = [bool] $tag.IsRecordLabel
                # A label nothing publishes cannot apply to anything.
                IsInForce     = [bool] $tag.Published
                Enabled       = $null
                Locations     = $null
                Notes         = [string] $tag.Notes
                WhenChangedUtc = $tag.WhenChanged
            }
        }
    }

    if (-not $Kind -or $Kind -eq 'Policy') {
        $policies = @(Get-RetentionCompliancePolicy -ErrorAction Stop)

        # ZERO IS UNVERIFIABLE HERE, so it is reported rather than returned silently. An
        # identity without retention rights gets an empty set, not an error, so "no policies
        # exist" and "this identity cannot see policies" arrive looking identical. There is no
        # signal in the response to tell them apart - hence a warning every time rather than a
        # heuristic that would be confidently wrong some of the time.
        if ($policies.Count -eq 0) {
            Write-Warning ('No retention policies returned. This is NOT proof that none exist: ' +
                'Get-RetentionCompliancePolicy is scoped by the connecting identity''s compliance ' +
                'role groups and returns an EMPTY SET rather than access-denied when retention ' +
                'rights are missing. Global Reader is not sufficient - a role group such as ' +
                'View-Only Retention Management is. Confirm in the Purview portal under Data ' +
                'Lifecycle Management before reporting that nothing is retained.')
        }

        foreach ($policy in $policies) {
            $locations = @(
                foreach ($n in 'ExchangeLocation', 'SharePointLocation', 'OneDriveLocation',
                               'ModernGroupLocation', 'TeamsChannelLocation', 'TeamsChatLocation') {
                    $resolved = Resolve-MsecPurviewLocation $policy.$n
                    if ($resolved.Scope -ne 'None') { "$($n -replace 'Location','')=$($resolved.Scope)" }
                }
            )

            [PSCustomObject]@{
                PSTypeName    = 'MsecPurviewRetention'
                Kind          = 'Policy'
                Name          = [string] $policy.Name
                Action        = $null
                Duration      = $null
                RetentionType = $null
                IsRecordLabel = $null
                IsInForce     = [bool] $policy.Enabled
                Enabled       = [bool] $policy.Enabled
                Locations     = $locations
                Notes         = [string] $policy.Comment
                WhenChangedUtc = $policy.WhenChanged
            }
        }
    }
}