Public/Get-NSPConditionalAccessInventory.ps1
|
function Get-NSPConditionalAccessInventory { <# .SYNOPSIS Read-only snapshot of a tenant's Conditional Access state and the directory objects policies depend on. .DESCRIPTION Makes GET requests only. Requires a session signed in as this tool's own app, from Connect-NSPConditionalAccessGraph. Any other session is refused, even one with the right scopes. Captures: - organization identity and hybrid state (onPremisesSyncEnabled); - every Conditional Access policy and named location; - security groups, with sync state (used to bind group slots and spot duplicate names); - breakglass candidates by name (display name or UPN starting with 'breakglass'); - every user a policy includes or excludes by ID, resolved, with the policies that reference it, sorted by how many policies exclude it (NotFound if since deleted); - group and active role memberships (transitive) of breakglass candidates and referenced users, so the preflight can evaluate group- and role-scoped policies; - custom authentication strengths, the Temporary Access Pass method settings, whether Security Defaults is on, and the licensed service plans; - directory sync accounts (members of either directory-sync role); - holders of the admin roles NSP-ADMIN-MFA-01 targets (active, tenant-wide; directly or through a role-assignable group), with their memberships; - directory role templates, cross-checked against the module's role table; - service principals for each application name the definitions reference. The result's Directory property is the -Directory input for Resolve-NSPConditionalAccessPolicy. Only names matching exactly one service principal are included; ambiguous and missing names are listed separately. -Save writes the snapshot under .nsp-conditionalaccess\inventory\<TenantId>\ (Git-ignored). It contains client identifiers and must never be committed. .EXAMPLE Connect-NSPConditionalAccessGraph -Tenant 'contoso.onmicrosoft.com' $inventory = Get-NSPConditionalAccessInventory -Save #> [CmdletBinding()] param( [string[]]$ApplicationName, [switch]$Save, [string]$ConfigRoot ) Import-NSPM365Graph $context = Assert-NSPConditionalAccessSession -ConfigRoot $ConfigRoot if (-not $PSBoundParameters.ContainsKey('ApplicationName')) { $ApplicationName = @(Get-NSPConditionalAccessDefinition | ForEach-Object { foreach ($section in 'Include', 'Exclude') { if ($_['Applications'] -is [System.Collections.IDictionary]) { $_['Applications'][$section] } } } | Where-Object { $_ -match '^\{App:(.+)\}$' } | ForEach-Object { $Matches[1] } | Sort-Object -Unique) } function Get-Query { param([string]$Path, [string]$Filter, [string]$Select) $query = @() if ($Filter) { $query += "`$filter=$([uri]::EscapeDataString($Filter))" } if ($Select) { $query += "`$select=$Select" } if ($query) { "$Path`?$($query -join '&')" } else { $Path } } function Show-Step { param([int]$Number, [string]$Text) Write-NSPConditionalAccessProgress -Activity Inventory -Status $Text -Step $Number -Of 14 } Show-Step 1 'Tenant identity' $organization = @((Invoke-NSPGraphRequest -Uri (Get-Query 'organization' -Select 'id,displayName,verifiedDomains,onPremisesSyncEnabled,countryLetterCode'))['value'])[0] if ($organization['id'] -ne $context.TenantId) { throw "Organization $($organization['id']) does not match the session tenant $($context.TenantId)." } $defaultDomain = @($organization['verifiedDomains'] | Where-Object { $_ -and $_['isDefault'] })[0] Show-Step 2 'Conditional Access policies' $policies = @(Invoke-NSPGraphCollection -Uri 'identity/conditionalAccess/policies') Show-Step 3 "Named locations ($($policies.Count) policies read)" $namedLocations = @(Invoke-NSPGraphCollection -Uri 'identity/conditionalAccess/namedLocations') Show-Step 4 'Security groups' $groups = @(Invoke-NSPGraphCollection -Uri (Get-Query 'groups' "securityEnabled eq true" 'id,displayName,onPremisesSyncEnabled,mailEnabled,securityEnabled,groupTypes')) Show-Step 5 "Breakglass accounts ($($groups.Count) groups read)" $breakGlass = @(Invoke-NSPGraphCollection -Uri (Get-Query 'users' "startswith(displayName,'breakglass') or startswith(userPrincipalName,'breakglass')" 'id,displayName,userPrincipalName,accountEnabled,onPremisesSyncEnabled')) Show-Step 6 'Directory roles' $roleTemplates = @(Invoke-NSPGraphCollection -Uri (Get-Query 'directoryRoleTemplates' -Select 'id,displayName')) Show-Step 7 'Authentication strengths' $strengths =@(Invoke-NSPGraphCollection -Uri 'policies/authenticationStrengthPolicies' | Where-Object { $_['policyType'] -eq 'custom' } | ForEach-Object { [ordered]@{ id = $_['id']; displayName = $_['displayName']; policyType = $_['policyType']; allowedCombinations = @($_['allowedCombinations']) } }) # The Temporary Access Pass method settings, for the TAP-only registration policies. A read # failure is recorded, not thrown: the preflight reports it as unverified. Show-Step 8 'Temporary Access Pass settings' $tapMethod = try { $tap = Invoke-NSPGraphRequest -Uri 'policies/authenticationMethodsPolicy/authenticationMethodConfigurations/TemporaryAccessPass' [ordered]@{ Available = $true State = $tap['state'] IsUsableOnce = $tap['isUsableOnce'] DefaultLifetimeInMinutes = $tap['defaultLifetimeInMinutes'] IncludeTargets = @($tap['includeTargets'] | Where-Object { $_ } | ForEach-Object { [ordered]@{ Id = $_['id']; TargetType = $_['targetType'] } }) } } catch { [ordered]@{ Available = $false; Reason = "$_" } } # Security Defaults: while on, Graph refuses to turn a Conditional Access policy On. Show-Step 9 'Security Defaults' $securityDefaults = try { $defaults = Invoke-NSPGraphRequest -Uri 'policies/identitySecurityDefaultsEnforcementPolicy' [ordered]@{ Available = $true; IsEnabled = [bool]$defaults['isEnabled'] } } catch { [ordered]@{ Available = $false; Reason = "$_" } } # Licenses: the service plans the tenant has, so the preflight can check what a policy needs # (Entra ID P2 for the risk policies). Organization.Read.All covers it. $licenses = try { $skus = @(Invoke-NSPGraphCollection -Uri 'subscribedSkus?$select=skuPartNumber,capabilityStatus,servicePlans') $active = @($skus | Where-Object { $_['capabilityStatus'] -in 'Enabled', 'Warning' }) [ordered]@{ Available = $true Skus = @($active | ForEach-Object { [string]$_['skuPartNumber'] } | Sort-Object -Unique) ServicePlans = @($active | ForEach-Object { @($_['servicePlans']) } | Where-Object { $_['provisioningStatus'] -ne 'Disabled' } | ForEach-Object { [string]$_['servicePlanName'] } | Sort-Object -Unique) } } catch { [ordered]@{ Available = $false; Reason = "$_" } } # Users are resolved once each. A user deleted since a policy referenced it is kept as NotFound: # a stale exclusion is itself a finding, not an error. $userCache = @{} $lookupUser = { param([string]$Id) if ($userCache.ContainsKey($Id)) { return $userCache[$Id] } $record = try { $user = Invoke-NSPGraphRequest -Uri "users/$Id`?`$select=id,displayName,userPrincipalName,accountEnabled,onPremisesSyncEnabled" [pscustomobject]@{ Id = $Id Status = 'Found' DisplayName = $user['displayName'] UserPrincipalName = $user['userPrincipalName'] AccountEnabled = $user['accountEnabled'] Synced = [bool]$user['onPremisesSyncEnabled'] } } catch { if ("$_" -notmatch 'Request_ResourceNotFound|NotFound|404') { throw } [pscustomobject]@{ Id = $Id; Status = 'NotFound'; DisplayName = $null; UserPrincipalName = $null; AccountEnabled = $null; Synced = $null } } $userCache[$Id] = $record $record } # Directory Synchronization Accounts, and On Premises Directory Sync Account for newer Entra Connect. $syncRoles = @{ 'd29b2b05-8046-44ba-8758-1e26182fcf32' = 'Directory Synchronization Accounts' 'a92aed5d-d78a-4d16-b381-09adb37eb3b0' = 'On Premises Directory Sync Account' } Show-Step 10 'Directory sync accounts' $syncAccounts = foreach ($roleId in $syncRoles.Keys | Sort-Object) { $assignments = @(Invoke-NSPGraphCollection -Uri (Get-Query 'roleManagement/directory/roleAssignments' "roleDefinitionId eq '$roleId'" 'principalId,roleDefinitionId')) foreach ($principalId in @($assignments | ForEach-Object { $_['principalId'] } | Sort-Object -Unique)) { $user = & $lookupUser $principalId $user | Select-Object *, @{ Name = 'SyncRole'; Expression = { $syncRoles[$roleId] } } } } $syncIds = @($syncAccounts | ForEach-Object { $_.Id }) # Holders of the roles NSP-ADMIN-MFA-01 targets, so the preflight can check that an enforced # MFA policy reaches each one. A read failure is recorded, not thrown (AdminRolesUnverified). Show-Step 10 'Admin role holders' $adminRoles = try { $holders = Get-NSPConditionalAccessAdminRoleHolder [ordered]@{ Available = $true Users = @(foreach ($id in $holders.Keys | Sort-Object) { & $lookupUser $id | Select-Object *, @{ Name = 'Roles'; Expression = { @($holders[$id].Via) } } }) } } catch { [ordered]@{ Available = $false; Reason = "$_"; Users = @() } } $references = [ordered]@{} $guidPattern = '^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$' foreach ($policy in $policies) { $users = $policy['conditions']['users'] if (-not $users) { continue } foreach ($direction in 'include', 'exclude') { foreach ($id in @($users["$($direction)Users"] | Where-Object { $_ -match $guidPattern })) { if (-not $references.Contains($id)) { $references[$id] = @{ include = [System.Collections.Generic.List[string]]::new(); exclude = [System.Collections.Generic.List[string]]::new() } } $references[$id][$direction].Add($policy['displayName']) } } } $done = 0 $referencedUsers = foreach ($id in $references.Keys) { $done++ Show-Step 11 "Users named in policies: $done of $($references.Count)" & $lookupUser $id | Select-Object *, @{ Name = 'ExcludedBy'; Expression = { $references[$id]['exclude'].ToArray() } }, @{ Name = 'IncludedBy'; Expression = { $references[$id]['include'].ToArray() } }, @{ Name = 'IsSyncAccount'; Expression = { $id -in $syncIds } } } # Group and active role memberships (transitive) of the users a breakglass account can be # chosen from, so the preflight can tell whether a group- or role-scoped policy reaches it. $memberships = [ordered]@{} $memberIds = @(@($breakGlass | ForEach-Object { $_['id'] }) + @($referencedUsers | Where-Object Status -eq 'Found' | ForEach-Object Id) + @($adminRoles.Users | Where-Object Status -eq 'Found' | ForEach-Object Id) | Sort-Object -Unique) $done = 0 foreach ($id in $memberIds) { $done++ Show-Step 12 "Group and role memberships: $done of $($memberIds.Count) accounts" $memberOf =@(Invoke-NSPGraphCollection -Uri "users/$id/transitiveMemberOf?`$select=id,roleTemplateId") $memberships[$id] = [ordered]@{ Groups = @($memberOf | Where-Object { $_['@odata.type'] -eq '#microsoft.graph.group' } | ForEach-Object { $_['id'] }) Roles = @($memberOf | Where-Object { $_['@odata.type'] -eq '#microsoft.graph.directoryRole' } | ForEach-Object { $_['roleTemplateId'] }) } } $applications = [ordered]@{} $ambiguous = [System.Collections.Generic.List[object]]::new() $notFound = [System.Collections.Generic.List[string]]::new() Show-Step 13 "Applications the templates name ($(@($ApplicationName).Count))" foreach ($name in $ApplicationName) { $matched = @(Invoke-NSPGraphCollection -Uri (Get-Query 'servicePrincipals' "displayName eq '$($name -replace "'", "''")'" 'id,appId,displayName')) switch ($matched.Count) { 0 { $notFound.Add($name) } 1 { $applications[$name] = $matched[0]['appId'] } default { $ambiguous.Add([pscustomobject]@{ Name = $name; AppIds = @($matched | ForEach-Object { $_['appId'] }) }) } } } $roleTableCheck = foreach ($entry in (Get-NSPConditionalAccessRoleTable).GetEnumerator() | Sort-Object Key) { $template = @($roleTemplates | Where-Object { $_['id'] -eq $entry.Value })[0] $status = if (-not $template) { 'Missing' } elseif (($template['displayName'] -replace '[^A-Za-z]', '') -eq $entry.Key) { 'Match' } else { 'NameDiffers' } [pscustomobject]@{ Name = $entry.Key TemplateId = $entry.Value TenantDisplayName = if ($template) { $template['displayName'] } else { $null } Status = $status } } $inventory = [pscustomobject][ordered]@{ SchemaVersion = 1 CapturedAtUtc = (Get-Date).ToUniversalTime().ToString('o') TenantId = $context.TenantId TenantDomain = if ($defaultDomain) { $defaultDomain['name'] } else { $null } Hybrid = [bool]$organization['onPremisesSyncEnabled'] Organization = $organization Policies = $policies NamedLocations = $namedLocations Groups = $groups BreakGlassCandidates = $breakGlass ReferencedUsers = @($referencedUsers | Sort-Object { - @($_.ExcludedBy).Count }, DisplayName) SyncAccounts = @($syncAccounts) AdminRoles = $adminRoles Memberships = $memberships AuthenticationStrengths = $strengths TemporaryAccessPass = $tapMethod SecurityDefaults = $securityDefaults Licenses = $licenses RoleTemplates = $roleTemplates RoleTableCheck = @($roleTableCheck) Directory = [ordered]@{ Applications = $applications } AmbiguousApplications = $ambiguous.ToArray() MissingApplications = $notFound.ToArray() SavedPath = $null } if ($Save) { Show-Step 14 'Saving' $directory = Join-Path (Get-NSPConditionalAccessLocalPath -Kind Inventory -ConfigRoot $ConfigRoot) $context.TenantId New-Item -ItemType Directory -Path $directory -Force | Out-Null $inventory.SavedPath = Join-Path $directory ((Get-Date).ToUniversalTime().ToString('yyyyMMdd-HHmmss') + '.json') ConvertTo-Json -InputObject $inventory -Depth 32 | Set-Content -LiteralPath $inventory.SavedPath -Encoding UTF8 } Write-NSPConditionalAccessProgress -Activity Inventory -Completed $inventory } |