MSGraphPermissions.psm1

function Add-ClaimToResource {
    <#
    .SYNOPSIS
        Adds a permission claim to a resource's method/scheme collection.
 
    .DESCRIPTION
        Internal helper function that adds a permission claim to the nested hashtable
        structure representing a Graph API resource. Ensures the proper hierarchy of
        Method -> Scheme -> Claims is maintained and initializes collections as needed.
 
        This function is used during the permissions index building process to organize
        permission data by HTTP method and authentication scheme.
 
    .PARAMETER Resource
        The resource hashtable object to modify. Must have a Methods property (hashtable).
 
    .PARAMETER Method
        The HTTP method (GET, POST, PUT, PATCH, DELETE, etc.) for this claim.
 
    .PARAMETER Scheme
        The authentication scheme (DelegatedWork, DelegatedPersonal, Application).
 
    .PARAMETER Claim
        The claim object to add, containing permission details (Permission, Least, AlsoRequires).
 
    .EXAMPLE
        Add-ClaimToResource -Resource $resource -Method 'GET' -Scheme 'DelegatedWork' -Claim $claim
         
        Adds a claim to the resource's nested method and scheme structure.
 
    .NOTES
        This is an internal function not exported from the module. It mutates the Resource
        parameter by adding the claim to the appropriate nested collection.
    #>

    param(
        [Parameter(Mandatory)]
        $Resource,
        [Parameter(Mandatory)]
        [string]$Method,
        [Parameter(Mandatory)]
        [string]$Scheme,
        [Parameter(Mandatory)]
        $Claim
    )
    
    if (-not $Resource.Methods.ContainsKey($Method)) {
        $Resource.Methods[$Method] = @{}
    }
    if (-not $Resource.Methods[$Method].ContainsKey($Scheme)) {
        $Resource.Methods[$Method][$Scheme] = [System.Collections.Generic.List[object]]::new()
    }
    $Resource.Methods[$Method][$Scheme].Add($Claim)
}


function Build-PermissionsIndex {
    <#
    .SYNOPSIS
        Builds an indexed lookup structure from raw Microsoft Graph permissions data.
 
    .DESCRIPTION
        Internal function that transforms the raw permissions JSON data from Microsoft Graph
        into an optimized hashtable index for fast lookups. The index maps API paths to
        ProtectedResource objects containing all available permissions organized by HTTP
        method and authentication scheme.
 
        This function processes the complex nested structure of the permissions data:
        - Iterates through all permissions and their path sets
        - Parses "least=" metadata to identify least privileged permissions
        - Parses "alsoRequires=" metadata for permission dependencies
        - Creates claims for each method/scheme/path combination
        - Normalizes paths to lowercase for case-insensitive lookups
        - Builds a nested structure: Path -> Method -> Scheme -> Claims
 
        The resulting index enables O(1) lookups by path for the module's query functions.
 
    .PARAMETER PermissionsData
        The raw permissions data hashtable deserialized from the Microsoft Graph JSON file.
        Must contain a 'permissions' property with the permission definitions.
 
    .OUTPUTS
        Hashtable
        Returns a hashtable indexed by normalized (lowercase) API paths, where each value
        is a ProtectedResource object with Methods, Schemes, and Claims organized for
        efficient querying.
 
    .EXAMPLE
        $index = Build-PermissionsIndex -PermissionsData $rawData
         
        Builds a searchable index from raw Graph permissions data.
 
    .EXAMPLE
        $index = Build-PermissionsIndex -PermissionsData $jsonData
        # Builds indexed structure from raw permissions data
 
    .NOTES
        This is an internal function not exported from the module. It is called by
        Initialize-GraphPermissions during the cache initialization process.
         
        The function processes 6000+ API paths and tens of thousands of permission
        combinations, so performance is important. The indexed structure allows
        Find-GraphLeastPrivilege and Get-GraphPermissions to run efficiently.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)]
        [object]$PermissionsData
    )

    $index = [System.Collections.Generic.Dictionary[string, object]]::new([StringComparer]::OrdinalIgnoreCase)

    foreach ($permissionName in $PermissionsData.permissions.Keys) {
        $permission = $PermissionsData.permissions[$permissionName]

        foreach ($pathSet in $permission.pathSets) {
            $schemeKeys = $pathSet.schemeKeys
            $methods = $pathSet.methods
            $paths = $pathSet.paths

            foreach ($pathKey in $paths.Keys) {
                $pathValue = $paths[$pathKey]
                
                # Get the "least=" metadata
                $leastPrivilegeSchemes = Get-LeastPrivilegeScheme $pathValue

                # Normalize path
                $normalizedPath = $pathKey.ToLowerInvariant()

                if (-not $index.ContainsKey($normalizedPath)) {
                    $index[$normalizedPath] = New-ProtectedResource -Path $normalizedPath
                }

                $resource = $index[$normalizedPath]

                foreach ($method in $methods) {
                    foreach ($schemeKey in $schemeKeys) {
                        $isLeast = $leastPrivilegeSchemes -contains $schemeKey
                        
                        # Parse alsoRequires if present
                        $alsoRequires = @()
                        if ($pathValue -match 'alsoRequires=([^,\s]+)') {
                            $alsoRequires = $matches[1] -split '\|'
                        }

                        $claim = New-AcceptableClaim -Permission $permissionName -Least $isLeast -AlsoRequires $alsoRequires -Scheme $schemeKey

                        Add-ClaimToResource -Resource $resource -Method $method -Scheme $schemeKey -Claim $claim
                    }
                }
            }
        }
    }

    return $index
}


function Format-PermissionString {
    <#
    .SYNOPSIS
        Formats a permission claim into a human-readable string.
 
    .DESCRIPTION
        Internal helper function that converts a permission claim object into a formatted
        string representation. If the claim has dependencies (AlsoRequires), they are
        included in the output joined with "and".
 
        Examples:
        - Simple permission: "Mail.Read"
        - With dependencies: "Mail.Read and Calendars.Read and Contacts.Read"
 
    .PARAMETER Claim
        The claim object containing Permission and optionally AlsoRequires properties.
 
    .OUTPUTS
        String
        Returns a formatted permission string with any dependencies included.
 
    .EXAMPLE
        Format-PermissionString -Claim $claim
         
        Returns formatted permission string like 'Mail.Read and User.Read'.
 
    .NOTES
        This is an internal function not exported from the module. It's used for
        formatting permission output in a consistent, readable way.
    #>

    param([Parameter(Mandatory)]$Claim)
    
    if ($Claim.AlsoRequires -and $Claim.AlsoRequires.Count -gt 0) {
        return "$($Claim.Permission) and $($Claim.AlsoRequires -join ' and ')"
    }
    return $Claim.Permission
}


function Get-LeastPrivilegeScheme {
    <#
    .SYNOPSIS
        Gets the least privileged scheme identifiers from Microsoft Graph path value metadata.
 
    .DESCRIPTION
        Internal function that retrieves authentication scheme identifiers from the "least="
        metadata embedded in Microsoft Graph permissions data path values. This metadata
        indicates which authentication schemes are considered least privileged for a
        specific permission on a specific path.
 
        The "least=" format supports comma-separated lists of scheme identifiers, for example:
        - "least=DelegatedWork" - Single scheme marked as least privileged
        - "least=DelegatedWork,Application" - Multiple schemes marked as least privileged
        - No "least=" metadata - Returns empty array (no explicit least privileged marking)
 
        This extraction is a critical part of the index building process, as it determines
        which permissions get marked with Least = $true in the claim objects.
 
    .PARAMETER PathValue
        The raw path value string from the Microsoft Graph permissions JSON, potentially
        containing "least=" metadata. Can be empty string.
 
    .OUTPUTS
        String[]
        Returns an array of scheme identifiers that are marked as least privileged.
        Returns empty array if no "least=" metadata is found.
 
    .EXAMPLE
        Get-LeastPrivilegeScheme -PathValue "least=DelegatedWork"
         
        Returns: @("DelegatedWork")
 
    .EXAMPLE
        Get-LeastPrivilegeScheme -PathValue "least=DelegatedWork,Application"
         
        Returns: @("DelegatedWork", "Application")
 
    .EXAMPLE
        Get-LeastPrivilegeScheme -PathValue ""
         
        Returns: @()
 
    .NOTES
        This is an internal function not exported from the module. It's called by
        Build-PermissionsIndex during the permissions data processing phase.
         
        The regex pattern matches "least=" followed by one or more scheme names
        separated by commas, stopping at the next comma or whitespace to handle
        cases where multiple metadata attributes are present.
    #>

    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$PathValue
    )

    if ($PathValue -match 'least=([^,\s]+(?:,[^,\s]+)*)') {
        return $matches[1] -split ','
    }

    # Default: no schemes marked as least privileged
    return @()
}


function Get-ResourceLeastPrivileged {
    <#
    .SYNOPSIS
        Extracts least privileged permissions from a resource object.
 
    .DESCRIPTION
        Internal function that filters and extracts only the least privileged permissions
        from a ProtectedResource object. It processes the nested Method -> Scheme -> Claims
        structure and returns only permissions marked as least privileged.
 
        The function implements smart fallback logic:
        - Prefers claims explicitly marked with Least = $true
        - If no explicit least privileged claim exists but only one permission is available,
          treats that single permission as implicitly least privileged
        - Calls Invoke-Disambiguate when multiple least privileged options exist to handle
          edge cases and select the most appropriate permission
 
        Supports optional filtering by HTTP method and/or authentication scheme to narrow
        results to specific scenarios.
 
    .PARAMETER Resource
        The ProtectedResource object containing the full set of permissions organized by
        method and scheme.
 
    .PARAMETER Method
        Optional HTTP method to filter results (GET, POST, PUT, PATCH, DELETE, etc.).
        If not specified, returns least privileged permissions for all methods.
 
    .PARAMETER Scheme
        Optional authentication scheme to filter results (DelegatedWork, DelegatedPersonal, Application).
 
    .EXAMPLE
        Get-ResourceLeastPrivileged -Resource $resource -Method 'GET' -Scheme 'DelegatedWork'
         
        Returns the least privileged permissions for the specified method and scheme.
        If not specified, returns least privileged permissions for all schemes.
 
    .OUTPUTS
        Hashtable
        Returns a nested hashtable structure: Method -> Scheme -> List[String] of permission names.
        Only includes methods/schemes that have least privileged permissions available.
 
    .NOTES
        This is an internal function not exported from the module. It is called by
        Find-GraphLeastPrivilege to extract the minimal permissions from cached data.
 
        The implicit least privilege fallback (single permission treated as least privileged)
        handles cases where Microsoft's metadata doesn't explicitly mark a permission as
        "least" but it's the only option available.
 
    .EXAMPLE
        Get-ResourceLeastPrivileged -Resource $resource -Method 'GET' -Scheme 'DelegatedWork'
        # Extracts least privileged permissions from resource
    #>

    param(
        [Parameter(Mandatory)]
        $Resource,
        [string]$Method,
        [string]$Scheme
    )

    $result = @{}

    if ($Method -and $Resource.Methods.ContainsKey($Method)) {
        $methodData = @{ $Method = $Resource.Methods[$Method] }
    }
    elseif ($Method) {
        return $result
    }
    else {
        $methodData = $Resource.Methods
    }

    foreach ($m in $methodData.Keys) {
        if ($Scheme -and $methodData[$m].ContainsKey($Scheme)) {
            $schemes = @{ $Scheme = $methodData[$m][$Scheme] }
        }
        elseif ($Scheme) {
            continue
        }
        else {
            $schemes = $methodData[$m]
        }

        foreach ($s in $schemes.Keys) {
            $claims = $schemes[$s] | Where-Object { $_.Least }

            # If no explicit least privileged permissions, check if there's only one permission total
            if (-not $claims) {
                $allClaims = $schemes[$s]
                if ($allClaims.Count -eq 1) {
                    # Only one permission exists, treat it as implicitly least privileged
                    $claims = $allClaims
                }
                elseif ($allClaims.Count -eq 2) {
                    # Exactly 2 permissions - check if they follow X.Read.All and X.ReadWrite.All pattern
                    $permissions = $allClaims | Select-Object -ExpandProperty Permission
                    $readPerm = $permissions | Where-Object { $_ -match '^(.+)\.Read\.All$' }
                    $readWritePerm = $permissions | Where-Object { $_ -match '^(.+)\.ReadWrite\.All$' }

                    if ($readPerm -and $readWritePerm) {
                        # Extract prefix before .Read.All and .ReadWrite.All
                        $readPerm -match '^(.+)\.Read\.All$' | Out-Null
                        $readPrefix = $Matches[1]
                        
                        $readWritePerm -match '^(.+)\.ReadWrite\.All$' | Out-Null
                        $readWritePrefix = $Matches[1]

                        # If prefixes match, the Read permission is less privileged
                        if ($readPrefix -eq $readWritePrefix) {
                            $claims = $allClaims | Where-Object { $_.Permission -eq $readPerm }
                        }
                    }

                    # If pattern didn't match, fall through to path count logic below
                    if (-not $claims) {
                        # Multiple permissions exist with no explicit least privilege marking
                        # Select the permission(s) with the fewest paths (least scope)
                        $permissionPathCounts = @{}
                        
                        foreach ($claim in $allClaims) {
                            $permName = $claim.Permission
                            if (-not $permissionPathCounts.ContainsKey($permName)) {
                                # Count paths for this permission across the entire permissions data
                                $pathCount = 0
                                if ($script:PermissionsData -and $script:PermissionsData.permissions.ContainsKey($permName)) {
                                    $permData = $script:PermissionsData.permissions[$permName]
                                    foreach ($pathSet in $permData.pathSets) {
                                        $pathCount += $pathSet.paths.Count
                                    }
                                }
                                $permissionPathCounts[$permName] = $pathCount
                            }
                        }
                        
                        # Find minimum path count
                        $minPathCount = ($permissionPathCounts.Values | Measure-Object -Minimum).Minimum
                        
                        # Select all permissions with the minimum path count
                        $leastBroadPermissions = $permissionPathCounts.GetEnumerator() |
                        Where-Object { $_.Value -eq $minPathCount } |
                        Select-Object -ExpandProperty Key

                        # Filter claims to only include those with minimum path count
                        $claims = $allClaims | Where-Object { $_.Permission -in $leastBroadPermissions }
                    }
                }
                elseif ($allClaims.Count -gt 2) {
                    # Multiple permissions exist with no explicit least privilege marking
                    # First, filter out ReadWrite permissions when corresponding Read permissions exist
                    $permissions = $allClaims | Select-Object -ExpandProperty Permission
                    $readPermissions = $permissions | Where-Object { $_ -match '^(.+)\.Read\.All$' }
                    $filteredClaims = $allClaims | Where-Object {
                        $perm = $_.Permission
                        # Keep if it's not a ReadWrite permission, or if it is but no Read equivalent exists
                        if ($perm -match '^(.+)\.ReadWrite\.All$') {
                            $prefix = $Matches[1]
                            $readEquivalent = "$prefix.Read.All"
                            # Exclude if Read version exists
                            return $readEquivalent -notin $readPermissions
                        }
                        return $true
                    }
                    
                    Write-Verbose "Filtered from $($allClaims.Count) to $($filteredClaims.Count) permissions after removing ReadWrite variants"
                    
                    # Select the permission(s) with the fewest paths (least scope)
                    $permissionPathCounts = @{}
                    
                    foreach ($claim in $filteredClaims) {
                        $permName = $claim.Permission
                        if (-not $permissionPathCounts.ContainsKey($permName)) {
                            # Count paths for this permission across the entire permissions data
                            $pathCount = 0
                            if ($script:PermissionsData -and $script:PermissionsData.permissions.ContainsKey($permName)) {
                                $permData = $script:PermissionsData.permissions[$permName]
                                foreach ($pathSet in $permData.pathSets) {
                                    $pathCount += $pathSet.paths.Count
                                }
                            }
                            $permissionPathCounts[$permName] = $pathCount
                            Write-Verbose "$permName has $pathCount total paths"
                        }
                    }
                    
                    # Find minimum path count
                    $minPathCount = ($permissionPathCounts.Values | Measure-Object -Minimum).Minimum
                    Write-Verbose "Minimum path count is $minPathCount"
                    
                    # Select all permissions with the minimum path count
                    $leastBroadPermissions = $permissionPathCounts.GetEnumerator() |
                    Where-Object { $_.Value -eq $minPathCount } |
                    Select-Object -ExpandProperty Key

                    # Filter claims to only include those with minimum path count
                    $claims = $filteredClaims | Where-Object { $_.Permission -in $leastBroadPermissions }
                }
            }
            
            if ($claims) {
                $disambiguated = Invoke-Disambiguate -Resource $Resource -Method $m -Scheme $s -Claims $claims
                
                if (-not $result.ContainsKey($m)) {
                    $result[$m] = @{}
                }
                if (-not $result[$m].ContainsKey($s)) {
                    $result[$m][$s] = [System.Collections.Generic.List[string]]::new()
                }
                
                foreach ($perm in $disambiguated) {
                    $result[$m][$s].Add($perm)
                }
            }
        }
    }

    return $result
}


function Get-SupportedMethods {
    <#
    .SYNOPSIS
        Determines which HTTP methods a permission supports for a given resource and scheme.
 
    .DESCRIPTION
        Internal helper function that searches through a resource's method/scheme structure
        to identify all HTTP methods where a specific permission claim is valid. This is
        used during disambiguation to understand the scope of a permission's applicability.
 
        For example, if Mail.Read supports both GET and POST methods but Mail.ReadBasic
        only supports GET, this function helps identify those differences.
 
    .PARAMETER Resource
        The ProtectedResource object containing all permissions organized by method and scheme.
 
    .PARAMETER Claim
        The claim object to search for, containing at minimum a Permission property.
 
    .PARAMETER Scheme
        The authentication scheme to check within (DelegatedWork, DelegatedPersonal, Application).
 
    .OUTPUTS
        String[]
        Returns an array of HTTP method names (GET, POST, etc.) where the claim is valid
        for the specified scheme. Returns an empty array if the permission is not found.
 
    .EXAMPLE
        Get-SupportedMethods -Resource $resource -Claim $claim -Scheme 'DelegatedWork'
         
        Returns array of HTTP methods supported by this permission.
 
    .NOTES
        This is an internal function not exported from the module. It's used by
        Invoke-Disambiguate to help select between multiple least privileged options
        based on their method coverage.
 
    .EXAMPLE
        Get-SupportedMethods -Resource $resource -Claim $claim -Scheme 'DelegatedWork'
        # Returns array of HTTP methods supported by this permission
    #>

    param(
        [Parameter(Mandatory)]
        $Resource,
        [Parameter(Mandatory)]
        $Claim,
        [Parameter(Mandatory)]
        [string]$Scheme
    )
    
    $methods = [System.Collections.Generic.List[string]]::new()
    foreach ($m in $Resource.Methods.Keys) {
        if ($Resource.Methods[$m].ContainsKey($Scheme)) {
            $schemeClaims = $Resource.Methods[$m][$Scheme]
            if ($schemeClaims | Where-Object { $_.Permission -eq $Claim.Permission }) {
                $methods.Add($m)
            }
        }
    }
    return $methods.ToArray()
}


function Invoke-Disambiguate {
    <#
    .SYNOPSIS
        Selects the most appropriate permission when multiple least privileged options exist.
 
    .DESCRIPTION
        Internal function that implements disambiguation logic to choose between multiple
        permissions that are all marked as least privileged for a given endpoint. This
        handles edge cases in the Microsoft Graph permissions metadata where multiple
        permissions are equally valid.
 
        The disambiguation algorithm uses a three-tier strategy:
         
        1. Single Option: If only one claim exists, return it immediately
         
        2. Exclusive Method Match: Prefer permissions that only work for the specific
           HTTP method being queried (most specific scope)
            
        3. Narrowest Scope: Choose the permission that supports the fewest total methods
           across the resource (most restrictive permission)
            
        4. First Match Fallback: If all else fails, return the first claim
         
        This ensures that when multiple "least privileged" options exist, the most
        contextually appropriate one is selected based on the specific method being used.
 
    .PARAMETER Resource
        The ProtectedResource object containing all permissions for the endpoint.
 
    .PARAMETER Method
        The HTTP method being queried (GET, POST, PUT, PATCH, DELETE, etc.).
 
    .PARAMETER Scheme
        The authentication scheme (DelegatedWork, DelegatedPersonal, Application).
 
    .PARAMETER Claims
        Array of claim objects that are all marked as least privileged and need
        to be disambiguated.
 
    .OUTPUTS
        String[]
        Returns an array containing a single formatted permission string (may include
        "and" clauses if the permission has dependencies via AlsoRequires).
 
    .NOTES
        This is an internal function not exported from the module. It's called by
        Get-ResourceLeastPrivileged when multiple least privileged permissions exist.
         
        The algorithm prioritizes context-specific permissions (exclusive to the method)
        over broader permissions that work across multiple methods, following the
        principle of least privilege most strictly.
 
    .EXAMPLE
        Invoke-Disambiguate -Resource $resource -Method 'GET' -Scheme 'DelegatedWork' -Claims $claims
         
        Selects the most appropriate permission from multiple least privileged candidates.
    #>

    param(
        [Parameter(Mandatory)]
        $Resource,
        [Parameter(Mandatory)]
        [string]$Method,
        [Parameter(Mandatory)]
        [string]$Scheme,
        [Parameter(Mandatory)]
        [array]$Claims
    )
    
    if ($Claims.Count -eq 1) {
        return @(Format-PermissionString -Claim $Claims[0])
    }

    # Try to find permissions exclusive to this method
    foreach ($claim in $Claims) {
        $supportedMethods = Get-SupportedMethods -Resource $Resource -Claim $claim -Scheme $Scheme
        if ($supportedMethods.Count -eq 1 -and $supportedMethods[0] -eq $Method) {
            return @(Format-PermissionString -Claim $claim)
        }
    }

    # Find permission with fewest supported methods (narrowest scope)
    $minMethodCount = [int]::MaxValue
    $bestClaim = $null

    foreach ($claim in $Claims) {
        $supportedMethods = Get-SupportedMethods -Resource $Resource -Claim $claim -Scheme $Scheme
        if ($supportedMethods.Count -lt $minMethodCount) {
            $minMethodCount = $supportedMethods.Count
            $bestClaim = $claim
        }
    }

    if ($bestClaim) {
        return @(Format-PermissionString -Claim $bestClaim)
    }

    return @(Format-PermissionString -Claim $Claims[0])
}


function New-AcceptableClaim {
    <#
    .SYNOPSIS
        Creates a new AcceptableClaim object representing a permission claim.
 
    .DESCRIPTION
        Internal factory function that creates a standardized PSCustomObject representing
        a permission claim with all necessary metadata. The claim object tracks:
        - The permission name
        - Whether it's marked as least privileged
        - Any additional required permissions (dependencies)
        - The authentication scheme it applies to
         
        The object includes a custom type name (GraphPermissions.AcceptableClaim) for
        type identification and potential future formatting customization.
 
    .PARAMETER Permission
        The name of the Graph API permission (e.g., "Mail.Read", "User.ReadBasic.All").
 
    .PARAMETER Least
        Boolean indicating whether this permission is explicitly marked as least privileged
        in the Microsoft Graph metadata.
 
    .PARAMETER AlsoRequires
        Array of additional permission names that must be granted alongside this permission
        for it to function. Most permissions don't have dependencies and will be empty.
 
    .PARAMETER Scheme
        The authentication scheme this claim applies to (DelegatedWork, DelegatedPersonal, Application).
 
    .OUTPUTS
        PSCustomObject
        Returns a claim object with properties: Permission, Least, AlsoRequires, Scheme, and PSTypeName.
 
    .EXAMPLE
        New-AcceptableClaim -Permission 'Mail.Read' -Least $true -AlsoRequires @() -Scheme 'DelegatedWork'
         
        Creates a new permission claim object with the specified properties.
        New-AcceptableClaim -Permission 'Mail.Read' -Least $true -AlsoRequires @() -Scheme 'DelegatedWork'
        # Creates a new claim object for a least privileged permission
 
    .NOTES
        This is an internal function not exported from the module. It's used during the
        permissions index building process to create standardized claim objects.
    #>

    param(
        [string]$Permission,
        [bool]$Least,
        [string[]]$AlsoRequires,
        [string]$Scheme
    )
    
    return [PSCustomObject]@{
        Permission   = $Permission
        Least        = $Least
        AlsoRequires = $AlsoRequires
        Scheme       = $Scheme
        PSTypeName   = 'GraphPermissions.AcceptableClaim'
    }
}


function New-ProtectedResource {
    <#
    .SYNOPSIS
        Creates a new ProtectedResource object representing a Graph API endpoint.
 
    .DESCRIPTION
        Internal factory function that creates a standardized PSCustomObject representing
        a Microsoft Graph API resource (endpoint). The resource object serves as a container
        for organizing all permissions associated with an API path.
         
        The Methods property is initialized as an empty hashtable that will be populated
        with nested structures: Method -> Scheme -> Claims during the index building process.
         
        The object includes a custom type name (GraphPermissions.ProtectedResource) for
        type identification and potential future formatting customization.
 
    .PARAMETER Path
        The normalized (lowercase) API path for this resource (e.g., "/me/messages", "/users/{id}").
 
    .OUTPUTS
        PSCustomObject
        Returns a resource object with properties: Path, Methods (empty hashtable), and PSTypeName.
 
    .EXAMPLE
        New-ProtectedResource -Path '/me/messages'
         
        Creates a new resource container for the /me/messages endpoint.
 
    .NOTES
        This is an internal function not exported from the module. It's used by
        Build-PermissionsIndex to create resource containers as API paths are discovered
        in the permissions data.
    #>

    param([string]$Path)
    
    return [PSCustomObject]@{
        Path       = $Path
        Methods    = @{}
        PSTypeName = 'GraphPermissions.ProtectedResource'
    }
}


function Find-GraphLeastPrivilege {
    <#
    .SYNOPSIS
        Finds the least privileged permission(s) required for a Microsoft Graph API endpoint.
 
    .DESCRIPTION
        The Find-GraphLeastPrivilege function identifies the minimal permissions needed to access
        a specific Microsoft Graph API endpoint. It queries the permissions cache (automatically
        initialized if needed) and returns only those permissions explicitly marked as least
        privileged in the Microsoft Graph permissions metadata.
 
        This function helps implement the principle of least privilege by identifying the minimum
        permission scope required for your application to function.
 
    .PARAMETER Path
        The Microsoft Graph API path to query. Path matching is case-insensitive.
        Use {id} placeholders for dynamic segments (e.g., "/users/{id}/messages").
         
        This parameter accepts pipeline input, allowing you to query multiple paths at once.
 
    .PARAMETER Method
        The HTTP method to filter by. Valid values are: GET, POST, PUT, PATCH, DELETE
         
        If not specified, returns least privileged permissions for all available methods on the path.
 
    .PARAMETER Scheme
        The authentication scheme to filter by. Valid values are:
        - DelegatedWork: Delegated permissions for work/school accounts
        - DelegatedPersonal: Delegated permissions for personal Microsoft accounts
        - Application: Application permissions (app-only access)
 
        If not specified, returns least privileged permissions for all available schemes.
 
    .EXAMPLE
        ```
        Find-GraphLeastPrivilege -Path "/me/messages" -Method GET -Scheme DelegatedWork
         
        Returns the least privileged permission needed to read the current user's messages
        using delegated work/school account permissions.
         
        Output:
        Path Method Scheme Permission
        ---- ------ ------ ----------
        /me/messages GET DelegatedWork Mail.ReadBasic
        ```
 
    .EXAMPLE
        ```
        Find-GraphLeastPrivilege -Path "/users/{id}/messages" -Method GET
         
        Returns the least privileged permissions for reading a user's messages across all
        authentication schemes.
         
        Output:
        Path Method Scheme Permission
        ---- ------ ------ ----------
        /users/{id}/messages GET Application Mail.ReadBasic.All
        /users/{id}/messages GET DelegatedWork Mail.ReadBasic
        /users/{id}/messages GET DelegatedPersonal Mail.ReadBasic
        ```
 
    .EXAMPLE
        ```
        Find-GraphLeastPrivilege -Path "/me/messages"
         
        Returns least privileged permissions for all HTTP methods and schemes available
        for the /me/messages endpoint.
        ```
 
    .EXAMPLE
        ```
        "/me/messages", "/me/calendar/events" | Find-GraphLeastPrivilege -Method GET -Scheme DelegatedWork
         
        Demonstrates pipeline usage to query multiple endpoints at once. Returns the least
        privileged delegated work permission for reading messages and calendar events.
         
        Output:
        Path Method Scheme Permission
        ---- ------ ------ ----------
        /me/messages GET DelegatedWork Mail.ReadBasic
        /me/calendar/events GET DelegatedWork Calendars.ReadBasic
        ```
 
    .EXAMPLE
        ```
        $paths = @("/users/{id}", "/groups/{id}", "/applications")
        $paths | Find-GraphLeastPrivilege -Method GET -Scheme Application | Select-Object Path, Permission
         
        Queries multiple paths and displays only the path and permission, useful for
        generating permission requirement documentation.
        ```
 
    .OUTPUTS
        PSCustomObject
        Returns objects with the following properties:
        - Path: The API path queried
        - Method: The HTTP method
        - Scheme: The authentication scheme
        - Permission: The least privileged permission name
 
    .NOTES
        - If a path is not found, a warning is displayed and no output is returned for that path
        - If no least privileged permissions are defined for the specified method/scheme combination,
          a warning is displayed
        - The permissions cache is automatically initialized on first use by calling
          Initialize-GraphPermissions
        - To refresh the permissions data, run: Initialize-GraphPermissions -Force
    .LINK
        https://mynster9361.github.io/MSGraphPermissions/docs/MSGraphPermissions/Find-GraphLeastPrivilege.html
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [ValidateNotNullOrEmpty()]
        [ValidatePattern('^/.*')]
        [string]$Path,

        [ValidateSet('GET', 'POST', 'PUT', 'PATCH', 'DELETE')]
        [string]$Method,

        [ValidateSet('DelegatedWork', 'DelegatedPersonal', 'Application')]
        [string]$Scheme
    )

    begin {
        if (-not $script:PermissionsCache) {
            Initialize-GraphPermissions
        }
    }

    process {
        $normalizedPath = $Path.ToLowerInvariant()

        if (-not $script:PermissionsCache.ContainsKey($normalizedPath)) {
            Write-Warning "Path '$Path' not found in permissions data"
            return
        }

        $resource = $script:PermissionsCache[$normalizedPath]
        $leastPrivileged = Get-ResourceLeastPrivileged -Resource $resource -Method $Method -Scheme $Scheme

        if ($leastPrivileged.Count -eq 0) {
            Write-Warning "No least privileged permissions found for path '$Path'"
            return
        }

        foreach ($m in $leastPrivileged.Keys) {
            foreach ($s in $leastPrivileged[$m].Keys) {
                foreach ($perm in $leastPrivileged[$m][$s]) {
                    [PSCustomObject]@{
                        Path       = $Path
                        Method     = $m
                        Scheme     = $s
                        Permission = $perm
                    }
                }
            }
        }
    }
}


function Find-GraphPath {
    <#
    .SYNOPSIS
        Searches for Microsoft Graph API paths matching a wildcard pattern.
 
    .DESCRIPTION
        The Find-GraphPath function searches through all available Microsoft Graph API paths
        in the permissions cache and returns those that match the specified wildcard pattern.
        This is useful for discovering available endpoints, exploring the API surface, or
        finding related endpoints by naming patterns.
 
        The function performs case-insensitive pattern matching using PowerShell's -like operator,
        supporting standard wildcards (* and ?).
 
    .PARAMETER Pattern
        A wildcard pattern to match against API paths. Pattern matching is case-insensitive.
         
        Supports PowerShell wildcard syntax:
        - * matches zero or more characters
        - ? matches exactly one character
         
        Examples:
        - "*messages*" finds all paths containing "messages"
        - "/me/*" finds all paths under /me
        - "/users/{id}/mail*" finds mail-related endpoints under users
 
    .EXAMPLE
        ```
        Find-GraphPath -Pattern "*messages*"
         
        Finds all API paths containing the word "messages" anywhere in the path.
         
        Output:
        Path Methods
        ---- -------
        /me/messages POST, GET
        /users/{id}/messages POST, GET
        /me/mailfolders/{id}/messages POST, GET
        /chats/{id}/messages POST, GET
        ```
 
    .EXAMPLE
        ```
        Find-GraphPath -Pattern "/me/*"
         
        Finds all API paths directly under the /me endpoint. Returns hundreds of paths
        showing all available operations for the current user context.
        ```
 
    .EXAMPLE
        ```
        Find-GraphPath -Pattern "*accessreviews*"
         
        Discovers all access review-related endpoints across the API.
         
        Output:
        Path Methods
        ---- -------
        /accessreviews POST, GET
        /accessreviews/{id} DELETE, PATCH, GET
        /identitygovernance/accessreviews/definitions POST, GET
        /identitygovernance/accessreviews/policy PATCH, GET
        ```
 
    .EXAMPLE
        ```
        Find-GraphPath -Pattern "/users/{id}/mail*" | Format-Table -AutoSize
         
        Finds all mail-related endpoints for a specific user and formats the output
        as a compact table.
        ```
 
    .EXAMPLE
        ```
        $calendarPaths = Find-GraphPath -Pattern "*calendar*"
        $calendarPaths | Select-Object -First 10
         
        Finds all calendar-related paths and displays the first 10 results.
        ```
 
    .EXAMPLE
        ```
        Find-GraphPath -Pattern "/identitygovernance/lifecycleworkflows/workflows*" |
            Measure-Object | Select-Object -ExpandProperty Count
         
        Counts how many workflow-related endpoints exist under lifecycle workflows.
        ```
 
    .OUTPUTS
        PSCustomObject
        Returns objects with the following properties:
        - Path: The API path that matched the pattern
        - Methods: Comma-separated list of HTTP methods available for this path
 
    .NOTES
        - Pattern matching is case-insensitive
        - The permissions cache is automatically initialized on first use
        - To refresh the permissions data, run: Initialize-GraphPermissions -Force
        - Use wildcards strategically to narrow down results, as some patterns may
          return hundreds of paths (e.g., "/me/*" returns 1000+ paths)
        - The Methods property shows all available HTTP methods; use Find-GraphLeastPrivilege
          to determine required permissions for specific methods
    .LINK
        https://mynster9361.github.io/MSGraphPermissions/docs/MSGraphPermissions/Find-GraphPath.html
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Pattern
    )

    if (-not $script:PermissionsCache) {
        Initialize-GraphPermissions
    }

    $normalizedPattern = $Pattern.ToLowerInvariant()
    
    $script:PermissionsCache.Keys | Where-Object { $_ -like $normalizedPattern } | ForEach-Object {
        [PSCustomObject]@{
            Path    = $_
            Methods = ($script:PermissionsCache[$_].Methods.Keys -join ', ')
        }
    }
}


function Get-GraphPermissions {
    <#
    .SYNOPSIS
        Retrieves all permissions (including non-least privileged) for a Microsoft Graph API endpoint.
 
    .DESCRIPTION
        The Get-GraphPermissions function returns comprehensive permission information for a
        Microsoft Graph API endpoint, including all available permissions regardless of whether
        they are marked as least privileged. This provides a complete view of all permissions
        that can access an endpoint.
 
        Unlike Find-GraphLeastPrivilege which only returns minimal permissions, this function
        shows every permission that grants access, along with indicators showing which are
        least privileged and what additional permissions they may require.
 
        Use this function when you need to:
        - Understand the full permission landscape for an endpoint
        - See what higher-privileged alternatives exist
        - Audit existing permissions against available options
        - Understand permission dependencies (AlsoRequires)
 
    .PARAMETER Path
        The Microsoft Graph API path to query. Path matching is case-insensitive.
        Use {id} placeholders for dynamic segments (e.g., "/users/{id}/messages").
         
        This parameter accepts pipeline input, allowing you to query multiple paths at once.
 
    .PARAMETER Method
        The HTTP method to filter by. Valid values are: GET, POST, PUT, PATCH, DELETE
         
        If not specified, returns permissions for all available methods on the path.
 
    .PARAMETER Scheme
        The authentication scheme to filter by. Valid values are:
        - DelegatedWork: Delegated permissions for work/school accounts
        - DelegatedPersonal: Delegated permissions for personal Microsoft accounts
        - Application: Application permissions (app-only access)
 
        If not specified, returns permissions for all available schemes.
 
    .EXAMPLE
        ```
        Get-GraphPermissions -Path "/users/{id}" -Method GET
         
        Returns all permissions (least privileged and higher) that can be used to read
        a user object, across all authentication schemes.
         
        Output shows IsLeastPrivileged column to identify minimal permissions:
        Path Method Scheme Permission IsLeastPrivileged
        ---- ------ ------ ---------- -----------------
        /users/{id} GET Application User.Read.All False
        /users/{id} GET Application User.ReadBasic.All True
        /users/{id} GET Application Directory.Read.All False
        ```
 
    .EXAMPLE
        ```
        Get-GraphPermissions -Path "/me/messages" -Method GET -Scheme DelegatedWork
         
        Returns all delegated work permissions that can read the current user's messages,
        showing both least privileged and broader permissions.
         
        Output:
        Path Method Scheme Permission IsLeastPrivileged
        ---- ------ ------ ---------- -----------------
        /me/messages GET DelegatedWork Mail.ReadBasic True
        /me/messages GET DelegatedWork Mail.Read False
        /me/messages GET DelegatedWork Mail.ReadWrite False
        ```
 
    .EXAMPLE
        ```
        Get-GraphPermissions -Path "/users/{id}/messages" -Method GET |
            Where-Object { $_.IsLeastPrivileged } |
            Format-Table Permission, Scheme
         
        Gets all permissions for reading user messages, then filters to show only
        the least privileged options across all schemes.
        ```
 
    .EXAMPLE
        ```
        Get-GraphPermissions -Path "/me/calendar/events" -Method POST -Scheme Application |
            Select-Object Permission, IsLeastPrivileged, AlsoRequires
         
        Shows all application permissions that can create calendar events, including
        any additional permissions required (AlsoRequires column).
        ```
 
    .EXAMPLE
        ```
        "/me/messages", "/me/calendar" | Get-GraphPermissions -Method GET |
            Group-Object Permission | Sort-Object Count -Descending
         
        Compares permissions across multiple endpoints to identify which permissions
        grant access to multiple resources.
        ```
 
    .EXAMPLE
        ```
        Get-GraphPermissions -Path "/groups/{id}/members" -Method GET |
            Format-Table Scheme, Permission, IsLeastPrivileged -GroupBy Scheme
         
        Displays permissions grouped by authentication scheme for better readability.
        ```
 
    .OUTPUTS
        PSCustomObject
        Returns objects with the following properties:
        - Path: The API path queried
        - Method: The HTTP method
        - Scheme: The authentication scheme
        - Permission: The permission name
        - IsLeastPrivileged: Boolean indicating if this is a least privileged permission
        - AlsoRequires: Comma-separated list of additional required permissions (usually empty)
 
    .NOTES
        - Returns ALL permissions, not just least privileged ones
        - Use the IsLeastPrivileged property to identify minimal permissions
        - If a path is not found, a warning is displayed and no output is returned
        - The permissions cache is automatically initialized on first use
        - To refresh the permissions data, run: Initialize-GraphPermissions -Force
        - The AlsoRequires property indicates permission dependencies; most permissions
          don't have dependencies and will show an empty string
    .LINK
        https://mynster9361.github.io/MSGraphPermissions/docs/MSGraphPermissions/Get-GraphPermissions.html
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [string]$Path,

        [ValidateSet('GET', 'POST', 'PUT', 'PATCH', 'DELETE')]
        [string]$Method,

        [ValidateSet('DelegatedWork', 'DelegatedPersonal', 'Application')]
        [string]$Scheme
    )

    begin {
        if (-not $script:PermissionsCache) {
            Initialize-GraphPermissions
        }
    }

    process {
        $normalizedPath = $Path.ToLowerInvariant()

        if (-not $script:PermissionsCache.ContainsKey($normalizedPath)) {
            Write-Warning "Path '$Path' not found in permissions data"
            return
        }

        $resource = $script:PermissionsCache[$normalizedPath]

        if ($Method -and $resource.Methods.ContainsKey($Method)) {
            $methodData = @{ $Method = $resource.Methods[$Method] }
        }
        elseif ($Method) {
            return
        }
        else {
            $methodData = $resource.Methods
        }

        foreach ($m in $methodData.Keys) {
            if ($Scheme -and $methodData[$m].ContainsKey($Scheme)) {
                $schemes = @{ $Scheme = $methodData[$m][$Scheme] }
            }
            elseif ($Scheme) {
                continue
            }
            else {
                $schemes = $methodData[$m]
            }

            foreach ($s in $schemes.Keys) {
                foreach ($claim in $schemes[$s]) {
                    [PSCustomObject]@{
                        Path              = $Path
                        Method            = $m
                        Scheme            = $s
                        Permission        = $claim.Permission
                        IsLeastPrivileged = $claim.Least
                        AlsoRequires      = $claim.AlsoRequires -join ', '
                    }
                }
            }
        }
    }
}


function Initialize-GraphPermissions {
    <#
    .SYNOPSIS
        Downloads and initializes the Microsoft Graph permissions cache.
 
    .DESCRIPTION
        The Initialize-GraphPermissions function downloads the latest Microsoft Graph API
        permissions metadata from the official Microsoft Graph GitHub repository and builds
        an in-memory cache for fast lookups.
 
        The permissions data is automatically cached in memory after the first download,
        so subsequent calls are instantaneous. The cache persists for the duration of the
        PowerShell session.
 
        This function is automatically called by other module functions (Find-GraphLeastPrivilege,
        Get-GraphPermissions, Find-GraphPath) if the cache is not already initialized, so you
        typically don't need to call it explicitly unless you want to force a refresh.
 
    .PARAMETER Force
        Forces a fresh download of permissions data from the remote source, even if the
        cache is already populated. Use this to refresh the data with the latest permissions
        from Microsoft Graph.
 
        Without this switch, the function will use existing cached data if available.
 
    .EXAMPLE
        ```
        Initialize-GraphPermissions
         
        Downloads permissions data if not already cached. If data is already in memory,
        does nothing and returns immediately.
        ```
 
    .EXAMPLE
        ```
        Initialize-GraphPermissions -Force
         
        Forces a fresh download of the latest permissions data, replacing any existing cache.
        Use this when you need to ensure you have the most recent permissions metadata.
        ```
 
    .EXAMPLE
        ```
        Initialize-GraphPermissions -Force -Verbose
         
        Forces a refresh and shows detailed progress information about the download
        and indexing process.
        ```
 
    .OUTPUTS
        None
        This function does not return any output. It populates the internal module cache.
 
    .NOTES
        - Downloads from: https://raw.githubusercontent.com/microsoftgraph/microsoft-graph-devx-content/refs/heads/master/permissions/new/permissions.json
        - Data is cached in memory for the current PowerShell session only
        - Cache is automatically initialized by other module functions if needed
        - Use -Force to refresh data without restarting PowerShell
        - Requires internet connectivity to download permissions data
        - The permissions file is typically several MB in size
        - First download may take a few seconds depending on connection speed
        - Cached lookups are instantaneous after initialization
    .LINK
        https://mynster9361.github.io/MSGraphPermissions/docs/MSGraphPermissions/Initialize-GraphPermissions.html
    #>

    [CmdletBinding()]
    param(
        [switch]$Force
    )

    if ($script:PermissionsCache -and -not $Force) {
        Write-Verbose "Using cached permissions data"
        return
    }
    $permissionsUrl = "https://raw.githubusercontent.com/microsoftgraph/microsoft-graph-devx-content/refs/heads/master/permissions/new/permissions.json"

    Write-Verbose "Downloading permissions from $permissionsUrl"
    
    try {
        $response = Invoke-WebRequest -Uri $permissionsUrl -ErrorAction Stop
        $jsonData = $response.Content | ConvertFrom-Json -AsHashtable
        $script:PermissionsData = $jsonData
        $script:PermissionsCache = Build-PermissionsIndex $jsonData
        Write-Verbose "Successfully loaded permissions data with $($script:PermissionsCache.Count) paths"
    }
    catch [System.Net.WebException] {
        throw "Network error downloading permissions. Check your internet connection: $_"
    }
    catch [System.ArgumentException] {
        throw "Invalid JSON format in permissions data: $_"
    }
    catch {
        throw "Failed to download permissions: $_"
    }
}