public/Get-MsecEntraAccessPackage.ps1
|
function Get-MsecEntraAccessPackage { <# .SYNOPSIS Entra entitlement-management access packages with the policy that governs each one - who may request it, whether anyone approves, and whether the access ever ends. .DESCRIPTION The standing-privilege review for entitlement management. One row per access package per assignment policy, because the policy is where the controls live: a package on its own says what access exists, and says nothing about who can get it or for how long. APPROVAL AND EXPIRY ARE DIFFERENT CONTROLS AND ARE EASILY CONFLATED. Approval governs WHO GETS IN. Expiry governs HOW LONG THEY STAY. A package that requires approval and never expires is not time-boxed access that someone signed off - it is permanent access that someone signed off once. Measured on one tenant: all 77 policies required approval, and 69 of them never expired, including 25 packages granting directory administrator roles. Reading only the approval column would have called that fully governed. 'never' IS PRINTED, NOT LEFT BLANK. An empty expiry cell reads as "not configured yet", which invites the reader to assume a default applies. noExpiration IS the configuration and it means permanent, so it is rendered as a value. WHAT A PACKAGE GRANTS IS USUALLY A GROUP, NOT A ROLE. Resource roles come back as AadGroup/Member far more often than as a directory role, so the access the package confers is whatever that group carries - which the package itself does not state. A package named for an administrator role may add the member to a group holding a permanent role assignment, an eligible one, or nothing at all. ResourceRole is reported as what it is; resolving the group's own privilege is Get-MsecEntraRoleHolder's job, and the two are meant to be read together. ResourceRole IS A string[] so a caller can test it exactly with -contains. Joining the entries into one string would force -like '*Administrator*' on every such query, and a substring match happily reports 'Helpdesk Administrator' as a hit for 'Administrator'. A PACKAGE WITH NO POLICY STILL APPEARS, with Policy null and the control columns blank. Such a package cannot be requested by anyone, so it grants nothing - but dropping it from the output would hide a package that exists and could be given a policy tomorrow. Runs as the msec app. Needs 'EntitlementManagement.Read.All'; these endpoints also answer to the Global Reader directory role, so a tenant where that is held may work without the grant - which is a side effect to rely on deliberately, not by accident. .PARAMETER NoExpiry Only policies whose access never expires. .PARAMETER SelfRequestable Only policies any member of the directory may request (allowedTargetScope 'allMemberUsers'), as opposed to a named set of users. .PARAMETER NoApproval Only policies that grant without anyone approving. .PARAMETER Catalog Limit to one or more catalogs by display name. .EXAMPLE Get-MsecEntraAccessPackage -NoExpiry Every package whose access, once granted, is permanent. .EXAMPLE Get-MsecEntraAccessPackage -SelfRequestable -NoApproval Access anyone in the directory can give themselves unprompted. An empty result here is a good answer and worth stating as one. .EXAMPLE Get-MsecEntraAccessPackage -NoExpiry | Where-Object { $_.Package -match 'Administrator' } | Select-Object Catalog, Package, AllowedTargetScope, Approvers Permanent administrator access, and who signed it off. .EXAMPLE Get-MsecEntraAccessPackage | Group-Object ApprovalRequired, Expiration The shape of the estate - the combination is the finding, not either column alone. .OUTPUTS PSCustomObject per package/policy pair, PSTypeName 'MsecEntraAccessPackage'. .NOTES Read-only. Pairs with Get-MsecEntraRoleHolder: this says what a package grants, that says what the granted group actually carries. #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [switch] $NoExpiry, [switch] $SelfRequestable, [switch] $NoApproval, [string[]] $Catalog ) Assert-MsecSession $base = '/v1.0/identityGovernance/entitlementManagement' $get = { param([string] $Path, [string] $What) try { @((Invoke-MsecGraphRequest -Path $Path).value) } catch { if ($_.Exception.Message -match '403|Forbidden') { throw "Forbidden reading $What. The msec app needs 'EntitlementManagement.Read.All' (admin consent required), or the Global Reader directory role. Original error: $($_.Exception.Message)" } throw } } # $expand=catalog, and there is no separate catalogs call: an accessPackage carries NO # catalog id of its own - its properties are id, displayName, uniqueName, description, # isHidden, createdDateTime, modifiedDateTime and nothing else - so the expand is the only # way to name the catalog, and a lookup table built from /catalogs would have nothing to # join on. $packages = & $get "$base/accessPackages?`$expand=catalog" 'access packages' if (-not $packages.Count) { return } # A -Catalog that matches nothing is named along with the catalogues that do exist: an # empty table is indistinguishable from a typo otherwise. if ($Catalog) { $present = @($packages | ForEach-Object { [string]$_.catalog.displayName } | Where-Object { $_ } | Sort-Object -Unique) foreach ($c in @($Catalog | Where-Object { $_ -notin $present })) { Write-Warning ("No catalog named '{0}'. Present: {1}" -f $c, ($present -join ', ')) } } # Policies are fetched once for the whole tenant rather than per package: one call instead # of one per package, and the expand gives the package id to group them back by. $policies = & $get "$base/assignmentPolicies?`$expand=accessPackage" 'assignment policies' $byPackage = @{} foreach ($p in $policies) { $pid = [string]$p.accessPackage.id if (-not $pid) { continue } if (-not $byPackage.ContainsKey($pid)) { $byPackage[$pid] = [System.Collections.Generic.List[object]]::new() } $byPackage[$pid].Add($p) } foreach ($pkg in $packages) { $pkgId = [string]$pkg.id $catName = [string]$pkg.catalog.displayName if ($Catalog -and $catName -notin $Catalog) { continue } # What the package actually confers. Failure here is reported per package rather than # aborting the inventory - one unreadable package must not hide the other 37. # NESTED EXPAND, AND IT IS THE WHOLE POINT. With $expand=role,scope the scope comes back # named "Root" on every package - the scope is the extent WITHIN the resource, not the # resource - so the output says "Root/Member" for all 38 and names nothing. Only # role($expand=resource) reaches the resource itself, which is what a reader needs: # "pim-entra-authentication-administrator/Member", not "Root/Member". $roles = @() try { foreach ($rr in @((Invoke-MsecGraphRequest -Path "$base/accessPackages/$pkgId/resourceRoleScopes?`$expand=role(`$expand=resource),scope").value)) { $rName = [string]$rr.role.displayName $resource = [string]$rr.role.resource.displayName $origin = [string]$rr.role.originSystem $text = (@($resource, $rName) | Where-Object { $_ }) -join '/' if ($origin) { $text = "$text [$origin]" } if ($text.Trim()) { $roles += $text } } } catch { Write-Warning ("Could not read resource roles for '{0}' - ResourceRole is empty on its rows, which is NOT the same as granting nothing: {1}" -f $pkg.displayName, $_.Exception.Message) } # A LIST, NOT @($null). Writing `$x = @($null)` looks like a one-element array holding # null, but PowerShell unwraps a single-element array on assignment, so $x becomes $null # outright - and `foreach ($i in $null)` iterates ZERO times. The effect was that every # package without an assignment policy silently vanished from the output: exactly the # package worth seeing, because nobody can request it and nobody would notice. $pkgPolicies = [System.Collections.Generic.List[object]]::new() if ($byPackage.ContainsKey($pkgId)) { $pkgPolicies.AddRange(@($byPackage[$pkgId])) } else { $pkgPolicies.Add($null) } foreach ($pol in $pkgPolicies) { # noExpiration IS the configuration, so it is rendered as a value. A blank here # would read as "not set yet" and invite the assumption that a default applies. $expiry = if (-not $pol) { $null } elseif ([string]$pol.expiration.type -eq 'noExpiration') { 'never' } elseif ($pol.expiration.duration) { [string]$pol.expiration.duration } elseif ($pol.expiration.endDateTime) { "until $([datetime]$pol.expiration.endDateTime)" } else { [string]$pol.expiration.type } $approval = if ($pol) { [bool]$pol.requestApprovalSettings.isApprovalRequiredForAdd } else { $null } $approvers = @() if ($pol) { foreach ($stage in @($pol.requestApprovalSettings.stages)) { foreach ($a in @($stage.primaryApprovers)) { $n = @($a.displayName, $a.userPrincipalName, $a.groupId, $a.roleId) | Where-Object { $_ } | Select-Object -First 1 if ($n) { $approvers += [string]$n } } } } if ($NoExpiry -and $expiry -ne 'never') { continue } if ($SelfRequestable -and [string]$pol.allowedTargetScope -ne 'allMemberUsers') { continue } if ($NoApproval -and $approval -ne $false) { continue } [PSCustomObject]@{ PSTypeName = 'MsecEntraAccessPackage' Catalog = $catName Package = [string]$pkg.displayName Policy = if ($pol) { [string]$pol.displayName } else { $null } # allMemberUsers = anyone in the directory may ask. specificDirectoryUsers = a # named set. Blank where the package has no policy and so cannot be requested. AllowedTargetScope = if ($pol) { [string]$pol.allowedTargetScope } else { $null } # $null, not $false, where there is no policy - nobody can request it, which is # not the same as it granting without approval. ApprovalRequired = $approval Approvers = @($approvers | Select-Object -Unique) # 'never' is a value. See the description. Expiration = $expiry # string[] so -contains tests it exactly. ResourceRole = @($roles) IsHidden = [bool]$pkg.isHidden PackageId = $pkgId PolicyId = if ($pol) { [string]$pol.id } else { $null } CreatedUtc = if ($pkg.createdDateTime) { [datetime]$pkg.createdDateTime } else { $null } } } } } |