Public/IdentityGovernance.ps1

<#
.SYNOPSIS
    Retrieves Access Packages with optional policy and resource details for migration planning.
.DESCRIPTION
    This helper queries Microsoft Graph entitlement management Access Packages, including assignment policies and (optionally)
    resource role scope bindings, so you can produce a repeatable plan for small/medium business tenants.
.PARAMETER CatalogId
    Limits results to a specific catalog. If omitted, all catalogs are returned.
.PARAMETER IncludeResourceRoleScopes
    When set, expands resource role scopes so exports can include resource bindings.
.EXAMPLE
    Get-O365AccessPackagePlan -IncludeResourceRoleScopes
.NOTES
    Requires Graph scopes: EntitlementManagement.Read.All or EntitlementManagement.ReadWrite.All.
#>

function Get-O365AccessPackagePlan {
    [CmdletBinding()]
    param(
        [string]$CatalogId,
        [switch]$IncludeResourceRoleScopes
    )

    # Entra ID PowerShell: Use Get-EntraAccessPackage and related cmdlets
    $params = @{}
    if ($CatalogId) { $params.CatalogId = $CatalogId }
    $packages = Get-EntraAccessPackage @params

    if ($IncludeResourceRoleScopes) {
        foreach ($pkg in $packages) {
            $pkg | Add-Member -MemberType NoteProperty -Name accessPackageResourceRoleScopes -Value (Get-EntraAccessPackageResourceRoleScope -AccessPackageId $pkg.Id)
        }
    }

    foreach ($pkg in $packages) {
        $pkg | Add-Member -MemberType NoteProperty -Name accessPackageAssignmentPolicies -Value (Get-EntraAccessPackageAssignmentPolicy -AccessPackageId $pkg.Id)
    }

    return $packages
}

<#
.SYNOPSIS
    Exports Access Package definitions (and optional resource bindings) to JSON for migration/backup.
.DESCRIPTION
    Builds a portable JSON plan containing Access Package metadata, assignment policies, and optional resource role scopes.
.PARAMETER OutputPath
    Destination path for the JSON export.
.PARAMETER CatalogId
    Limits export to a specific catalog.
.PARAMETER IncludeResourceRoleScopes
    Include resource role scopes (group/app/resource bindings) to rehydrate packages in another tenant.
.EXAMPLE
    Export-O365AccessPackagePlan -OutputPath '.\exports\access-packages.json' -IncludeResourceRoleScopes
.NOTES
    Requires Graph scopes: EntitlementManagement.Read.All or EntitlementManagement.ReadWrite.All.
#>

function Export-O365AccessPackagePlan {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true)]
        [string]$OutputPath,
        [string]$CatalogId,
        [switch]$IncludeResourceRoleScopes
    )

    $packages = Get-O365AccessPackagePlan -CatalogId $CatalogId -IncludeResourceRoleScopes:$IncludeResourceRoleScopes
    $tenant = Get-EntraTenantDetail | Select-Object -First 1

    $payload = [PSCustomObject]@{
        exportedAt     = (Get-Date).ToString("o")
        tenantId       = $tenant.TenantId
        accessPackages = $packages | ForEach-Object {
            $policies = $_.accessPackageAssignmentPolicies | ForEach-Object {
                [PSCustomObject]@{
                    id                      = $_.Id
                    displayName             = $_.DisplayName
                    description             = $_.Description
                    durationInDays          = $_.DurationInDays
                    requestorSettings       = $_.RequestorSettings
                    requestApprovalSettings = $_.RequestApprovalSettings
                    accessReviewSettings    = $_.AccessReviewSettings
                    specificSettings        = $_.SpecificSettings
                }
            }

            $resourceRoleScopes = @()
            if ($IncludeResourceRoleScopes -and $_.accessPackageResourceRoleScopes) {
                $resourceRoleScopes = $_.accessPackageResourceRoleScopes | ForEach-Object {
                    $role  = $_.AccessPackageResourceRole
                    $scope = $_.AccessPackageResourceScope

                    [PSCustomObject]@{
                        roleOriginId       = $role.OriginId
                        roleDisplayName    = $role.DisplayName
                        resourceId         = $role.ResourceId
                        resourceOriginId   = $scope.OriginId
                        resourceOriginType = $scope.OriginSystem
                        scopeId            = $scope.Id
                        scopeDisplayName   = $scope.DisplayName
                        scopeOriginId      = $scope.OriginId
                        scopeOriginSystem  = $scope.OriginSystem
                    }
                }
            }

            [PSCustomObject]@{
                id                 = $_.Id
                displayName        = $_.DisplayName
                description        = $_.Description
                catalogId          = $_.CatalogId
                state              = $_.State
                isHidden           = $_.IsHidden
                assignmentPolicies = $policies
                resourceRoleScopes = $resourceRoleScopes
            }
        }
    }

    $outputDir = Split-Path -Path $OutputPath -Parent
    if ($outputDir -and -not (Test-Path -Path $outputDir)) {
        New-Item -ItemType Directory -Path $outputDir | Out-Null
    }

    $json = $payload | ConvertTo-Json -Depth 20
    $json | Out-File -FilePath $OutputPath -Encoding utf8
    return $OutputPath
}

<#
.SYNOPSIS
    Imports Access Packages (and optionally resource bindings) from an export plan.
.DESCRIPTION
    Replays an Access Package plan into the current tenant. Packages are created (or reused), assignment policies are applied,
    and resource role scopes are reattached when the target catalog already has matching resources (with optional mapping).
.PARAMETER Path
    Path to a JSON export created by Export-O365AccessPackagePlan.
.PARAMETER ResourceMap
    Optional hashtable mapping source resource originIds (e.g., group objectIds) to target originIds when migrating across tenants.
.PARAMETER SkipExistingPackages
    When set, reuses existing packages that match by displayName instead of creating new ones.
.PARAMETER SkipResourceRoleScopes
    Skip recreating resource role scopes (creates only packages and assignment policies).
.EXAMPLE
    Import-O365AccessPackagePlan -Path '.\exports\access-packages.json' -SkipExistingPackages -WhatIf
.NOTES
    Requires Graph scopes: EntitlementManagement.ReadWrite.All.
#>

function Import-O365AccessPackagePlan {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory=$true)]
        [string]$Path,
        [hashtable]$ResourceMap,
        [switch]$SkipExistingPackages,
        [switch]$SkipResourceRoleScopes
    )

    if (-not (Test-Path -Path $Path)) {
        Write-Warning "File not found: $Path"
        return
    }

    $plan = Get-Content -Path $Path -Raw | ConvertFrom-Json
    $packages = if ($plan.accessPackages) { $plan.accessPackages } elseif ($plan -is [System.Collections.IEnumerable]) { $plan } else { @($plan) }

    $createdPackages = [System.Collections.Generic.List[Object]]::new()

    function Resolve-CatalogResource {
        param(
            [string]$CatalogId,
            [string]$OriginId
        )
        Get-EntraAccessPackageResource -CatalogId $CatalogId | Where-Object { $_.OriginId -eq $OriginId } | Select-Object -First 1
    }

    function Resolve-ResourceRole {
        param(
            [string]$CatalogId,
            [string]$ResourceId,
            [string]$RoleOriginId
        )
        Get-EntraAccessPackageResourceRole -CatalogId $CatalogId -ResourceId $ResourceId | Where-Object { $_.OriginId -eq $RoleOriginId } | Select-Object -First 1
    }

    function Resolve-ResourceScope {
        param(
            [string]$CatalogId,
            [string]$ResourceId,
            [string]$ScopeOriginId
        )
        Get-EntraAccessPackageResourceScope -CatalogId $CatalogId -ResourceId $ResourceId | Where-Object { $_.OriginId -eq $ScopeOriginId } | Select-Object -First 1
    }

    foreach ($pkg in $packages) {
        $existingPkg = $null
        if ($SkipExistingPackages) {
            $existingPkg = Get-EntraAccessPackage | Where-Object { $_.DisplayName -eq $pkg.displayName } | Select-Object -First 1
        }

        $targetPackageId = $existingPkg.Id
        if (-not $existingPkg) {
            if ($PSCmdlet.ShouldProcess($pkg.displayName, "Create Access Package in catalog $($pkg.catalogId)")) {
                $newPackage = New-EntraAccessPackage -DisplayName $pkg.displayName -Description $pkg.description -CatalogId $pkg.catalogId -IsHidden $pkg.isHidden -State ($pkg.state ? $pkg.state : "published")
                $targetPackageId = $newPackage.Id
                $createdPackages.Add([PSCustomObject]@{
                    DisplayName = $newPackage.DisplayName
                    Id          = $newPackage.Id
                    CatalogId   = $newPackage.CatalogId
                })
            }
        }
        else {
            Write-Verbose "Using existing Access Package '$($existingPkg.DisplayName)' ($($existingPkg.Id))"
        }

        if (-not $targetPackageId) {
            Write-Warning "Skipping package '$($pkg.displayName)' because a target package id could not be resolved."
            continue
        }

        foreach ($policy in $pkg.assignmentPolicies) {
            if ($PSCmdlet.ShouldProcess($policy.displayName, "Create assignment policy on package $targetPackageId")) {
                New-EntraAccessPackageAssignmentPolicy -AccessPackageId $targetPackageId -DisplayName $policy.displayName -Description $policy.description -DurationInDays $policy.durationInDays -RequestorSettings $policy.requestorSettings -RequestApprovalSettings $policy.requestApprovalSettings -AccessReviewSettings $policy.accessReviewSettings -SpecificSettings $policy.specificSettings | Out-Null
            }
        }

        if (-not $SkipResourceRoleScopes -and $pkg.resourceRoleScopes) {
            foreach ($rrs in $pkg.resourceRoleScopes) {
                $resourceOrigin = $rrs.resourceOriginId
                if ($ResourceMap -and $ResourceMap.ContainsKey($resourceOrigin)) {
                    $resourceOrigin = $ResourceMap[$resourceOrigin]
                }

                $resource = Resolve-CatalogResource -CatalogId $pkg.catalogId -OriginId $resourceOrigin
                if (-not $resource) {
                    Write-Warning "Resource '$($rrs.resourceOriginId)' not found in catalog $($pkg.catalogId); skipping binding '$($rrs.roleDisplayName)'."
                    continue
                }

                $role = Resolve-ResourceRole -CatalogId $pkg.catalogId -ResourceId $resource.Id -RoleOriginId $rrs.roleOriginId
                if (-not $role) {
                    Write-Warning "Role '$($rrs.roleOriginId)' not found for resource '$($resourceOrigin)'; skipping binding."
                    continue
                }

                $scope = Resolve-ResourceScope -CatalogId $pkg.catalogId -ResourceId $resource.Id -ScopeOriginId $rrs.scopeOriginId
                if (-not $scope) {
                    Write-Warning "Scope '$($rrs.scopeOriginId)' not found for resource '$($resourceOrigin)'; skipping binding."
                    continue
                }

                if ($PSCmdlet.ShouldProcess("$($rrs.roleDisplayName) -> $($rrs.scopeDisplayName)", "Attach resource to Access Package")) {
                    New-EntraAccessPackageResourceRoleScope -AccessPackageId $targetPackageId -AccessPackageResourceRoleId $role.Id -AccessPackageResourceScopeId $scope.Id | Out-Null
                }
            }
        }
    }

    return $createdPackages
}

<#
.SYNOPSIS
    Exports Entra (Azure AD) directory role assignments for migration.
.DESCRIPTION
    Captures current directory role assignments (principals, roles, and scopes) into a JSON plan so SMB tenants can rebuild or audit admin permissions.
.PARAMETER OutputPath
    Destination path for the JSON export.
.EXAMPLE
    Export-O365EntraPermissionsPlan -OutputPath '.\exports\entra-permissions.json'
.NOTES
    Requires Graph scopes: RoleManagement.Read.Directory or RoleManagement.ReadWrite.Directory.
#>

function Export-O365EntraPermissionsPlan {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true)]
        [string]$OutputPath
    )

    $tenant = Get-EntraTenantDetail | Select-Object -First 1
    $assignments = [System.Collections.Generic.List[Object]]::new()
    $allAssignments = Get-EntraDirectoryRoleAssignment
    foreach ($item in $allAssignments) {
        $roleDef = Get-EntraDirectoryRoleDefinition -Id $item.RoleDefinitionId
        $principal = Get-EntraDirectoryObject -Id $item.PrincipalId
        $assignments.Add([PSCustomObject]@{
            roleDefinitionId     = $item.RoleDefinitionId
            roleDisplayName      = $roleDef.DisplayName
            principalId          = $item.PrincipalId
            principalDisplayName = $principal.DisplayName
            principalType        = $principal.ObjectType
            directoryScopeId     = $item.DirectoryScopeId
        })
    }

    $payload = [PSCustomObject]@{
        exportedAt      = (Get-Date).ToString("o")
        tenantId        = $tenant.TenantId
        roleAssignments = $assignments
    }

    $outputDir = Split-Path -Path $OutputPath -Parent
    if ($outputDir -and -not (Test-Path -Path $outputDir)) {
        New-Item -ItemType Directory -Path $outputDir | Out-Null
    }

    $json = $payload | ConvertTo-Json -Depth 10
    $json | Out-File -FilePath $OutputPath -Encoding utf8
    return $OutputPath
}

<#
.SYNOPSIS
    Replays an Entra permissions plan to recreate directory role assignments.
.DESCRIPTION
    Reads a JSON export of directory role assignments and reassigns roles to mapped principals. Useful for SMB tenants moving to a new tenant or rebuilding a clean permissions model.
.PARAMETER Path
    Path to the JSON plan created by Export-O365EntraPermissionsPlan.
.PARAMETER PrincipalMap
    Optional hashtable mapping source principals (Id or DisplayName) to target principal Ids or UPNs.
.PARAMETER SkipExisting
    Skip creating assignments that already exist (same principal, roleDefinitionId, and scope).
.EXAMPLE
    Invoke-O365EntraPermissionsMigration -Path '.\exports\entra-permissions.json' -PrincipalMap @{ 'admin@oldtenant.com' = 'admin@newtenant.com' } -SkipExisting -WhatIf
.NOTES
    Requires Graph scopes: RoleManagement.ReadWrite.Directory.
#>

function Invoke-O365EntraPermissionsMigration {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory=$true)]
        [string]$Path,
        [hashtable]$PrincipalMap,
        [switch]$SkipExisting
    )

    if (-not (Test-Path -Path $Path)) {
        Write-Warning "File not found: $Path"
        return
    }

    $plan = Get-Content -Path $Path -Raw | ConvertFrom-Json
    $assignments = if ($plan.roleAssignments) { $plan.roleAssignments } else { @() }

    $existingAssignments = @()
    if ($SkipExisting) {
        $existingAssignments = Get-EntraDirectoryRoleAssignment
    }

    foreach ($item in $assignments) {
        $targetPrincipalId = $item.principalId

        if ($PrincipalMap) {
            foreach ($key in @($item.principalId, $item.principalDisplayName)) {
                if ($PrincipalMap.ContainsKey($key)) {
                    $mappedValue = $PrincipalMap[$key]
                    if ($mappedValue -match '@') {
                        $user = Get-EntraUser -UserPrincipalName $mappedValue
                        if ($user) {
                            $targetPrincipalId = $user.Id
                        }
                    }
                    else {
                        $targetPrincipalId = $mappedValue
                    }
                    break
                }
            }
        }

        if (-not $targetPrincipalId) {
            Write-Warning "Skipping role '$($item.roleDisplayName)' because the principal could not be resolved."
            continue
        }

        if ($SkipExisting -and ($existingAssignments | Where-Object {
            $_.PrincipalId -eq $targetPrincipalId -and
            $_.RoleDefinitionId -eq $item.roleDefinitionId -and
            $_.DirectoryScopeId -eq $item.directoryScopeId
        })) {
            Write-Verbose "Role assignment already exists for $targetPrincipalId and role $($item.roleDefinitionId); skipping."
            continue
        }

        if ($PSCmdlet.ShouldProcess("$($item.roleDisplayName) -> $targetPrincipalId", "Create directory role assignment")) {
            New-EntraDirectoryRoleAssignment -PrincipalId $targetPrincipalId -RoleDefinitionId $item.roleDefinitionId -DirectoryScopeId ($item.directoryScopeId ? $item.directoryScopeId : "/") | Out-Null
        }
    }
}

<#
.SYNOPSIS
    Creates a guided, one-click permission request when an action is denied.
.DESCRIPTION
    Surfaces what the caller currently has, what is missing, proposes the least-privileged directory role, and can send a pre-filled email/ticket request (approvals or helpdesk) in one go.
.PARAMETER RequestedAction
    Friendly description of what you attempted (e.g., "enable a Conditional Access policy").
.PARAMETER RequiredGraphScopes
    Graph scopes needed for the action (e.g., @('EntitlementManagement.ReadWrite.All')).
.PARAMETER RequiredRoleNames
    Directory roles needed (display names) to satisfy the request.
.PARAMETER ApproverEmail
    Email for the approver or approvals queue (Teams Approvals mailbox works).
.PARAMETER TicketEmail
    Optional email for the ticketing system to auto-open an incident/request.
.PARAMETER SendRequest
    If set, sends the composed request email automatically.
.EXAMPLE
    Invoke-O365PermissionRescue -RequestedAction "import access packages" -RequiredGraphScopes EntitlementManagement.ReadWrite.All -RequiredRoleNames "Identity Governance Administrator" -ApproverEmail "approvals@contoso.com" -TicketEmail "helpdesk@contoso.com" -SendRequest -WhatIf
.NOTES
    Requires Graph permissions to query the signed-in user and directory roles (Directory.Read.All minimum). Uses ShouldProcess around outbound mail.
#>

function Invoke-O365PermissionRescue {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory=$true)]
        [string]$RequestedAction,
        [string[]]$RequiredGraphScopes,
        [string[]]$RequiredRoleNames,
        [string]$ApproverEmail,
        [string]$TicketEmail,
        [switch]$SendRequest
    )

    function Get-LowestPrivilegeRoleSuggestion {
        param([string[]]$Scopes, [string[]]$RoleNames)

        $map = @{
            'Policy.ReadWrite.ConditionalAccess' = 'Conditional Access Administrator'
            'Policy.Read.All'                    = 'Security Reader'
            'EntitlementManagement.ReadWrite.All'= 'Identity Governance Administrator'
            'RoleManagement.ReadWrite.Directory' = 'Privileged Role Administrator'
            'Directory.Read.All'                 = 'Directory Readers'
        }

        foreach ($scope in $Scopes) {
            if ($map.ContainsKey($scope)) {
                return $map[$scope]
            }
        }

        if ($RoleNames -and $RoleNames.Count -gt 0) {
            return $RoleNames[0]
        }

        return "Custom role with only the required Graph scopes"
    }

    $me  = Get-EntraUser -Current
    $currentRoles = Get-EntraUserDirectoryRole -UserId $me.Id
    $currentRoleNames = $currentRoles | Select-Object -ExpandProperty DisplayName

    $missingScopes = @()
    if ($RequiredGraphScopes) {
        # Entra module does not expose scopes directly; placeholder for future scope checks
        $missingScopes = $RequiredGraphScopes
    }

    $missingRoles = @()
    if ($RequiredRoleNames) {
        $missingRoles = $RequiredRoleNames | Where-Object { $currentRoleNames -notcontains $_ }
    }

    $suggestedRole = Get-LowestPrivilegeRoleSuggestion -Scopes $missingScopes -RoleNames $missingRoles

    $report = [PSCustomObject]@{
        RequestedAction       = $RequestedAction
        CurrentScopes         = $null # Not available in Entra module
        MissingGraphScopes    = $missingScopes
        CurrentDirectoryRoles = $currentRoleNames
        MissingDirectoryRoles = $missingRoles
        ProposedLeastPrivilegeRole = $suggestedRole
    }

    if ($SendRequest -and -not $ApproverEmail -and -not $TicketEmail) {
        Write-Warning "SendRequest specified but no ApproverEmail or TicketEmail provided. Skipping mail."
        return $report
    }

    if ($SendRequest) {
        $toList = @()
        if ($ApproverEmail) { $toList += $ApproverEmail }
        if ($TicketEmail)   { $toList += $TicketEmail }

        $lines = @(
            "Heads-up! I tried to $RequestedAction and got blocked.",
            "",
            "Who am I: $($me.DisplayName) <$($me.UserPrincipalName)>",
            "What I have: " + ($(if ($currentRoleNames) { $currentRoleNames -join ', ' } else { 'no directory roles' })),
            "What I need: " + ($(if ($missingRoles) { $missingRoles -join ', ' } else { 'directory role not specified' })),
            "Graph scopes missing: " + ($(if ($missingScopes) { $missingScopes -join ', ' } else { 'unknown/none provided' })),
            "Least-privilege proposal: $suggestedRole",
            "",
            "Approve via Teams Approvals or the ticket system to keep the quest moving. :)"
        )

        $bodyText = $lines -join "`n"
        # Entra module does not support direct mail send; placeholder for integration
        Write-Host "Permission request email would be sent to: $($toList -join ', ')"
        Write-Host $bodyText
    }

    return $report
}