Private/Identity.ps1

# Azure identity and permission helpers

function Get-SignedInUserId {
    <#
    .SYNOPSIS
    Return the identity of the currently signed-in user from the Az context.
    Returns a UPN for users, or app/service-principal ID for service principals.
    #>

    try {
        $ctx = Get-AzContext
        if ($ctx -and $ctx.Account -and $ctx.Account.Id) { return $ctx.Account.Id }
    } catch {}
    return $null
}

function Get-SubscriptionList {
    <#
    .SYNOPSIS
    Return all subscriptions accessible to the current identity (all states).
    Scoped to the specified tenant when provided — prevents cross-tenant subscription
    leakage when the account is a guest in multiple tenants (Get-AzSubscription without
    -TenantId can return subscriptions from all accessible tenants).
    Returns array of {id, name, state} objects.
    Callers are responsible for state-filtering so they can emit useful diagnostics.
    #>

    param([string]$TenantId = "")
    try {
        $subs = if ($TenantId) {
            Get-AzSubscription -TenantId $TenantId -WarningAction SilentlyContinue
        } else {
            Get-AzSubscription -WarningAction SilentlyContinue
        }
        return @($subs | ForEach-Object {
            [PSCustomObject]@{ id = $_.Id; name = $_.Name; state = $_.State }
        })
    } catch {
        throw "Failed to list subscriptions: $_"
    }
}

function Test-AuditPermissions {
    <#
    .SYNOPSIS
    Preflight — verify the signed-in identity can read resources on each subscription.
    Checks run in parallel (up to 8 at once, matching the Python implementation) and
    aggregate unique roles across all subscriptions rather than printing per-sub lines.
    Returns hashtable: .AllClear [bool], .Warnings [string[]], .UserId [string],
                       .Roles [string[]], .RoleSubCount [hashtable], .TotalSubs [int]
    #>

    param(
        [string[]]$SubscriptionIds,
        [hashtable]$SubNames = @{},  # id -> display name used in warning messages
        [switch]$ProbeKeyVaults      # probe each vault's data plane so the 8.3.x access gap surfaces once, upfront
    )

    $warnings = [System.Collections.Generic.List[string]]::new()
    $userId   = Get-SignedInUserId

    if (-not $userId) {
        $warnings.Add("Could not determine signed-in user identity. Run 'Connect-AzAccount' first.")
        return @{ AllClear = $false; Warnings = $warnings.ToArray(); UserId = $null; Roles = @(); RoleSubCount = @{}; TotalSubs = 0 }
    }

    Write-AuditLog "Identity: $userId" -Level DEBUG

    # Capture account type before spawning parallel runspaces so we know whether
    # to use -SignInName (User) or -ObjectId (ServicePrincipal) in Get-AzRoleAssignment.
    $azCtxForParallel = Get-AzContext
    $accountTypeLocal = [string]$azCtxForParallel.Account.Type   # 'User' or 'ServicePrincipal'
    $total            = $SubscriptionIds.Count
    $probeKVLocal     = $ProbeKeyVaults.IsPresent

    # Run all role lookups in parallel — up to 8 at once
    $subResults = $SubscriptionIds | ForEach-Object -Parallel {
        $subId       = $_
        $uid         = $using:userId
        $accountType = $using:accountTypeLocal
        $probeKV     = $using:probeKVLocal

        try {
            # Switch context to this subscription in the current runspace.
            # Note: -SubscriptionId alone is correct here — combining -Context and
            # -SubscriptionId uses conflicting parameter sets and throws.
            $null = Set-AzContext -SubscriptionId $subId `
                        -WarningAction SilentlyContinue -ErrorAction Stop

            $roles = @()
            try {
                # Targeted query — filter to the current identity on the server side
                $assignments = if ($accountType -eq 'ServicePrincipal') {
                    @(Get-AzRoleAssignment -ObjectId $uid -ErrorAction Stop)
                } else {
                    @(Get-AzRoleAssignment -SignInName $uid -ErrorAction Stop)
                }
                $roles = @($assignments | Select-Object -ExpandProperty RoleDefinitionName | Sort-Object -Unique)
            } catch {
                # Fallback: enumerate all assignments and return unique role names
                # (handles edge cases such as group-based assignments or cross-tenant guest accounts)
                $all = @(Get-AzRoleAssignment -ErrorAction SilentlyContinue)
                if ($all.Count -gt 0) {
                    $roles = @($all | Select-Object -ExpandProperty RoleDefinitionName | Sort-Object -Unique)
                }
            }

            # Data-plane probe: list one key per vault. Raw error messages are
            # classified in the caller (Test-AuthzError etc. are not visible here —
            # parallel runspaces don't inherit module functions).
            $vaultFailures = @()
            if ($probeKV) {
                $vaults = @()
                try { $vaults = @(Get-AzKeyVault -WarningAction SilentlyContinue -ErrorAction Stop) } catch {}
                foreach ($v in $vaults) {
                    $vn = [string]$v.VaultName
                    try {
                        $null = Get-AzKeyVaultKey -VaultName $vn -WarningAction SilentlyContinue -ErrorAction Stop |
                                    Select-Object -First 1
                    } catch {
                        $vaultFailures += [PSCustomObject]@{
                            Vault = $vn
                            SubId = $subId
                            Error = ($_.Exception.Message -replace '\r?\n', ' ').Trim()
                        }
                    }
                }
            }

            [PSCustomObject]@{ SubId = $subId; Success = $true; Roles = $roles; Error = $null; VaultFailures = $vaultFailures }
        } catch {
            $errMsg = ($_.Exception.Message -replace '\r?\n', ' ').Trim()
            [PSCustomObject]@{ SubId = $subId; Success = $false; Roles = @(); Error = $errMsg; VaultFailures = @() }
        }
    } -ThrottleLimit 8

    # Aggregate: collect unique roles and count how many subscriptions each appears in
    $roleSubCount  = @{}
    $vaultFailures = [System.Collections.Generic.List[object]]::new()
    foreach ($sr in $subResults) {
        $label = if ($SubNames.ContainsKey($sr.SubId) -and $SubNames[$sr.SubId]) { $SubNames[$sr.SubId] } else { $sr.SubId }
        if (-not $sr.Success) {
            $warnings.Add("Could not verify permissions for '$label' ($($sr.SubId)): $($sr.Error)")
            continue
        }
        foreach ($role in ($sr.Roles | Select-Object -Unique)) {
            $roleSubCount[$role] = ($roleSubCount[$role] ?? 0) + 1
        }
        foreach ($vf in @($sr.VaultFailures)) { $vaultFailures.Add($vf) }
    }

    foreach ($w in (New-KeyVaultProbeWarning -Failures $vaultFailures.ToArray())) {
        $warnings.Add($w)
    }

    $allRoles    = @($roleSubCount.Keys | Sort-Object)
    $rolesLower  = @($allRoles | ForEach-Object { $_.ToLower() })
    $hasReader   = @($rolesLower | Where-Object { $_ -in @('reader', 'contributor', 'owner', 'user access administrator') }).Count -gt 0
    $hasSecurity = @($rolesLower | Where-Object { $_ -like 'security*' }).Count -gt 0

    if (-not $hasReader) {
        $warnings.Add("No 'Reader' or higher role found — most audit checks require Reader access.")
    }
    if (-not $hasSecurity) {
        $warnings.Add("No 'Security Reader' or 'Security Admin' role found — some checks (Sections 2, 8) may show as ERROR.")
    }

    return @{
        AllClear     = ($warnings.Count -eq 0)
        Warnings     = $warnings.ToArray()
        UserId       = $userId
        Roles        = $allRoles
        RoleSubCount = $roleSubCount
        TotalSubs    = $total
    }
}

function New-KeyVaultProbeWarning {
    <#
    .SYNOPSIS
    Turn per-vault data-plane probe failures into consolidated preflight warnings —
    one per cause (authorization / firewall / other) instead of one ERROR per control
    per vault later. Returns string[]; empty when there are no failures.
    #>

    param([object[]]$Failures = @())

    if ($Failures.Count -eq 0) { return @() }

    $authz    = [System.Collections.Generic.List[string]]::new()
    $firewall = [System.Collections.Generic.List[string]]::new()
    $other    = [System.Collections.Generic.List[string]]::new()

    foreach ($f in $Failures) {
        $name = [string]$f.Vault
        if (Test-AuthzError ([string]$f.Error))        { $authz.Add($name) }
        elseif (Test-FirewallError ([string]$f.Error)) { $firewall.Add($name) }
        else                                           { $other.Add("$name ($([string]$f.Error))") }
    }

    $out = [System.Collections.Generic.List[string]]::new()
    if ($authz.Count -gt 0) {
        $out.Add("Key Vault data-plane access is missing for $($authz.Count) vault(s): $($authz -join ', '). " +
            "Dependent checks (8.3.x) will show ERROR for these vaults. Grant the 'Key Vault Reader' role " +
            "(RBAC vaults) or an access policy with List permissions, e.g. " +
            "az role assignment create --role 'Key Vault Reader' --assignee <upn-or-app-id> --scope <vault-resource-id>")
    }
    if ($firewall.Count -gt 0) {
        $out.Add("Key Vault firewall blocks this machine for $($firewall.Count) vault(s): $($firewall -join ', '). " +
            "Dependent checks (8.3.x) will show ERROR for these vaults. Add the audit machine's IP to each vault's firewall allowlist.")
    }
    if ($other.Count -gt 0) {
        $out.Add("Key Vault data-plane probe failed for $($other.Count) vault(s): $($other -join '; ')")
    }
    return $out.ToArray()
}

function Get-DisabledUserPrefetch {
    <#
    .SYNOPSIS
    Fetch all disabled Entra ID user accounts (tenant-wide) via Microsoft Graph.
    Returns @{ users = [array of {id, userPrincipalName}] } on success or
    @{ __error = <message> } on failure — same sentinel convention as the
    Resource Graph prefetch entries, so Get-PrefetchError works unchanged.
    #>

    $uri = 'https://graph.microsoft.com/v1.0/users?$filter=accountEnabled eq false&$select=id,userPrincipalName&$top=999'
    $r = Invoke-AzRestPaged -Uri $uri
    if (-not $r.Success) {
        return @{ __error = [string]$r.Error }
    }
    return @{ users = @($r.Data) }
}