modules/Invoke-ConditionalAccessGraph.ps1
|
#Requires -Version 7.4 <# .SYNOPSIS Conditional Access policy graph wrapper (graph mapping family R1). .DESCRIPTION Pulls Conditional Access policies from Microsoft Graph (`/identity/conditionalAccess/policies`) and emits: * Findings for high-risk gaps (disabled policy covering privileged roles, report-only stuck >30 d, GA excluded from MFA, etc.). * Edges into the EntityStore: - AppliesTo : ConditionalAccessPolicy -> User|Group|Application|NamedLocation - Excludes : ConditionalAccessPolicy -> User|Group|Application|NamedLocation Microsoft Graph access is OPTIONAL. When the Microsoft.Graph modules are not connected, the wrapper consumes pre-fetched data via `-PreFetchedData` (test fixtures, offline mode). All Graph calls are wrapped in `Invoke-WithRetry` to handle 429 throttling. All output passes through `Remove-Credentials`. Read-only Graph scopes required when running live: Policy.Read.All, Directory.Read.All Design doc: docs/design/graph-mapping-integration.md. .PARAMETER TenantId Home tenant id used for Tenant entity canonicalization. .PARAMETER PreFetchedData Optional PSCustomObject with .Policies (array of CA policy objects in the Microsoft Graph shape). When supplied, bypasses the live Graph call. Used by the wrapper test suite and `-FixtureMode`. .OUTPUTS PSCustomObject @{ Source; SchemaVersion='1.0'; Status; Message; Findings=@(); Errors=@(); Policies=@() } Each entry in `Policies` is a sanitized projection of the raw Graph policy used by the normalizer to derive entities and edges. #> [CmdletBinding()] param ( [string] $TenantId, [PSCustomObject] $PreFetchedData ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # Dot-source shared modules with inline fallback stubs (matches the # established wrapper pattern in Invoke-DnsTwist.ps1 + # Invoke-IdentityGraphExpansion.ps1). $sharedDir = Join-Path $PSScriptRoot 'shared' $sanitizePath = Join-Path $sharedDir 'Sanitize.ps1' $errorsPath = Join-Path $sharedDir 'Errors.ps1' $retryPath = Join-Path $sharedDir 'Retry.ps1' $envelopePath = Join-Path $sharedDir 'New-WrapperEnvelope.ps1' if (Test-Path $sanitizePath) { . $sanitizePath } if (Test-Path $errorsPath) { . $errorsPath } if (Test-Path $retryPath) { . $retryPath } if (Test-Path $envelopePath) { . $envelopePath } if (-not (Get-Command New-WrapperEnvelope -ErrorAction SilentlyContinue)) { function New-WrapperEnvelope { param([string]$Source,[string]$Status='Failed',[string]$Message='',[object[]]$FindingErrors=@()) return [PSCustomObject]@{ Source=$Source; SchemaVersion='1.0'; Status=$Status; Message=$Message; Findings=@(); Errors=@($FindingErrors) } } } if (-not (Get-Command Remove-Credentials -ErrorAction SilentlyContinue)) { function Remove-Credentials { param ([string]$Text) return $Text } } if (-not (Get-Command New-FindingError -ErrorAction SilentlyContinue)) { function New-FindingError { param([string]$Source,[string]$Category,[string]$Reason,[string]$Remediation,[string]$Details) return [pscustomobject]@{ Source=$Source; Category=$Category; Reason=$Reason; Remediation=$Remediation; Details=$Details } } } if (-not (Get-Command Invoke-WithRetry -ErrorAction SilentlyContinue)) { function Invoke-WithRetry { param ([scriptblock]$ScriptBlock); & $ScriptBlock } } function Test-MgGraphAvailable { # We want a *connected* session, not just the module being importable. # Get-MgContext returns $null when no Connect-MgGraph has happened. $cmd = Get-Command Get-MgContext -ErrorAction SilentlyContinue if (-not $cmd) { return $false } try { $ctx = Get-MgContext return ($null -ne $ctx) } catch { return $false } } function Get-CaPolicyState { param ([Parameter(Mandatory)][PSCustomObject] $Policy) if ($Policy.PSObject.Properties['state'] -and $Policy.state) { return [string]$Policy.state } return 'unknown' } function Get-CaPolicyDisplayName { param ([Parameter(Mandatory)][PSCustomObject] $Policy) if ($Policy.PSObject.Properties['displayName'] -and $Policy.displayName) { # Scrub the operator-controlled displayName at the point of # extraction so any secret accidentally embedded in a policy name # is redacted before it can flow into the projection, the # finding Title, or the report. Remove-Credentials is a no-op # if Sanitize.ps1 was not loadable. return [string](Remove-Credentials ([string]$Policy.displayName)) } return '' } function Get-CaConditionField { <# .SYNOPSIS Safe accessor for a nested condition list on a CA policy. #> param ( [Parameter(Mandatory)] [PSCustomObject] $Policy, [Parameter(Mandatory)] [string] $Section, [Parameter(Mandatory)] [string] $Field ) if (-not $Policy.PSObject.Properties['conditions']) { return @() } $cond = $Policy.conditions if (-not $cond -or -not $cond.PSObject.Properties[$Section]) { return @() } $sect = $cond.$Section if (-not $sect -or -not $sect.PSObject.Properties[$Field]) { return @() } $v = $sect.$Field if ($null -eq $v) { return @() } return @($v) } function Get-CaGrantControl { param ( [Parameter(Mandatory)] [PSCustomObject] $Policy, [Parameter(Mandatory)] [string] $Field ) if (-not $Policy.PSObject.Properties['grantControls']) { return @() } $g = $Policy.grantControls if (-not $g -or -not $g.PSObject.Properties[$Field]) { return @() } $v = $g.$Field if ($null -eq $v) { return @() } return @($v) } function Get-CaPolicyFindings { <# .SYNOPSIS Apply the CA risk rubric documented in docs/design/graph-mapping-integration.md section 4.4 and emit one v1 finding object per gap. #> param ([Parameter(Mandatory)][PSCustomObject] $Policy) $findings = [System.Collections.Generic.List[PSCustomObject]]::new() $policyId = if ($Policy.PSObject.Properties['id']) { [string]$Policy.id } else { '' } $policyName = Get-CaPolicyDisplayName -Policy $Policy $state = Get-CaPolicyState -Policy $Policy $deepLink = if ($policyId) { "https://entra.microsoft.com/#view/Microsoft_AAD_ConditionalAccess/PolicyBlade/policyId/$policyId" } else { '' } $includeRoles = Get-CaConditionField -Policy $Policy -Section 'users' -Field 'includeRoles' $excludeRoles = Get-CaConditionField -Policy $Policy -Section 'users' -Field 'excludeRoles' $includeUsers = Get-CaConditionField -Policy $Policy -Section 'users' -Field 'includeUsers' $excludeUsers = Get-CaConditionField -Policy $Policy -Section 'users' -Field 'excludeUsers' $builtIns = Get-CaGrantControl -Policy $Policy -Field 'builtInControls' # Directory role template id for Global Administrator. $globalAdminRoleId = '62e90394-69f5-4237-9190-012177145e10' $coversGlobalAdmin = ($includeRoles -contains $globalAdminRoleId) -or ($includeUsers -contains 'All') # Indicator 1: disabled policy that covers privileged role members. if ($state -eq 'disabled' -and $coversGlobalAdmin) { $findings.Add([PSCustomObject]@{ Id = "ca:$policyId:disabled-covers-priv" RuleId = 'ca-disabled-covers-privileged' Title = "Disabled CA policy covers privileged identities: $policyName" Category = 'Identity Graph' Severity = 'High' Compliant = $false Detail = "Policy state is 'disabled' but the targeting includes Global Administrator role or All users." Remediation = "Enable the policy, or remove the privileged-role targeting if intentional." ResourceId = $policyId Pillar = 'Identity' Impact = 'High' Effort = 'Low' DeepLinkUrl = $deepLink }) | Out-Null } # Indicator 2: report-only mode (long tail; we cannot infer "30d" without # createdDateTime, so we emit Medium whenever the state is report-only). if ($state -eq 'enabledForReportingButNotEnforced') { $findings.Add([PSCustomObject]@{ Id = "ca:$policyId:report-only" RuleId = 'ca-report-only-not-enforced' Title = "CA policy is in report-only mode: $policyName" Category = 'Identity Graph' Severity = 'Medium' Compliant = $false Detail = "Policy is logging matches but not enforcing controls. Review the sign-in log impact and promote to 'enabled'." Remediation = "Promote the policy to 'enabled' once impact has been validated." ResourceId = $policyId Pillar = 'Identity' Impact = 'Medium' Effort = 'Low' DeepLinkUrl = $deepLink }) | Out-Null } # Indicator 3: GA excluded from MFA grant. $excludesGlobalAdmin = ($excludeRoles -contains $globalAdminRoleId) $requiresMfa = ($builtIns -contains 'mfa') if ($excludesGlobalAdmin -and $requiresMfa) { $findings.Add([PSCustomObject]@{ Id = "ca:$policyId:ga-excluded-from-mfa" RuleId = 'ca-ga-excluded-from-mfa' Title = "Global Administrator role is excluded from an MFA-requiring policy: $policyName" Category = 'Identity Graph' Severity = 'Critical' Compliant = $false Detail = "Policy requires MFA but excludes the Global Administrator directory-role group, leaving the highest-privilege identities outside the MFA gate." Remediation = "Remove the Global Administrator role from the exclusion set; rely on a small named break-glass account list instead." ResourceId = $policyId Pillar = 'Identity' Impact = 'Critical' Effort = 'Low' DeepLinkUrl = $deepLink }) | Out-Null } # Indicator 4: All-users targeting with too many user exclusions. if (($includeUsers -contains 'All') -and (@($excludeUsers).Count -gt 2)) { $findings.Add([PSCustomObject]@{ Id = "ca:$policyId:break-glass-too-large" RuleId = 'ca-break-glass-too-large' Title = "All-users CA policy excludes more than 2 accounts: $policyName" Category = 'Identity Graph' Severity = 'Medium' Compliant = $false Detail = ("Policy targets All users but excludes {0} individual accounts. Limit break-glass exclusions to 2 named accounts and document them." -f @($excludeUsers).Count) Remediation = "Trim the exclusion list to 2 break-glass accounts and document them in the runbook." ResourceId = $policyId Pillar = 'Identity' Impact = 'Medium' Effort = 'Medium' DeepLinkUrl = $deepLink }) | Out-Null } # Indicator 5: policy declares no MFA (and no other strong control). $strongControls = @('mfa','compliantDevice','domainJoinedDevice','passwordChange','approvedApplication','compliantApplication') $hasStrong = (@($builtIns | Where-Object { $strongControls -contains $_ })).Count -gt 0 if ($state -eq 'enabled' -and -not $hasStrong) { $findings.Add([PSCustomObject]@{ Id = "ca:$policyId:no-strong-control" RuleId = 'ca-no-strong-control' Title = "Enabled CA policy declares no strong grant control: $policyName" Category = 'Identity Graph' Severity = 'Low' Compliant = $false Detail = "Policy is enabled but its grantControls.builtInControls list does not include MFA, compliant device, password change, or approved application." Remediation = "Add at least one strong control (typically 'mfa') to the policy's grant controls." ResourceId = $policyId Pillar = 'Identity' Impact = 'Low' Effort = 'Low' DeepLinkUrl = $deepLink }) | Out-Null } return @($findings) } function ConvertTo-CaPolicyProjection { <# .SYNOPSIS Reduce a raw Microsoft Graph CA policy down to the fields the normalizer needs to emit edges. Free-text claim payloads are deliberately omitted (see design doc section 4.7). #> param ([Parameter(Mandatory)][PSCustomObject] $Policy) [PSCustomObject]@{ Id = if ($Policy.PSObject.Properties['id']) { [string]$Policy.id } else { '' } DisplayName = Get-CaPolicyDisplayName -Policy $Policy State = Get-CaPolicyState -Policy $Policy IncludeUsers = Get-CaConditionField -Policy $Policy -Section 'users' -Field 'includeUsers' ExcludeUsers = Get-CaConditionField -Policy $Policy -Section 'users' -Field 'excludeUsers' IncludeGroups = Get-CaConditionField -Policy $Policy -Section 'users' -Field 'includeGroups' ExcludeGroups = Get-CaConditionField -Policy $Policy -Section 'users' -Field 'excludeGroups' IncludeRoles = Get-CaConditionField -Policy $Policy -Section 'users' -Field 'includeRoles' ExcludeRoles = Get-CaConditionField -Policy $Policy -Section 'users' -Field 'excludeRoles' IncludeApps = Get-CaConditionField -Policy $Policy -Section 'applications' -Field 'includeApplications' ExcludeApps = Get-CaConditionField -Policy $Policy -Section 'applications' -Field 'excludeApplications' IncludeLocs = Get-CaConditionField -Policy $Policy -Section 'locations' -Field 'includeLocations' ExcludeLocs = Get-CaConditionField -Policy $Policy -Section 'locations' -Field 'excludeLocations' BuiltIns = Get-CaGrantControl -Policy $Policy -Field 'builtInControls' } } # Main wrapper body try { $policies = @() $source = 'live-graph' if ($PreFetchedData -and $PreFetchedData.PSObject.Properties['Policies']) { $policies = @($PreFetchedData.Policies) $source = 'pre-fetched' } else { if (-not (Test-MgGraphAvailable)) { $err = New-FindingError -Source 'wrapper:conditional-access-graph' ` -Category 'MissingDependency' ` -Reason 'Microsoft.Graph.Identity.SignIns module is not available or not connected' ` -Remediation 'Install Microsoft.Graph and run Connect-MgGraph -Scopes "Policy.Read.All Directory.Read.All", or pass -PreFetchedData.' return New-WrapperEnvelope -Source 'conditional-access-graph' -Status 'Skipped' ` -Message 'Microsoft.Graph not available; skipping Conditional Access graph collection.' ` -FindingErrors @($err) } $policies = @(Invoke-WithRetry -ScriptBlock { Get-MgIdentityConditionalAccessPolicy -All }) } $findings = [System.Collections.Generic.List[PSCustomObject]]::new() $projections = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($p in $policies) { if ($null -eq $p) { continue } foreach ($f in (Get-CaPolicyFindings -Policy $p)) { $findings.Add($f) | Out-Null } $projections.Add((ConvertTo-CaPolicyProjection -Policy $p)) | Out-Null } return [PSCustomObject]@{ Source = 'conditional-access-graph' SchemaVersion = '1.0' Status = 'Success' Message = ("Inspected {0} Conditional Access policy(ies) ({1}); emitted {2} finding(s)." -f @($policies).Count, $source, $findings.Count) TenantId = $TenantId Policies = @($projections) Findings = @($findings) Errors = @() } } catch { $sanitised = Remove-Credentials ([string]$_) $err = New-FindingError -Source 'wrapper:conditional-access-graph' ` -Category 'UnexpectedFailure' ` -Reason 'Unhandled exception in Invoke-ConditionalAccessGraph' ` -Remediation 'See Details; rerun with -Verbose for stack.' ` -Details $sanitised return New-WrapperEnvelope -Source 'conditional-access-graph' -Status 'Failed' ` -Message $sanitised -FindingErrors @($err) } |