Skip to content

Commit 2187073

Browse files
committed
update gdap trace
1 parent c86da19 commit 2187073

File tree

1 file changed

+165
-77
lines changed

1 file changed

+165
-77
lines changed

Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecGDAPTrace.ps1

Lines changed: 165 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
function Invoke-ExecAccessTest {
1+
function Invoke-ExecGDAPTrace {
22
<#
33
.SYNOPSIS
44
Tests the complete GDAP (Granular Delegated Admin Privileges) access path for a user.
@@ -215,6 +215,104 @@ function Invoke-ExecAccessTest {
215215
Write-LogMessage -Headers $Headers -API $APIName -message "Could not get user group memberships: $($_.Exception.Message)" -sev 'Warn'
216216
}
217217

218+
# ============================================================================
219+
# HELPER FUNCTION: Find complete transitive path from user to target group
220+
# ============================================================================
221+
# This recursive function finds the complete path through nested groups,
222+
# handling any depth of nesting (User → Group A → Group B → ... → Target Group)
223+
#
224+
# Examples of paths it can trace:
225+
# - User → Target Group (direct membership)
226+
# - User → Group A → Target Group (2 levels)
227+
# - User → Group A → Group B → Target Group (3 levels)
228+
# - User → Group A → Group B → Group C → Target Group (4 levels)
229+
# - And so on, up to MaxDepth (10 levels)
230+
#
231+
# The function recursively traverses the group membership hierarchy by:
232+
# 1. Checking if user is directly in the target group
233+
# 2. If not, checking each group that is a member of the target group
234+
# 3. Recursively checking if the user is in those intermediate groups
235+
# 4. Building the complete path from user to target group
236+
# ============================================================================
237+
function Find-TransitiveGroupPath {
238+
param(
239+
[string]$UserId,
240+
[string]$TargetGroupId,
241+
[string]$TargetGroupName,
242+
[hashtable]$VisitedGroups = @{},
243+
[int]$MaxDepth = 10
244+
)
245+
246+
# Prevent infinite loops and excessive depth
247+
if ($VisitedGroups.ContainsKey($TargetGroupId) -or $MaxDepth -le 0) {
248+
return $null
249+
}
250+
$VisitedGroups[$TargetGroupId] = $true
251+
252+
try {
253+
# Check if user is directly in target group
254+
$DirectMembers = New-GraphGetRequest -Uri "https://graph.microsoft.com/beta/groups/$TargetGroupId/members?`$select=id,displayName,userPrincipalName" -tenantid $env:TenantID -NoAuthCheck $true -AsApp $true -ErrorAction SilentlyContinue
255+
if ($DirectMembers.value | Where-Object { $_.id -eq $UserId }) {
256+
# User is directly in target group
257+
return @(
258+
@{
259+
sequence = 0
260+
groupId = $TargetGroupId
261+
groupName = $TargetGroupName
262+
membershipType = 'direct'
263+
}
264+
)
265+
}
266+
267+
# User is not directly in target group, check nested groups
268+
$GroupMembers = New-GraphGetRequest -Uri "https://graph.microsoft.com/beta/groups/$TargetGroupId/members?`$select=id,displayName,@odata.type" -tenantid $env:TenantID -NoAuthCheck $true -AsApp $true -ErrorAction SilentlyContinue
269+
270+
if ($GroupMembers.value) {
271+
foreach ($Member in $GroupMembers.value) {
272+
# Only check groups, not users
273+
if ($Member.'@odata.type' -eq '#microsoft.graph.group') {
274+
$IntermediateGroupId = $Member.id
275+
$IntermediateGroupName = $Member.displayName
276+
277+
# Recursively check if user is in this intermediate group
278+
$SubPath = Find-TransitiveGroupPath -UserId $UserId -TargetGroupId $IntermediateGroupId -TargetGroupName $IntermediateGroupName -VisitedGroups $VisitedGroups -MaxDepth ($MaxDepth - 1)
279+
280+
if ($SubPath) {
281+
# Found a path! Build the complete chain
282+
$Path = @()
283+
$Sequence = 0
284+
285+
# Add intermediate groups from sub-path
286+
foreach ($PathItem in $SubPath) {
287+
$Path += @{
288+
sequence = $Sequence++
289+
groupId = $PathItem.groupId
290+
groupName = $PathItem.groupName
291+
membershipType = $PathItem.membershipType # Preserve membership type from sub-path
292+
}
293+
}
294+
295+
# Add target group at the end (intermediate group is nested in target)
296+
$Path += @{
297+
sequence = $Sequence
298+
groupId = $TargetGroupId
299+
groupName = $TargetGroupName
300+
membershipType = 'nested'
301+
}
302+
303+
return $Path
304+
}
305+
}
306+
}
307+
}
308+
} catch {
309+
# If we can't check (permissions, API error), return null
310+
# Silently fail - errors are expected when checking group memberships
311+
}
312+
313+
return $null
314+
}
315+
218316
# ============================================================================
219317
# STEP 6: Collect all relationships, groups, and build role mapping
220318
# ============================================================================
@@ -445,78 +543,48 @@ function Invoke-ExecAccessTest {
445543
# nested, try to find the path through intermediate groups.
446544
# ============================================================
447545
$IsPathComplete = $true
448-
# Start with assumption of direct membership
449-
$MembershipPath = @(
450-
@{
451-
groupId = $GroupId
452-
groupName = $Group.displayName
453-
membershipType = 'direct'
454-
}
455-
)
546+
# Path will be determined by Find-TransitiveGroupPath function
547+
# Initialize empty - will be populated below
548+
$MembershipPath = @()
456549

457550
# ============================================================
458-
# Determine if membership is direct or nested
551+
# Determine if membership is direct or nested and find full path
459552
# ============================================================
460-
# We check the direct members of the group to see if the user
461-
# is directly in it. If not, they must be nested (through
462-
# another group that's a member of this group).
553+
# We use a recursive function to find the complete transitive path
554+
# through nested groups, handling any depth of nesting.
555+
# This replaces the previous single-level detection with full
556+
# path tracing: User → Group A → Group B → ... → Target Group
463557
# ============================================================
464558
try {
465-
# Get direct members of the target group
466-
$DirectMembers = New-GraphGetRequest -Uri "https://graph.microsoft.com/beta/groups/$GroupId/members?`$select=id,displayName,userPrincipalName" -tenantid $env:TenantID -NoAuthCheck $true -AsApp $true
467-
$IsDirectMember = $DirectMembers.value | Where-Object { $_.id -eq $UserId }
468-
469-
if (-not $IsDirectMember) {
470-
# ====================================================
471-
# User is nested - find the path through nested groups
472-
# ====================================================
473-
# The user is not directly in this group, so they must
474-
# be in a group that's a member of this group.
475-
# We try to find which of the user's direct groups
476-
# are members of this target group.
477-
# ====================================================
478-
$MembershipPath[0].membershipType = 'nested'
479-
480-
# Get groups the user is directly in (not nested)
481-
$UserDirectGroups = New-GraphGetRequest -Uri "https://graph.microsoft.com/beta/users/$UserId/memberOf?`$select=id,displayName" -tenantid $env:TenantID -NoAuthCheck $true -AsApp $true -ErrorAction SilentlyContinue
482-
if ($UserDirectGroups) {
483-
$NestedGroups = @()
484-
# Check each of the user's direct groups
485-
foreach ($UserGroup in $UserDirectGroups) {
486-
if ($UserGroup.'@odata.type' -eq '#microsoft.graph.group') {
487-
try {
488-
# Check if this user's direct group is a member of the target group
489-
$GroupMembers = New-GraphGetRequest -Uri "https://graph.microsoft.com/beta/groups/$GroupId/members?`$select=id" -tenantid $env:TenantID -NoAuthCheck $true -AsApp $true -ErrorAction SilentlyContinue
490-
if ($GroupMembers.value | Where-Object { $_.id -eq $UserGroup.id }) {
491-
# Found it! This is the intermediate group
492-
$NestedGroups += @{
493-
groupId = $UserGroup.id
494-
groupName = $UserGroup.displayName
495-
membershipType = 'direct' # User is direct member of this intermediate group
496-
}
497-
}
498-
} catch {
499-
# Skip if we can't check (permissions issue, etc.)
500-
}
501-
}
502-
}
503-
if ($NestedGroups.Count -gt 0) {
504-
# Build the complete path: User → Intermediate Group → Target Group
505-
# Add the target group to complete the path
506-
$NestedGroups += @{
507-
groupId = $GroupId
508-
groupName = $Group.displayName
509-
membershipType = 'nested' # Intermediate group is nested in target group
510-
}
511-
$MembershipPath = $NestedGroups
559+
# Use recursive function to find the complete path
560+
$FullPath = Find-TransitiveGroupPath -UserId $UserId -TargetGroupId $GroupId -TargetGroupName $Group.displayName
561+
562+
if ($FullPath) {
563+
# Found the complete path - use it
564+
$MembershipPath = $FullPath
565+
} else {
566+
# Fallback: Couldn't determine path (permissions, API error, etc.)
567+
# We know user is a member (from transitiveMemberOf), so mark as nested
568+
$MembershipPath = @(
569+
@{
570+
sequence = 0
571+
groupId = $GroupId
572+
groupName = $Group.displayName
573+
membershipType = 'nested'
512574
}
513-
}
575+
)
514576
}
515-
# If IsDirectMember is true, membershipPath already shows 'direct' - we're done
516577
} catch {
517-
# If we can't check direct members (permissions, API error), assume nested
578+
# If we can't check (permissions, API error), assume nested
518579
# This is a safe assumption - we know they're a member somehow
519-
$MembershipPath[0].membershipType = 'nested'
580+
$MembershipPath = @(
581+
@{
582+
sequence = 0
583+
groupId = $GroupId
584+
groupName = $Group.displayName
585+
membershipType = 'nested'
586+
}
587+
)
520588
}
521589
} else {
522590
# ============================================================
@@ -538,10 +606,11 @@ function Invoke-ExecAccessTest {
538606
# Record the broken path
539607
$MembershipPath = @(
540608
@{
541-
groupId = $GroupId
542-
groupName = $Group.displayName
543-
membershipType = 'not_member'
544-
groupHasMembers = $GroupHasMembers # Helps diagnose if group is empty
609+
sequence = 0
610+
groupId = $GroupId
611+
groupName = $Group.displayName
612+
membershipType = 'not_member'
613+
groupHasMembers = $GroupHasMembers # Helps diagnose if group is empty
545614
}
546615
)
547616
}
@@ -565,7 +634,7 @@ function Invoke-ExecAccessTest {
565634
}
566635

567636
$RelationshipGroups.Add($GroupData)
568-
Write-LogMessage -Headers $Headers -API $APIName -message "Processed group $GroupDisplayName ($GroupId) with $($Roles.Count) roles for relationship ${RelationshipName}" -Sev 'Debug'
637+
Write-LogMessage -Headers $Headers -API $APIName -message "Processed group $($Group.displayName) ($GroupId) with $($Roles.Count) roles for relationship ${RelationshipName}" -Sev 'Debug'
569638

570639
# ================================================================
571640
# Map each role to this relationship/group combination
@@ -638,6 +707,14 @@ function Invoke-ExecAccessTest {
638707
# - All relationships/groups that have it
639708
# - The complete path from role to user (if access exists)
640709
# ============================================================================
710+
711+
# Create a lookup map for relationship data (relationshipId -> relationship object)
712+
# This allows us to quickly access customer tenant info when building accessPaths
713+
$RelationshipLookup = @{}
714+
foreach ($RelData in $AllRelationshipData) {
715+
$RelationshipLookup[$RelData.relationshipId] = $RelData
716+
}
717+
641718
$RoleTraces = [System.Collections.Generic.List[object]]::new()
642719

643720
# Check each of the 15 standard GDAP roles
@@ -683,14 +760,25 @@ function Invoke-ExecAccessTest {
683760
# ================================================================
684761
if ($GroupData.isMember) {
685762
$UserHasAccess = $true
686-
# Record the access path for this role
687-
$AccessPaths.Add([PSCustomObject]@{
688-
relationshipId = $RoleRelationship.relationshipId
689-
relationshipName = $RoleRelationship.relationshipName
690-
groupId = $RoleRelationship.groupId
691-
groupName = $RoleRelationship.groupName
692-
membershipPath = $GroupData.membershipPath # Shows: User → Group (or User → Intermediate → Group)
693-
})
763+
764+
# Get full relationship context from lookup
765+
$FullRelationshipData = $null
766+
if ($RelationshipLookup.ContainsKey($RoleRelationship.relationshipId)) {
767+
$FullRelationshipData = $RelationshipLookup[$RoleRelationship.relationshipId]
768+
}
769+
770+
# Record the access path for this role with full relationship context
771+
$AccessPath = [PSCustomObject]@{
772+
relationshipId = $RoleRelationship.relationshipId
773+
relationshipName = $RoleRelationship.relationshipName
774+
relationshipStatus = $RoleRelationship.relationshipStatus
775+
customerTenantId = if ($FullRelationshipData) { $FullRelationshipData.customerTenantId } else { $CustomerTenantId }
776+
customerTenantName = if ($FullRelationshipData) { $FullRelationshipData.customerTenantName } else { $CustomerTenantName }
777+
groupId = $RoleRelationship.groupId
778+
groupName = $RoleRelationship.groupName
779+
membershipPath = $GroupData.membershipPath # Shows: User → Group(s) → Target Group (with sequence numbers)
780+
}
781+
$AccessPaths.Add($AccessPath)
694782
}
695783
}
696784
}

0 commit comments

Comments
 (0)