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