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 } |