Common/Resolve-TenantIdentity.ps1

<#
.SYNOPSIS
    Resolves the canonical tenant identity for baseline / drift bookkeeping.
.DESCRIPTION
    Returns a PSCustomObject describing the connected tenant in stable terms:

      Guid : the tenant GUID (from Get-MgContext.TenantId after Graph
                      connect) -- the canonical identifier used as the folder
                      key for baselines (C1 #780). Falls back to a stable hash
                      of the user-supplied TenantId if Graph isn't connected.
      DisplayName : tenant display name (from Get-MgOrganization)
      PrimaryDomain : primary verified domain (Get-MgOrganization VerifiedDomains
                      with isDefault=true)
      Environment : commercial / gcc / gcchigh / dod (passed in by caller)
      Source : 'Graph' when fully resolved, 'Fallback' when Graph data
                      was unavailable -- callers can warn

    The function is read-only: no Graph calls beyond Get-MgContext +
    Get-MgOrganization, both of which are already in the assessment's required
    permissions for the Tenant section.

    Resolves once per assessment run (caller caches the result). Used by the
    baseline export/compare path and intended for any future feature that
    needs a stable tenant identifier (#812 permissions deficit CSV, #802
    score-disclosure, etc.).
.PARAMETER TenantIdInput
    The user-supplied -TenantId from Invoke-M365Assessment. Used as the
    fallback folder key when Graph context isn't usable (e.g. AD-only runs).
.PARAMETER Environment
    The cloud environment (commercial/gcc/gcchigh/dod). Stored as metadata.
.EXAMPLE
    $identity = Resolve-TenantIdentity -TenantIdInput $TenantId -Environment $M365Environment
    $folderKey = $identity.Guid
#>

function Resolve-TenantIdentity {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        [string]$TenantIdInput,

        [Parameter()]
        [string]$Environment = 'commercial'
    )

    $guid          = $null
    $displayName   = ''
    $primaryDomain = ''
    $source        = 'Fallback'

    try {
        $context = Get-MgContext -ErrorAction SilentlyContinue
        if ($context -and $context.TenantId) {
            $guid = [string]$context.TenantId
            $source = 'Graph'
        }
    }
    catch { Write-Verbose "Resolve-TenantIdentity: Get-MgContext threw: $($_.Exception.Message)" }

    # Try to enrich with display name + primary domain via Get-MgOrganization.
    # This call requires Organization.Read.All which the Tenant section already
    # asks for, so it's expected to succeed for any normal assessment run.
    if ($guid) {
        try {
            $org = Get-MgOrganization -ErrorAction Stop | Select-Object -First 1
            if ($org) {
                $displayName = [string]$org.DisplayName
                $primary = $org.VerifiedDomains | Where-Object { $_.IsDefault } | Select-Object -First 1
                if ($primary) { $primaryDomain = [string]$primary.Name }
            }
        }
        catch { Write-Verbose "Resolve-TenantIdentity: Get-MgOrganization unavailable: $($_.Exception.Message)" }
    }

    # Fallback: synthesize a stable key from the user-supplied TenantId. The
    # resulting "guid" isn't a real GUID -- it's a deterministic 32-hex hash
    # so identical TenantIdInput values produce identical folder keys.
    if (-not $guid) {
        $sha = [System.Security.Cryptography.SHA256]::Create()
        try {
            $bytes = $sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($TenantIdInput.ToLowerInvariant()))
            $hex = ([System.BitConverter]::ToString($bytes) -replace '-', '').Substring(0, 32).ToLowerInvariant()
            # Format as a GUID-shaped string so callers can treat it uniformly.
            $guid = "{0}-{1}-{2}-{3}-{4}" -f $hex.Substring(0, 8), $hex.Substring(8, 4), $hex.Substring(12, 4), $hex.Substring(16, 4), $hex.Substring(20, 12)
        }
        finally {
            $sha.Dispose()
        }
    }

    return [pscustomobject]@{
        Guid          = $guid
        DisplayName   = $displayName
        PrimaryDomain = $primaryDomain
        Environment   = $Environment
        Source        = $source
        TenantInput   = $TenantIdInput
    }
}

function Resolve-BaselineFolder {
    <#
    .SYNOPSIS
        Resolves a baseline folder path, preferring GUID-keyed naming
        and falling back to legacy TenantId-keyed naming for read.
    .DESCRIPTION
        C1 #780: baselines saved on v2.9.0+ use '<Label>_<TenantGuid>' as
        the folder name. Pre-v2.9.0 baselines used '<Label>_<TenantId>'
        where TenantId was whatever string the user supplied. This helper
        searches the GUID path first, then the legacy path. Returns the
        first existing folder, or the canonical GUID path if neither
        exists (so error messages point at the new location).
    .PARAMETER OutputFolder
        Root output folder (parent of Baselines/).
    .PARAMETER Label
        Baseline label.
    .PARAMETER TenantGuid
        Canonical tenant GUID. If supplied, the GUID-keyed folder is the
        first candidate.
    .PARAMETER TenantId
        Legacy tenant identifier (vanity domain or onmicrosoft.com short).
        Searched as a fallback for pre-v2.9.0 baselines.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [string]$OutputFolder,

        [Parameter(Mandatory)]
        [string]$Label,

        [Parameter()]
        [string]$TenantGuid = '',

        [Parameter()]
        [string]$TenantId = ''
    )

    $safeLabel = $Label -replace '[^\w\-]', '_'
    $candidates = @()
    if ($TenantGuid) {
        $g = $TenantGuid -replace '[^\w\-]', ''
        $candidates += (Join-Path -Path $OutputFolder -ChildPath ("Baselines\${safeLabel}_${g}"))
    }
    if ($TenantId) {
        $t = $TenantId -replace '[^\w\.\-]', '_'
        $candidates += (Join-Path -Path $OutputFolder -ChildPath ("Baselines\${safeLabel}_${t}"))
    }

    foreach ($candidate in $candidates) {
        if (Test-Path -LiteralPath $candidate -PathType Container) {
            return $candidate
        }
    }

    # Neither folder exists -- return the canonical (GUID) candidate if we
    # have one, else the legacy candidate. This lets callers compose useful
    # "not found" messages pointing at the path they expected.
    if ($candidates.Count -gt 0) { return $candidates[0] }
    return Join-Path -Path $OutputFolder -ChildPath ("Baselines\${safeLabel}")
}