From 2c8284ce1bcfd689f978e904484a55048524c27b Mon Sep 17 00:00:00 2001
From: Markus Gruber <26838358+GruberMarkus@users.noreply.github.com>
Date: Tue, 25 Oct 2022 09:52:49 +0200
Subject: [PATCH] v2.3.0 (#19)
## v2.3.0 - 2022-10-25
### Added
- When '`ExportFromOnPrem`' is set to '`$true`' and '`ExchangeConnectionUriList`' is not specified, '`ExchangeConnectionUriList`' defaults to '`http:///powershell`' for each Exchange server with the mailbox server role
- New FAQs in '`README`': 'Which resources does a particular user or group have access to?', 'How to find distribution lists without members?'
- New sample code 'MemberOfRecurse.ps1'
---
.github/workflows/build, draft release.yml | 4 +-
docs/CHANGELOG.md | 6 +
docs/README.md | 110 +++-
src/Export-RecipientPermissions.ps1 | 253 ++++----
.../other samples/MemberOfRecurse.ps1 | 581 ++++++++++++++++++
5 files changed, 813 insertions(+), 141 deletions(-)
create mode 100644 src/sample code/other samples/MemberOfRecurse.ps1
diff --git a/.github/workflows/build, draft release.yml b/.github/workflows/build, draft release.yml
index 626c08a..89ecd58 100644
--- a/.github/workflows/build, draft release.yml
+++ b/.github/workflows/build, draft release.yml
@@ -15,12 +15,12 @@ jobs:
fetch-depth: 0
- name: Install GitVersion
- uses: gittools/actions/gitversion/setup@v0.9.13
+ uses: gittools/actions/gitversion/setup@v0.9.14
with:
versionSpec: "5.x"
- name: Determine SemVer
- uses: gittools/actions/gitversion/execute@v0.9.13
+ uses: gittools/actions/gitversion/execute@v0.9.14
with:
additionalArguments: '/overrideconfig major-version-bump-message="^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\\([\\w\\s]*\\))?(!:|:.*\\n\\n((.+\\n)+\\n)?BREAKING CHANGE:\\s.+)" /overrideconfig minor-version-bump-message="^(feat)(\\([\\w\\s]*\\))?:" /overrideconfig patch-version-bump-message="^(build|chore|ci|docs|fix|perf|refactor|revert|style|test)(\\([\\w\\s]*\\))?:"'
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 1fd388e..6eb842f 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -15,6 +15,12 @@
### Fixed
-->
+## v2.3.0 - 2022-10-25
+### Added
+- When '`ExportFromOnPrem`' is set to '`$true`' and '`ExchangeConnectionUriList`' is not specified, '`ExchangeConnectionUriList`' defaults to '`http:///powershell`' for each Exchange server with the mailbox server role
+- New FAQs in '`README`': 'Which resources does a particular user or group have access to?', 'How to find distribution lists without members?'
+- New sample code 'MemberOfRecurse.ps1'
+
## v2.2.1 - 2022-09-27
### Fixed
- When ExportGrantorsWithNoPermissions is enabled and ExportGuids is disabled, empty management role groups were exported with no name and a trailing slash in the recipient type field
diff --git a/docs/README.md b/docs/README.md
index 5f4584c..7d5773b 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -74,10 +74,13 @@ Compare exports from different times to detect permission changes (sample code i
- [2.7. Isn't a plural noun in the script name against PowerShell best practices?](#27-isnt-a-plural-noun-in-the-script-name-against-powershell-best-practices)
- [2.8. Is there a roadmap for future versions?](#28-is-there-a-roadmap-for-future-versions)
- [2.9. Is there a GUI available?](#29-is-there-a-gui-available)
+ - [2.10. Which resources does a particular user or group have access to?](#210-which-resources-does-a-particular-user-or-group-have-access-to)
+ - [2.11. How to find distribution lists without members?](#211-how-to-find-distribution-lists-without-members)
- [3. Sample code](#3-sample-code)
- [3.1. Get-DependentRecipients.ps1](#31-get-dependentrecipientsps1)
- [3.2. Compare-RecipientPermissions.ps1](#32-compare-recipientpermissionsps1)
- [3.3. FiltersAndSidhistory.ps1](#33-filtersandsidhistoryps1)
+ - [3.4. MemberOfRecurse.ps1](#34-memberofrecurseps1)
- [4. Recommendations](#4-recommendations)
# 1. Export-RecipientPermissions.ps1
@@ -133,11 +136,14 @@ $true for export from on-prem, $false for export from Exchange Online
Default: $false
### 1.2.2. ExchangeConnectionUriList
-Server URIs to connect to
+Exchange remote PowerShell URIs to connect to
For on-prem installations, list all Exchange Server Remote PowerShell URIs the script can use
+For Exchange Online, use 'https://outlook.office365.com/powershell-liveid/' or the URI specific to your cloud environment
-For Exchange Online use 'https://outlook.office365.com/powershell-liveid/', or the URI specific to your cloud environment
+Default:
+- If ExportFromOnPrem ist set to false: 'https://outlook.office365.com/powershell-liveid/'
+- If ExportFromOnPrem ist set to true: 'http://\/powershell' for each Exchange server with the mailbox server role
### 1.2.3. ExchangeOnlineConnectionParameters
This hashtable will be passed as parameter to Connect-ExchangeOnline
@@ -217,7 +223,7 @@ Only report results where the filter criteria matches $true.
This filter works against every single row of the results found. ExportFile will only contain lines where this filter returns $true.
-The $ExportFileLine contains an object with the header names from $ExportFile as string properties:
+The $ExportFileLine variable contains an object with the header names from $ExportFile as string properties
- 'Grantor Primary SMTP'
- 'Grantor Display Name'
- 'Grantor Exchange GUID' (only when '`ExportGuids`' is enabled)
@@ -237,7 +243,7 @@ The $ExportFileLine contains an object with the header names from $ExportFile as
- 'Trustee Recipient Type'
- 'Trustee Environment'
-Example: "`$ExportFileFilter.'Trustee Environment' -ieq 'On-Prem'"
+Example: "`$ExportFileLine.'Trustee Environment' -ieq 'On-Prem'"
Default: $null
### 1.2.10. ExportMailboxAccessRights
@@ -615,6 +621,92 @@ A basic GUI for configuring the script is accessible via the following built-in
```
Show-Command .\Export-RecipientPermissions.ps1
```
+## 2.10. Which resources does a particular user or group have access to?
+As many other IT systems, Exchange stores permissions as forward links and not as backward links. This means that the information about granted permissions is stored on the granting object (forwarding to the trustee), without storing any information about the granted permission at the trustee object (pointing backwards to the grantor).
+
+This means that we have to query all granted permissions first and then filter those for trustees that involve the user or group we are looking for.
+
+There are some exceptions where Exchange stores permissions not only as forward links but also as backward links, to allow getting a list of certain permission with just one fast query from the grantor perspective. All these cases rely on automatic calculation of the backlink attribute in Active Directory. Well-known examples are publicDelegates/publicDelegatesBL, member/memberOf, manager/directReports and owner/ownerBL.
+
+But back to the initial question which resources a particular user or group has access to.
+We already know that we need to query all permissions of interest first, and then filter the results. But what should we filter for?
+
+If we are only interested in permissions granted directly to a certain user or group, the search is straight forward:
+- If the user or group is an Exchange recipient, use '`TrusteeFilter`' to filter for 'Trustee Primary SMTP'
+- If The user or group is not an Exchange recipient, enable '`ExportGUIDs`' and use '`ExportFileFilter`' to filter for '`Trustee AD ObjectGuid`'
+
+If we are interested in permissions granted directly or indirectly to a certain user or group, it get's more complicated.
+Export-RecipientPermissions can resolve permissions granted to groups in three ways: Do not resolve groups, resolve groups to their direct members, or resolve groups to their resulting members.
+- Not resolving groups does not take into consideration nested groups (permissions granted indirectly)
+- Resolving groups also does not consider nested groups (permissions granted indirectly) below the first membership level
+- Resolving groups to their resulting members requires relatively high CPU and RAM ressources and results in large result files.
+ - '`ExportDistributionGroupMembers`' only helps when the group in question might be a security group
+ - '`ExpandGroups`' results in large result files
+ - Neither '`ExportDistributionGroupMembers`' nor '`ExpandGroups`' can handle the following case: User A grants group X a certain permission. group Y is a member of group X, group Z is a member of group Y. Group Z does not have any members, but we need to know that future members of group Z will have access to the permission granted by user A.
+
+The most economic solution to all these problems is the following:
+- Export all the permissions you are interested in
+- Do not use '`ExportDistributionGroupMembers`' or '`ExpandGroups`'
+- Use '`ExportFileFilter`' to filter for '`Trustee AD ObjectGUID`', looking for the following AD ObjectGUIDs:
+ - The AD ObjectGUID of the object you are looking for
+ - All AD ObjectGUIDs of groups the initial object is a direct or indirect member of.
+ - In the example above, the following GUIDs are needed:
+ - AD ObjectGUID of group Z (because we are looking for permissions granted to group Z directly or indirectly)
+ - AD ObjectGUID of group Y (because group Z is a member of group Y)
+ - AD ObjectGUID of group X (because group Z is a member of group Y, and group Y is a member of group X)
+
+Getting all these GUIDs can be a lot of work. Use the sample code '`MemberOfRecurse.ps1`', which is described later in this document, to make this task as simple as possible.
+## 2.11. How to find distribution lists without members?
+When a distribution group has no members, E-Mails sent to the group's address are lost because there is no member Exchange could distribute the e-mail to. The sender is not informed about this, as an empty distribution group is a valid recipient, so no Non-Delivery Report is generated.
+
+When looking for distribution groups, counting the direct members is not enough. A group can have another group as only member, and this other group can be empty.
+
+Use the following configuration to reliably identify empty distribution groups:
+```
+$params = @{
+ ExportMailboxAccessRights = $false
+ ExportMailboxFolderPermissions = $false
+ ExportSendAs = $false
+ ExportSendOnBehalf = $false
+ ExportManagedBy = $false
+ ExportLinkedMasterAccount = $false
+ ExportPublicFolderPermissions = $false
+ ExportForwarders = $false
+ ExportManagementRoleGroupMembers = $false
+ ExportDistributionGroupMembers = 'All'
+ ExportGroupMembersRecurse = $true
+ ExpandGroups = $false
+ ExportGuids = $true
+ ExportGrantorsWithNoPermissions = $true
+ ExportTrustees = 'All'
+
+ GrantorFilter = "
+ if (`$Grantor.RecipientTypeDetails.Value -ilike ""*Group*"")
+ ) {
+ `$true
+ } else {
+ `$false
+ }
+ "
+ TrusteeFilter = $null
+ ExportFileFilter = "
+ if ([string]::IsNullOrEmpty(`$ExportFileLine.Permission) -eq `$true) {
+ `$true
+ } else {
+ `$false
+ }
+ "
+
+ ExportFile = '..\export\Export-RecipientPermissions_DVSV-Verteiler_Result.csv'
+ ErrorFile = '..\export\Export-RecipientPermissions_DVSV-Verteiler_Error.csv'
+ DebugFile = $null
+
+ verbose = $false
+}
+
+
+& .\Export-RecipientPermissions\Export-RecipientPermissions.ps1 @params
+```
# 3. Sample code
## 3.1. Get-DependentRecipients.ps1
The script can be found in '`.\sample code\Get-DependentRecipients`'.
@@ -658,6 +750,16 @@ The script can be found in '`.\sample code\other samples`'.
This sample code shows how to use TrusteeFilter to find permissions which may be affected by SIDHistory removal.
GrantorFilter behaves exactly like TrusteeFilter, only the reference variable is $Grantor instead of $Trustee.
+## 3.4. MemberOfRecurse.ps1
+The script can be found in '`.\sample code\other samples`'.
+
+This sample code shows how to list the GUIDs of all groups a certain AD object is a member of. The script considers
+- nested groups
+- security groups, no matter of which group scope they are
+- distribution groups (static only), no matter of which group scope they are
+- group membership in trusted domains/forests (incl. nested groups)
+
+These GUIDs can then be used to answer the question 'Which resources does a particular user or group have access to?', which is described in detail in the FAQ section of this document.
# 4. Recommendations
Make sure you have the latest updates installed to avoid memory leaks and CPU spikes (PowerShell, .Net framework).
diff --git a/src/Export-RecipientPermissions.ps1 b/src/Export-RecipientPermissions.ps1
index 4a11cc3..db4f7c4 100644
--- a/src/Export-RecipientPermissions.ps1
+++ b/src/Export-RecipientPermissions.ps1
@@ -31,10 +31,12 @@ Default: $false
.PARAMETER ExchangeConnectionUriList
-Server URIs to connect to
+Exchange remote PowerShell URIs to connect to
For on-prem installations, list all Exchange Server Remote PowerShell URIs the script can use
-For Exchange Online, this parameter is ignored use 'https://outlook.office365.com/powershell-liveid/', or the URI specific to your cloud environment
-
+For Exchange Online, use 'https://outlook.office365.com/powershell-liveid/' or the URI specific to your cloud environment
+Default:
+ If ExportFromOnPrem ist set to false: 'https://outlook.office365.com/powershell-liveid/'
+ If ExportFromOnPrem ist set to true: 'http:///powershell' for each Exchange server with the mailbox server role
.PARAMETER ExchangeCredentialUsernameFile, ExchangeCredentialPasswordFile, UseDefaultCredential
Credentials for Exchange connection
@@ -98,10 +100,10 @@ Default: $null
.PARAMETER ExportFileFilter
Only report results where the filter criteria matches $true.
This filter works against every single row of the results found. ExportFile will only contain lines where this filter returns $true.
-The $ExportFileLine contains an object with the header names from $ExportFile as string properties
+The $ExportFileLine variable contains an object with the header names from $ExportFile as string properties
'Grantor Primary SMTP', 'Grantor Display Name', 'Grantor Recipient Type', 'Grantor Environment', 'Folder', 'Permission', 'Allow/Deny', 'Inherited', 'InheritanceType', 'Trustee Original Identity', 'Trustee Primary SMTP', 'Trustee Display Name', 'Trustee Recipient Type', 'Trustee Environment'
-
-Example: "`$ExportFileFilter.'Trustee Environment' -ieq 'On-Prem'"
+ When GUIDs are exported, additional attributes are available: 'Grantor Exchange GUID', 'Grantor AD ObjectGUID', 'Trustee Exchange GUID', 'Trustee AD ObjectGUID'
+Example: "`$ExportFileLine.'Trustee Environment' -ieq 'On-Prem'"
Default: $null
@@ -316,7 +318,21 @@ License: MIT license (see '.\docs\LICENSE.txt' for details and copyright)
Param(
[boolean]$ExportFromOnPrem = $false,
- [uri[]]$ExchangeConnectionUriList = ('https://outlook.office365.com/powershell-liveid'),
+ [uri[]]$ExchangeConnectionUriList = $(
+ if ($ExportFromOnPrem) {
+ try {
+ $search = New-Object DirectoryServices.DirectorySearcher([ADSI]"LDAP://$(([ADSI]'LDAP://RootDse').configurationNamingContext)")
+ $search.Filter = '(&(objectClass=msExchExchangeServer)(msExchCurrentServerRoles:1.2.840.113556.1.4.803:=2))' # all Exchange servers with the mailbox role
+ $search.PageSize = 1000
+ [void]$search.PropertiesToLoad.Add('networkaddress')
+ @((($search.FindAll().properties.networkaddress | Where-Object { $_ -ilike 'ncacn_ip_tcp:*' }) -ireplace '^ncacn_ip_tcp:', 'http://' -ireplace '$', '/powershell') | Sort-Object -Unique)
+ } catch {
+ @()
+ }
+ } else {
+ @('https://outlook.office365.com/powershell-liveid')
+ }
+ ),
[boolean]$UseDefaultCredential = $false,
[string]$ExchangeCredentialUsernameFile = '.\Export-RecipientPermissions_CredentialUsername.txt',
[string]$ExchangeCredentialPasswordFile = '.\Export-RecipientPermissions_CredentialPassword.txt',
@@ -1050,12 +1066,10 @@ try {
}
}
- if ($tempQueue.count -eq 0) {
- Write-Host (("`b" * 100) + (' {0:0000000} @{1}@' -f $tempQueueCount, $(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz'))) -NoNewline
- Write-Host
- } else {
- Write-Host
- Write-Host ' Not all queries have been performed. Enable DebugFile option and check log file.' -ForegroundColor red
+ Write-Host (("`b" * 100) + (' {0:0000000} @{1}@' -f $tempQueueCount, $(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz')))
+
+ if ($tempQueue.count -ne 0) {
+ Write-Host ' Not all queries have been performed. Enable ErrorFile and DebugFile options and check the log files.' -ForegroundColor red
}
foreach ($runspace in $runspaces) {
@@ -1639,12 +1653,10 @@ try {
}
}
- if ($tempQueue.count -eq 0) {
- Write-Host (("`b" * 100) + (' {0:0000000} @{1}@' -f $tempQueueCount, $(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz'))) -NoNewline
- Write-Host
- } else {
- Write-Host
- Write-Host ' Not all databases have been checked. Enable DebugFile option and check log file.' -ForegroundColor red
+ Write-Host (("`b" * 100) + (' {0:0000000} @{1}@' -f $tempQueueCount, $(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz')))
+
+ if ($tempQueue.count -ne 0) {
+ Write-Host ' Not all databases have been checked. Enable ErrorFile and DebugFile options and check the log files.' -ForegroundColor red
}
foreach ($runspace in $runspaces) {
@@ -1855,12 +1867,10 @@ try {
}
}
- if ($tempQueue.count -eq 0) {
- Write-Host (("`b" * 100) + (' {0:0000000} @{1}@' -f $tempQueueCount, $(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz'))) -NoNewline
- Write-Host
- } else {
- Write-Host
- Write-Host ' Not all recipients have been checked. Enable DebugFile option and check log file.' -ForegroundColor red
+ Write-Host (("`b" * 100) + (' {0:0000000} @{1}@' -f $tempQueueCount, $(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz')))
+
+ if ($tempQueue.count -ne 0) {
+ Write-Host ' Not all recipients have been checked. Enable ErrorFile and DebugFile options and check the log files.' -ForegroundColor red
}
foreach ($runspace in $runspaces) {
@@ -1962,7 +1972,16 @@ try {
Write-Host
Write-Host "Import security principals, filtered by Name @$(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz')@"
- if (($ExportMailboxAccessRights) -and (($ExportSendAs) -or ($ExportLinkedMasterAccount -and $ExportFromOnPrem) -or ($ExportManagementRoleGroupMembers) -or (($ExportDistributionGroupMembers -ieq 'All') -or ($ExportDistributionGroupMembers -ieq 'OnlyTrustees')) -or ($ExpandGroups))) {
+ if (
+ ($ExportMailboxAccessRights) -or
+ ($ExportSendAs) -or
+ ($ExportLinkedMasterAccount -and $ExportFromOnPrem) -or
+ ($ExportManagementRoleGroupMembers) -or
+ ($ExportDistributionGroupMembers -ieq 'All') -or
+ ($ExportDistributionGroupMembers -ieq 'OnlyTrustees') -or
+ ($ExpandGroups) -or
+ ($ExportGuids)
+ ) {
$AllSecurityPrincipals = [system.collections.arraylist]::Synchronized([system.collections.arraylist]::new($AllRecipients.count))
$tempChars = ([char[]](0..255) -clike '[A-Z0-9]')
@@ -2138,12 +2157,10 @@ try {
}
}
- if ($tempQueue.count -eq 0) {
- Write-Host (("`b" * 100) + (' {0:0000000} @{1}@' -f $tempQueueCount, $(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz'))) -NoNewline
- Write-Host
- } else {
- Write-Host
- Write-Host ' Not all queries have been performed. Enable DebugFile option and check log file.' -ForegroundColor red
+ Write-Host (("`b" * 100) + (' {0:0000000} @{1}@' -f $tempQueueCount, $(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz')))
+
+ if ($tempQueue.count -ne 0) {
+ Write-Host ' Not all queries have been performed. Enable ErrorFile and DebugFile options and check the log files.' -ForegroundColor red
}
foreach ($runspace in $runspaces) {
@@ -2417,12 +2434,10 @@ try {
}
}
- if ($tempQueue.count -eq 0) {
- Write-Host (("`b" * 100) + (' {0:0000000} @{1}@' -f $tempQueueCount, $(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz'))) -NoNewline
- Write-Host
- } else {
- Write-Host
- Write-Host ' Not all queries have been performed. Enable DebugFile option and check log file.' -ForegroundColor red
+ Write-Host (("`b" * 100) + (' {0:0000000} @{1}@' -f $tempQueueCount, $(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz')))
+
+ if ($tempQueue.count -ne 0) {
+ Write-Host ' Not all queries have been performed. Enable ErrorFile and DebugFile options and check the log files.' -ForegroundColor red
}
foreach ($runspace in $runspaces) {
@@ -2845,12 +2860,10 @@ try {
}
}
- if ($tempQueue.count -eq 0) {
- Write-Host (("`b" * 100) + (' {0:0000000} @{1}@' -f $tempQueueCount, $(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz'))) -NoNewline
- Write-Host
- } else {
- Write-Host
- Write-Host ' Not all grantor mailboxes have been checked. Enable DebugFile option and check log file.' -ForegroundColor red
+ Write-Host (("`b" * 100) + (' {0:0000000} @{1}@' -f $tempQueueCount, $(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz')))
+
+ if ($tempQueue.count -ne 0) {
+ Write-Host ' Not all grantor mailboxes have been checked. Enable ErrorFile and DebugFile options and check the log files.' -ForegroundColor red
}
foreach ($runspace in $runspaces) {
@@ -2892,7 +2905,7 @@ try {
for ($x = 0; $x -lt $AllRecipients.count; $x++) {
$Recipient = $AllRecipients[$x]
- if (($Recipient.RecipientTypeDetails.Value -ilike '*Mailbox') -and ($x -in $GrantorsToConsider) -and ($Recipient.RecipientTypeDetails.Value -ine 'PublicFolderMailbox') -and (-not $Recipient.WhenSoftDeleted)) {
+ if (($Recipient.RecipientTypeDetails.Value -ilike '*Mailbox') -and ($x -in $GrantorsToConsider) -and ($Recipient.RecipientTypeDetails.Value -inotin @('PublicFolderMailbox', 'MonitoringMailbox')) -and (-not $Recipient.WhenSoftDeleted)) {
$tempQueue.enqueue($x)
}
}
@@ -3314,12 +3327,10 @@ try {
}
}
- if ($tempQueue.count -eq 0) {
- Write-Host (("`b" * 100) + (' {0:0000000} @{1}@' -f $tempQueueCount, $(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz'))) -NoNewline
- Write-Host
- } else {
- Write-Host
- Write-Host ' Not all grantor mailboxes have been checked. Enable DebugFile option and check log file.' -ForegroundColor red
+ Write-Host (("`b" * 100) + (' {0:0000000} @{1}@' -f $tempQueueCount, $(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz')))
+
+ if ($tempQueue.count -ne 0) {
+ Write-Host ' Not all grantor mailboxes have been checked. Enable ErrorFile and DebugFile options and check the log files.' -ForegroundColor red
}
foreach ($runspace in $runspaces) {
@@ -3817,12 +3828,10 @@ try {
}
}
- if ($tempQueue.count -eq 0) {
- Write-Host (("`b" * 100) + (' {0:0000000} @{1}@' -f $tempQueueCount, $(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz'))) -NoNewline
- Write-Host
- } else {
- Write-Host
- Write-Host ' Not all grantors have been checked. Enable DebugFile option and check log file.' -ForegroundColor red
+ Write-Host (("`b" * 100) + (' {0:0000000} @{1}@' -f $tempQueueCount, $(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz')))
+
+ if ($tempQueue.count -ne 0) {
+ Write-Host ' Not all grantors have been checked. Enable ErrorFile and DebugFile options and check the log files.' -ForegroundColor red
}
foreach ($runspace in $runspaces) {
@@ -4224,12 +4233,10 @@ try {
}
}
- if ($tempQueue.count -eq 0) {
- Write-Host (("`b" * 100) + (' {0:0000000} @{1}@' -f $tempQueueCount, $(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz'))) -NoNewline
- Write-Host
- } else {
- Write-Host
- Write-Host ' Not all grantors have been checked. Enable DebugFile option and check log file.' -ForegroundColor red
+ Write-Host (("`b" * 100) + (' {0:0000000} @{1}@' -f $tempQueueCount, $(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz')))
+
+ if ($tempQueue.count -ne 0) {
+ Write-Host ' Not all grantors have been checked. Enable ErrorFile and DebugFile options and check the log files.' -ForegroundColor red
}
foreach ($runspace in $runspaces) {
@@ -4529,12 +4536,10 @@ try {
}
}
- if ($tempQueue.count -eq 0) {
- Write-Host (("`b" * 100) + (' {0:0000000} @{1}@' -f $tempQueueCount, $(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz'))) -NoNewline
- Write-Host
- } else {
- Write-Host
- Write-Host ' Not all grantors have been checked. Enable DebugFile option and check log file.' -ForegroundColor red
+ Write-Host (("`b" * 100) + (' {0:0000000} @{1}@' -f $tempQueueCount, $(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz')))
+
+ if ($tempQueue.count -ne 0) {
+ Write-Host ' Not all grantors have been checked. Enable ErrorFile and DebugFile options and check the log files.' -ForegroundColor red
}
foreach ($runspace in $runspaces) {
@@ -4889,12 +4894,10 @@ try {
}
}
- if ($tempQueue.count -eq 0) {
- Write-Host (("`b" * 100) + (' {0:0000000} @{1}@' -f $tempQueueCount, $(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz'))) -NoNewline
- Write-Host
- } else {
- Write-Host
- Write-Host ' Not all grantors have been checked. Enable DebugFile option and check log file.' -ForegroundColor red
+ Write-Host (("`b" * 100) + (' {0:0000000} @{1}@' -f $tempQueueCount, $(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz')))
+
+ if ($tempQueue.count -ne 0) {
+ Write-Host ' Not all grantors have been checked. Enable ErrorFile and DebugFile options and check the log files.' -ForegroundColor red
}
foreach ($runspace in $runspaces) {
@@ -5436,12 +5439,10 @@ try {
}
}
- if ($tempQueue.count -eq 0) {
- Write-Host (("`b" * 100) + (' {0:0000000} @{1}@' -f $tempQueueCount, $(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz'))) -NoNewline
- Write-Host
- } else {
- Write-Host
- Write-Host ' Not all Public Folders have been checked. Enable DebugFile option and check log file.' -ForegroundColor red
+ Write-Host (("`b" * 100) + (' {0:0000000} @{1}@' -f $tempQueueCount, $(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz')))
+
+ if ($tempQueue.count -ne 0) {
+ Write-Host ' Not all Public Folders have been checked. Enable ErrorFile and DebugFile options and check the log files.' -ForegroundColor red
}
foreach ($runspace in $runspaces) {
@@ -5754,12 +5755,10 @@ try {
}
}
- if ($tempQueue.count -eq 0) {
- Write-Host (("`b" * 100) + (' {0:0000000} @{1}@' -f $tempQueueCount, $(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz'))) -NoNewline
- Write-Host
- } else {
- Write-Host
- Write-Host ' Not all recipients have been checked. Enable DebugFile option and check log file.' -ForegroundColor red
+ Write-Host (("`b" * 100) + (' {0:0000000} @{1}@' -f $tempQueueCount, $(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz')))
+
+ if ($tempQueue.count -ne 0) {
+ Write-Host ' Not all recipients have been checked. Enable ErrorFile and DebugFile options and check the log files.' -ForegroundColor red
}
foreach ($runspace in $runspaces) {
@@ -6016,12 +6015,10 @@ try {
}
}
- if ($tempQueue.count -eq 0) {
- Write-Host (("`b" * 100) + (' {0:0000000} @{1}@' -f $tempQueueCount, $(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz'))) -NoNewline
- Write-Host
- } else {
- Write-Host
- Write-Host ' Not all groups have been checked. Enable DebugFile option and check log file.' -ForegroundColor red
+ Write-Host (("`b" * 100) + (' {0:0000000} @{1}@' -f $tempQueueCount, $(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz')))
+
+ if ($tempQueue.count -ne 0) {
+ Write-Host ' Not all groups have been checked. Enable ErrorFile and DebugFile options and check the log files.' -ForegroundColor red
}
foreach ($runspace in $runspaces) {
@@ -6376,12 +6373,10 @@ try {
}
}
- if ($tempQueue.count -eq 0) {
- Write-Host (("`b" * 100) + (' {0:0000000} @{1}@' -f $tempQueueCount, $(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz'))) -NoNewline
- Write-Host
- } else {
- Write-Host
- Write-Host ' Not all management role group members have been checked. Enable DebugFile option and check log file.' -ForegroundColor red
+ Write-Host (("`b" * 100) + (' {0:0000000} @{1}@' -f $tempQueueCount, $(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz')))
+
+ if ($tempQueue.count -ne 0) {
+ Write-Host ' Not all management role group members have been checked. Enable ErrorFile and DebugFile options and check the log files.' -ForegroundColor red
}
foreach ($runspace in $runspaces) {
@@ -6731,12 +6726,10 @@ try {
}
}
- if ($tempQueue.count -eq 0) {
- Write-Host (("`b" * 100) + (' {0:0000000} @{1}@' -f $tempQueueCount, $(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz'))) -NoNewline
- Write-Host
- } else {
- Write-Host
- Write-Host ' Not all distribution groups have been checked. Enable DebugFile option and check log file.' -ForegroundColor red
+ Write-Host (("`b" * 100) + (' {0:0000000} @{1}@' -f $tempQueueCount, $(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz')))
+
+ if ($tempQueue.count -ne 0) {
+ Write-Host ' Not all distribution groups have been checked. Enable ErrorFile and DebugFile options and check the log files.' -ForegroundColor red
}
foreach ($runspace in $runspaces) {
@@ -7043,12 +7036,10 @@ try {
}
}
- if ($tempQueue.count -eq 0) {
- Write-Host (("`b" * 100) + (' {0:0000000} @{1}@' -f $tempQueueCount, $(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz'))) -NoNewline
- Write-Host
- } else {
- Write-Host
- Write-Host ' Not all files have been checked. Enable DebugFile option and check log file.' -ForegroundColor red
+ Write-Host (("`b" * 100) + (' {0:0000000} @{1}@' -f $tempQueueCount, $(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz')))
+
+ if ($tempQueue.count -ne 0) {
+ Write-Host ' Not all files have been checked. Enable ErrorFile and DebugFile options and check the log files.' -ForegroundColor red
}
foreach ($runspace in $runspaces) {
@@ -7306,12 +7297,10 @@ try {
}
}
- if ($tempQueue.count -eq 0) {
- Write-Host (("`b" * 100) + (' {0:0000000} @{1}@' -f $tempQueueCount, $(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz'))) -NoNewline
- Write-Host
- } else {
- Write-Host
- Write-Host ' Not all recipients have been checked. Enable DebugFile option and check log file.' -ForegroundColor red
+ Write-Host (("`b" * 100) + (' {0:0000000} @{1}@' -f $tempQueueCount, $(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz')))
+
+ if ($tempQueue.count -ne 0) {
+ Write-Host ' Not all recipients have been checked. Enable ErrorFile and DebugFile options and check the log files.' -ForegroundColor red
}
foreach ($runspace in $runspaces) {
@@ -7577,12 +7566,10 @@ try {
}
}
- if ($tempQueue.count -eq 0) {
- Write-Host (("`b" * 100) + (' {0:0000000} @{1}@' -f $tempQueueCount, $(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz'))) -NoNewline
- Write-Host
- } else {
- Write-Host
- Write-Host ' Not all Public Folders have been checked. Enable DebugFile option and check log file.' -ForegroundColor red
+ Write-Host (("`b" * 100) + (' {0:0000000} @{1}@' -f $tempQueueCount, $(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz')))
+
+ if ($tempQueue.count -ne 0) {
+ Write-Host ' Not all Public Folders have been checked. Enable ErrorFile and DebugFile options and check the log files.' -ForegroundColor red
}
foreach ($runspace in $runspaces) {
@@ -7842,12 +7829,10 @@ try {
}
}
- if ($tempQueue.count -eq 0) {
- Write-Host (("`b" * 100) + (' {0:0000000} @{1}@' -f $tempQueueCount, $(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz'))) -NoNewline
- Write-Host
- } else {
- Write-Host
- Write-Host ' Not all Management Role Groups have been checked. Enable DebugFile option and check log file.' -ForegroundColor red
+ Write-Host (("`b" * 100) + (' {0:0000000} @{1}@' -f $tempQueueCount, $(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz')))
+
+ if ($tempQueue.count -ne 0) {
+ Write-Host ' Not all Management Role Groups have been checked. Enable ErrorFile and DebugFile options and check the log files.' -ForegroundColor red
}
foreach ($runspace in $runspaces) {
@@ -8062,12 +8047,10 @@ try {
}
}
- if ($tempQueue.count -eq 0) {
- Write-Host (("`b" * 100) + (' {0:0000000} @{1}@' -f $tempQueueCount, $(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz'))) -NoNewline
- Write-Host
- } else {
- Write-Host
- Write-Host ' Not all files have been combined. Enable DebugFile option and check log file.' -ForegroundColor red
+ Write-Host (("`b" * 100) + (' {0:0000000} @{1}@' -f $tempQueueCount, $(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz')))
+
+ if ($tempQueue.count -ne 0) {
+ Write-Host ' Not all files have been checked. Enable ErrorFile and DebugFile options and check the log files.' -ForegroundColor red
}
foreach ($runspace in $runspaces) {
diff --git a/src/sample code/other samples/MemberOfRecurse.ps1 b/src/sample code/other samples/MemberOfRecurse.ps1
new file mode 100644
index 0000000..21bda34
--- /dev/null
+++ b/src/sample code/other samples/MemberOfRecurse.ps1
@@ -0,0 +1,581 @@
+[CmdletBinding(PositionalBinding = $false)]
+
+
+Param(
+ # If the first entry in the list is '*', all outgoing and bidirectional trusts in the current user's forest are considered.
+ # If a string starts with a minus or dash ('-domain-a.local'), the domain after the dash or minus is removed from the list (no wildcards allowed).
+ # All domains belonging to the Active Directory forest of the currently logged in user are always considered, but specific domains can be removed (`'*', '-childA1.childA.user.forest'`).
+ # When a cross-forest trust is detected by the '*' option, all domains belonging to the trusted forest are considered but specific domains can be removed (`'*', '-childX.trusted.forest'`).
+ # Default value: '*'
+ [string[]]$TrustsToCheckForGroups = @('*'),
+
+
+ [string[]]$AdObjectsToCheck = @(
+ # Accepted string formats (examples are in this order):
+ # Distinguished Name
+ # Canonical name
+ # Domain\SamAccountName (pre Windows 2000 logon name, NT4 logon name)
+ # User Principal Name (UPN)
+ # AD Object GUID (in curly braces)
+ # SID or SIDHistory
+ 'CN=Jeff Smith,CN=users,DC=example,DC=com',
+ 'example.com/Users/Hank Morgan',
+ 'EXAMPLE\GruberMa',
+ 'John.Carpenter@example.com',
+ '{95ee9fff-3436-11d1-b2b0-d15ae3ac8436}',
+ 'S-1-5-21-1180699209-877415012-3182924384-1004'
+ )
+)
+
+function CheckADConnectivity {
+ param (
+ [array]$CheckDomains,
+ [string]$CheckProtocolText,
+ [string]$Indent
+ )
+ [void][runspacefactory]::CreateRunspacePool()
+ $RunspacePool = [runspacefactory]::CreateRunspacePool(1, 25)
+ $RunspacePool.Open()
+
+ for ($DomainNumber = 0; $DomainNumber -lt $CheckDomains.count; $DomainNumber++) {
+ if ($($CheckDomains[$DomainNumber]) -eq '') {
+ continue
+ }
+
+ $PowerShell = [powershell]::Create()
+ $PowerShell.RunspacePool = $RunspacePool
+
+ [void]$PowerShell.AddScript( {
+ Param (
+ [string]$CheckDomain,
+ [string]$CheckProtocolText
+ )
+ $DebugPreference = 'Continue'
+ Write-Debug "Start(Ticks) = $((Get-Date).Ticks)"
+ Write-Output "$CheckDomain"
+ $Search = New-Object DirectoryServices.DirectorySearcher
+ $Search.PageSize = 1000
+ $Search.searchroot = New-Object System.DirectoryServices.DirectoryEntry("$($CheckProtocolText)://$CheckDomain")
+ $Search.filter = '(objectclass=user)'
+ try {
+ $null = ([ADSI]"$(($Search.FindOne()).path)")
+ Write-Output 'QueryPassed'
+ } catch {
+ Write-Output 'QueryFailed'
+ }
+ }).AddArgument($($CheckDomains[$DomainNumber])).AddArgument($CheckProtocolText)
+ $Object = New-Object 'System.Management.Automation.PSDataCollection[psobject]'
+ $Handle = $PowerShell.BeginInvoke($Object, $Object)
+ $temp = '' | Select-Object PowerShell, Handle, Object, StartTime, Done
+ $temp.PowerShell = $PowerShell
+ $temp.Handle = $Handle
+ $temp.Object = $Object
+ $temp.StartTime = $null
+ $temp.Done = $false
+ [void]$script:jobs.Add($Temp)
+ }
+ while (($script:jobs.Done | Where-Object { $_ -eq $false }).count -ne 0) {
+ foreach ($job in $script:jobs) {
+ if (($null -eq $job.StartTime) -and ($job.Powershell.Streams.Debug[0].Message -match 'Start')) {
+ $StartTicks = $job.powershell.Streams.Debug[0].Message -replace '[^0-9]'
+ $job.StartTime = [Datetime]::MinValue + [TimeSpan]::FromTicks($StartTicks)
+ }
+
+ if ($null -ne $job.StartTime) {
+ if ((($job.handle.IsCompleted -eq $true) -and ($job.Done -eq $false)) -or (($job.Done -eq $false) -and ((New-TimeSpan -Start $job.StartTime -End (Get-Date)).TotalSeconds -ge 5))) {
+ $data = $job.Object[0..$(($job.object).count - 1)]
+ Write-Host "$Indent$($data[0])"
+ if ($data -icontains 'QueryPassed') {
+ Write-Host "$Indent $CheckProtocolText query successful"
+ $returnvalue = $true
+ } else {
+ Write-Host "$Indent $CheckProtocolText query failed, remove domain from list." -ForegroundColor Red
+ Write-Host "$Indent If this error is permanent, check firewalls, DNS and AD trust. Consider parameter TrustsToCheckForGroups." -ForegroundColor Red
+
+ if ($TrustsToCheckForGroups -icontains $data[0]) {
+ $TrustsToCheckForGroups.remove($data[0])
+ }
+
+ $LookupDomainsToTrusts.remove($data[0])
+
+ $returnvalue = $false
+ }
+ $job.Done = $true
+ }
+ }
+ }
+ }
+ return $returnvalue
+}
+
+
+function ConvertSidToGuidAndFillResult {
+ param (
+ $sid,
+ $AdObjectToCheckDn,
+ $indent
+ )
+
+ try {
+ $objTrans = New-Object -ComObject 'NameTranslate'
+ $objNT = $objTrans.GetType()
+ $null = $objNT.InvokeMember('Init', 'InvokeMethod', $Null, $objTrans, (3, $null))
+ $null = $objNT.InvokeMember('Set', 'InvokeMethod', $Null, $objTrans, (8, "$($sid)"))
+ $GroupGuid = $($objNT.InvokeMember('Get', 'InvokeMethod', $Null, $objTrans, 7).trimstart('{').trimend('}'))
+ Write-Verbose "$indent$($GroupGuid)"
+
+ $script:MemberOfRecurse += New-Object PSObject -Property (
+ [ordered]@{
+ 'Original object' = $ADObjectToCheck.ToString()
+ 'MemberOf recurse group objectGUID' = $GroupGuid.ToString()
+ 'MemberOf recurse group canonicalName' = $objNT.InvokeMember('Get', 'InvokeMethod', $Null, $objTrans, 2).ToString()
+ }
+ )
+
+ $script:SIDsToCheckInTrusts += $sid
+ } catch {
+ try {
+ # Non-domain-specific well-known SID with a domain specific ObjectGUID?
+ $SidHex = @()
+ $ot = New-Object System.Security.Principal.SecurityIdentifier($Sid)
+ $c = New-Object 'byte[]' $ot.BinaryLength
+ $ot.GetBinaryForm($c, 0)
+ foreach ($char in $c) {
+ $SidHex += $('\{0:x2}' -f $char)
+ }
+
+ $local:Search = New-Object DirectoryServices.DirectorySearcher
+ $local:Search.PageSize = 1000
+ $local:Search.searchroot = New-Object System.DirectoryServices.DirectoryEntry("LDAP://$(($($AdObjectToCheckDn) -split ',DC=')[1..999] -join '.')")
+ $local:Search.filter = "(&(objectclass=group)(objectsid=$($SidHex -join '')))"
+
+ @('canonicalname', 'objectguid') | ForEach-Object {
+ if (-not $local:search.PropertiesToLoad.Contains($_)) {
+ $null = $local:search.PropertiesToLoad.add($_)
+ }
+ }
+ $Group = $local:search.FindOne()
+ $GroupGuid = [guid]::new($Group.Properties.objectguid[0]).guid
+ Write-Verbose "$indent$($GroupGuid)"
+ $script:MemberOfRecurse += New-Object PSObject -Property (
+ [ordered]@{
+ 'Original object' = $ADObjectToCheck.ToString()
+ 'MemberOf recurse group objectGUID' = $GroupGuid.ToString()
+ 'MemberOf recurse group canonicalName' = $Group.Properties.canonicalname[0].ToString()
+ }
+ )
+ } catch {
+ Write-Verbose "$indent$($_ | Out-String)"
+ }
+ }
+}
+
+
+# Setup
+$script:jobs = New-Object System.Collections.ArrayList
+Add-Type -AssemblyName System.DirectoryServices.AccountManagement
+$Search = New-Object DirectoryServices.DirectorySearcher
+$Search.PageSize = 1000
+$script:MemberOfRecurse = @()
+
+
+Write-Host "Enumerate domains @$(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz')@"
+$x = $TrustsToCheckForGroups
+[System.Collections.ArrayList]$TrustsToCheckForGroups = @()
+$LookupDomainsToTrusts = @{}
+# Users own domain/forest is always included
+try {
+ $objTrans = New-Object -ComObject 'NameTranslate'
+ $objNT = $objTrans.GetType()
+ $objNT.InvokeMember('Init', 'InvokeMethod', $Null, $objTrans, (3, $Null)) # 3 = ADS_NAME_INITTYPE_GC
+ $objNT.InvokeMember('Set', 'InvokeMethod', $Null, $objTrans, (12, $(([System.Security.Principal.WindowsIdentity]::GetCurrent()).User.Value))) # 12 = ADS_NAME_TYPE_SID_OR_SID_HISTORY_NAME
+ $UserForest = (([ADSI]"LDAP://$(($objNT.InvokeMember('Get', 'InvokeMethod', $Null, $objTrans, 1) -split ',DC=')[1..999] -join '.')/RootDSE").rootDomainNamingContext -replace ('DC=', '') -replace (',', '.')).tolower()
+
+ if ($UserForest -ne '') {
+ Write-Host " User forest: $UserForest"
+ $TrustsToCheckForGroups += $UserForest.tolower()
+ $LookupDomainsToTrusts.add($UserForest, $UserForest)
+
+ $Search.SearchRoot = "GC://$($UserForest)"
+ $Search.Filter = '(ObjectClass=trustedDomain)'
+
+ $TrustedDomains = @(
+ @($Search.FindAll()) | Sort-Object @{Expression = {
+ $TemporaryArray = @($_.properties.name.Split('.'))
+ [Array]::Reverse($TemporaryArray)
+ $TemporaryArray
+ }
+ }
+ )
+
+ # Internal trusts
+ foreach ($TrustedDomain in $TrustedDomains) {
+ if (($TrustedDomain.properties.trustattributes -eq 32) -and ($TrustedDomain.properties.name -ine $UserForest) -and (-not $LookupDomainsToTrusts.ContainsKey($TrustedDomain.properties.name.tolower()))) {
+ Write-Host " Child domain: $($TrustedDomain.properties.name.tolower())"
+ $LookupDomainsToTrusts.add($TrustedDomain.properties.name.tolower(), $UserForest)
+ }
+ }
+
+ # Other trusts
+ if ($x[0] -eq '*') {
+ foreach ($TrustedDomain in $TrustedDomains) {
+ # No intra-forest trusts, only bidirectional trusts and outbound trusts
+ if (($($TrustedDomain.properties.trustattributes) -ne 32) -and (($($TrustedDomain.properties.trustdirection) -eq 2) -or ($($TrustedDomain.properties.trustdirection) -eq 3)) ) {
+ if ($TrustedDomain.properties.trustattributes -eq 8) {
+ # Cross-forest trust
+ Write-Host " Trusted forest: $($TrustedDomain.properties.name.tolower())"
+ if ("-$($TrustedDomain.properties.name)" -iin $x) {
+ Write-Host " Ignoring because of TrustsToCheckForGroups entry '-$($TrustedDomain.properties.name.tolower())'"
+ } else {
+ $TrustsToCheckForGroups += $TrustedDomain.properties.name.tolower()
+ $LookupDomainsToTrusts.add($TrustedDomain.properties.name.tolower(), $TrustedDomain.properties.name.tolower())
+ }
+
+ $temp = @(
+ @(@(Resolve-DnsName -Name "_gc._tcp.$($TrustedDomain.properties.name)" -Type srv).nametarget) | ForEach-Object { ($_ -split '\.')[1..999] -join '.' } | Where-Object { $_ -ine $TrustedDomain.properties.name } | Select-Object -Unique | Sort-Object @{Expression = {
+ $TemporaryArray = @($_.Split('.'))
+ [Array]::Reverse($TemporaryArray)
+ $TemporaryArray
+ }
+ }
+ )
+
+ $temp | ForEach-Object {
+ Write-Host " Child domain: $($_.tolower())"
+ $LookupDomainsToTrusts.add($_.tolower(), $TrustedDomain.properties.name.tolower())
+ }
+ } else {
+ # No cross-forest trust
+ Write-Host " Trusted domain: $($TrustedDomain.properties.name)"
+ if ("-$($TrustedDomain.properties.name)" -iin $x) {
+ Write-Host " Ignoring because of TrustsToCheckForGroups entry '-$($TrustedDomain.properties.name)'"
+ } else {
+ $TrustsToCheckForGroups += $TrustedDomain.properties.name.tolower()
+ $LookupDomainsToTrusts.add($TrustedDomain.properties.name.tolower(), $TrustedDomain.properties.name.tolower())
+ }
+ }
+ }
+ }
+ }
+
+ for ($a = 0; $a -lt $x.Count; $a++) {
+ if (($a -eq 0) -and ($x[$a] -ieq '*')) {
+ continue
+ }
+
+ $y = ($x[$a] -replace ('DC=', '') -replace (',', '.')).tolower()
+
+ if ($y -eq $x[$a]) {
+ Write-Host " User provided trusted domain/forest: $y"
+ } else {
+ Write-Host " User provided trusted domain/forest: $($x[$a]) -> $y"
+ }
+
+ if (($a -ne 0) -and ($x[$a] -ieq '*')) {
+ Write-Host ' Entry * is only allowed at first position in list. Skip entry.' -ForegroundColor Red
+ continue
+ }
+
+ if ($y -match '[^a-zA-Z0-9.-]') {
+ Write-Host ' Allowed characters are a-z, A-Z, ., -. Skip entry.' -ForegroundColor Red
+ continue
+ }
+
+ if (-not ($y.StartsWith('-'))) {
+ if ($TrustsToCheckForGroups -icontains $y) {
+ Write-Host ' Trusted domain/forest already in list.' -ForegroundColor Yellow
+ } else {
+ if ($TrustedDomains.properties.name -icontains $y) {
+ foreach ($TrustedDomain in @($TrustedDomains | Where-Object { $_.properties.name -ieq $y })) {
+ # No intra-forest trusts, only bidirectional trusts and outbound trusts
+ if (($($TrustedDomain.properties.trustattributes) -ne 32) -and (($($TrustedDomain.properties.trustdirection) -eq 2) -or ($($TrustedDomain.properties.trustdirection) -eq 3)) ) {
+ if ($TrustedDomain.properties.trustattributes -eq 8) {
+ # Cross-forest trust
+ Write-Host " Trusted forest: $($TrustedDomain.properties.name)"
+ if ("-$($TrustedDomain.properties.name)" -iin $x) {
+ Write-Host " Ignoring because of TrustsToCheckForGroups entry '-$($TrustedDomain.properties.name)'"
+ } else {
+ $TrustsToCheckForGroups += $TrustedDomain.properties.name.tolower()
+ $LookupDomainsToTrusts.add($TrustedDomain.properties.name.tolower(), $TrustedDomain.properties.name.tolower())
+ }
+
+ $temp = @(
+ @(@(Resolve-DnsName -Name "_gc._tcp.$($TrustedDomain.properties.name)" -Type srv).nametarget) | ForEach-Object { ($_ -split '\.')[1..999] -join '.' } | Where-Object { $_ -ine $TrustedDomain.properties.name } | Select-Object -Unique | Sort-Object @{Expression = {
+ $TemporaryArray = @($_.Split('.'))
+ [Array]::Reverse($TemporaryArray)
+ $TemporaryArray
+ }
+ }
+ )
+
+ $temp | ForEach-Object {
+ Write-Host " Child domain: $($_.tolower())"
+ $LookupDomainsToTrusts.add($_.tolower(), $TrustedDomain.properties.name.tolower())
+ }
+ } else {
+ # No cross-forest trust
+ Write-Host " Trusted domain: $($TrustedDomain.properties.name)"
+ if ("-$($TrustedDomain.properties.name)" -iin $x) {
+ Write-Host " Ignoring because of TrustsToCheckForGroups entry '-$($TrustedDomain.properties.name)'"
+ } else {
+ $TrustsToCheckForGroups += $TrustedDomain.properties.name.tolower()
+ $LookupDomainsToTrusts.add($TrustedDomain.properties.name.tolower(), $TrustedDomain.properties.name.tolower())
+ }
+ }
+ }
+ }
+ } else {
+ Write-Host ' No trust to this domain/forest found.' -ForegroundColor Yellow
+ }
+ }
+ } else {
+ Write-Host ' Remove trusted domain/forest.'
+ for ($z = 0; $z -lt $TrustsToCheckForGroups.Count; $z++) {
+ if ($TrustsToCheckForGroups[$z] -ieq $y.substring(1)) {
+ $TrustsToCheckForGroups.RemoveAt($z)
+ $LookupDomainsToTrusts = $LookupDomainsToTrusts.GetEnumerator() | Where-Object { $_.Value -ine $y.substring(1) }
+ }
+ }
+ }
+ }
+
+ $TrustsToCheckForGroups = @($TrustsToCheckForGroups | Where-Object { $_ })
+
+
+ Write-Host
+ Write-Host "Check trusts for open LDAP port and connectivity @$(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz')@"
+ CheckADConnectivity @(@(@($TrustsToCheckForGroups) + @($LookupDomainsToTrusts.GetEnumerator() | ForEach-Object { $_.Name })) | Select-Object -Unique) 'LDAP' ' ' | Out-Null
+
+
+ Write-Host
+ Write-Host "Check trusts for open Global Catalog port and connectivity @$(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz')@"
+ CheckADConnectivity $TrustsToCheckForGroups 'GC' ' ' | Out-Null
+ }
+} catch {
+ $y = ''
+ Write-Verbose $error[0]
+ Write-Host ' Problem connecting to logged in user''s Active Directory (see verbose stream for error message).' -ForegroundColor Yellow
+}
+
+
+
+Write-Host
+Write-Host "Enumerate group membership @$(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz')@"
+foreach ($AdObjectToCheck in $AdObjectsToCheck) {
+ Write-Host " '$($AdObjectToCheck)' @$(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz')@"
+
+ try {
+ $AdObjectToCheckDn = $null
+ $AdObjectToCheckGuid = $null
+
+ # Get DN of AD object
+ $objTrans = New-Object -ComObject 'NameTranslate'
+ $objNT = $objTrans.GetType()
+ $null = $objNT.InvokeMember('Init', 'InvokeMethod', $Null, $objTrans, (3, $null))
+ $null = $objNT.InvokeMember('Set', 'InvokeMethod', $Null, $objTrans, (8, "$($AdObjectToCheck)"))
+ $AdObjectToCheckDn = $objNT.InvokeMember('Get', 'InvokeMethod', $Null, $objTrans, 1)
+ $AdObjectToCheckGuid = $objNT.InvokeMember('Get', 'InvokeMethod', $Null, $objTrans, 7).trimstart('{').trimend('}')
+ $script:MemberOfRecurse += New-Object PSObject -Property (
+ [ordered]@{
+ 'Original object' = $ADObjectToCheck.ToString()
+ 'MemberOf recurse group objectGUID' = $ADObjectToCheckGuid.ToString()
+ 'MemberOf recurse group canonicalName' = $objNT.InvokeMember('Get', 'InvokeMethod', $Null, $objTrans, 2).ToString()
+ }
+ )
+
+ $script:SIDsToCheckInTrusts = @()
+
+ Write-Verbose " $($LookupDomainsToTrusts[$(($($AdObjectToCheckDn) -split ',DC=')[1..999] -join '.')]) @$(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz')@"
+
+ # Security groups, no matter if enabled for mail or not
+ Write-Verbose " Security groups via LDAP query of tokengroups attribute @$(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz')@"
+ $UserAccount = [ADSI]"LDAP://$($AdObjectToCheckDn)"
+ $UserAccount.GetInfoEx(@('tokengroups'), 0)
+ foreach ($sidBytes in $UserAccount.Properties.tokengroups) {
+ $sid = (New-Object System.Security.Principal.SecurityIdentifier($sidbytes, 0)).value
+ Write-Verbose " $sid"
+ ConvertSidToGuidAndFillResult $sid $AdObjectToCheckDn ' '
+ }
+
+ # Distribution groups (static only)
+ Write-Verbose " Distribution groups (static only) via GC query @$(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz')@"
+ $Search.searchroot = New-Object System.DirectoryServices.DirectoryEntry("GC://$(($($AdObjectToCheckDn) -split ',DC=')[1..999] -join '.')")
+ $Search.filter = "(&(objectClass=group)(!(groupType:1.2.840.113556.1.4.803:=2147483648))(member:1.2.840.113556.1.4.1941:=$($AdObjectToCheckDn)))"
+ foreach ($DistributionGroup in $search.findall()) {
+ if ($DistributionGroup.properties.objectsid) {
+ $sid = (New-Object System.Security.Principal.SecurityIdentifier $($DistributionGroup.properties.objectsid), 0).value
+ Write-Verbose " $sid"
+ ConvertSidToGuidAndFillResult $sid $AdObjectToCheckDn ' '
+ }
+
+ foreach ($SidHistorySid in @($DistributionGroup.properties.sidhistory | Where-Object { $_ })) {
+ $sid = (New-Object System.Security.Principal.SecurityIdentifier $SidHistorySid, 0).value
+ Write-Verbose " SidHistory: $sid"
+ $script:SIDsToCheckInTrusts += $sid
+ }
+ }
+
+ # Domain local groups
+ Write-Verbose " Domain local groups via LDAP query @$(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz')@"
+ foreach ($DomainToCheckForDomainLocalGroups in @(($LookupDomainsToTrusts.GetEnumerator() | Where-Object { $_.Value -ieq $LookupDomainsToTrusts[$(($($AdObjectToCheckDn) -split ',DC=')[1..999] -join '.')] }).name)) {
+ Write-Verbose " $($DomainToCheckForDomainLocalGroups) @$(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz')@"
+ $Search.searchroot = New-Object System.DirectoryServices.DirectoryEntry("LDAP://$($DomainToCheckForDomainLocalGroups)")
+ $Search.filter = "(&(objectClass=group)(groupType:1.2.840.113556.1.4.803:=4)(member:1.2.840.113556.1.4.1941:=$($AdObjectToCheckDn)))"
+ foreach ($LocalGroup in $search.findall()) {
+ if ($LocalGroup.properties.objectsid) {
+ $sid = (New-Object System.Security.Principal.SecurityIdentifier $($LocalGroup.properties.objectsid), 0).value
+ Write-Verbose " $sid"
+ ConvertSidToGuidAndFillResult $sid $AdObjectToCheckDn ' '
+ }
+
+ foreach ($SidHistorySid in @($LocalGroup.properties.sidhistory | Where-Object { $_ })) {
+ $sid = (New-Object System.Security.Principal.SecurityIdentifier $SidHistorySid, 0).value
+ Write-Verbose " SidHistory: $sid"
+ $script:SIDsToCheckInTrusts += $sid
+ }
+ }
+ }
+
+ # Loop through all domains to check if the mailbox account has a group membership there
+ # Across a trust, a user can only be added to a domain local group.
+ # Domain local groups can not be used outside their own domain, so we don't need to query recursively
+ # But when it's a cross-forest trust, we need to query every every domain on that other side of the trust
+ # This is handled before by adding every single domain of a cross-forest trusted forest to $TrustsToCheckForGroups
+ if ($script:SIDsToCheckInTrusts.count -gt 0) {
+ $script:SIDsToCheckInTrusts = @($script:SIDsToCheckInTrusts | Select-Object -Unique)
+ $LdapFilterSIDs = '(|'
+
+ foreach ($SidToCheckInTrusts in $script:SIDsToCheckInTrusts) {
+ try {
+ $SidHex = @()
+ $ot = New-Object System.Security.Principal.SecurityIdentifier($SidToCheckInTrusts)
+ $c = New-Object 'byte[]' $ot.BinaryLength
+ $ot.GetBinaryForm($c, 0)
+ foreach ($char in $c) {
+ $SidHex += $('\{0:x2}' -f $char)
+ }
+ # Foreign Security Principals have an objectSID, but no sIDHistory
+ # The sIDHistory of the current object is part of $script:SIDsToCheckInTrusts and therefore also considered in $LdapFilterSIDs
+ $LdapFilterSIDs += ('(objectsid=' + $($SidHex -join '') + ')')
+ } catch {
+ Write-Host ' Error creating LDAP filter for search across trusts.' -ForegroundColor Red
+ $error[0]
+ }
+ }
+ $LdapFilterSIDs += ')'
+ } else {
+ $LdapFilterSIDs = ''
+ }
+
+ if ($LdapFilterSids -ilike '*(objectsid=*') {
+ # Across each trust, search for all Foreign Security Principals matching a SID from our list
+ foreach ($TrustToCheckForFSPs in @(($LookupDomainsToTrusts.GetEnumerator() | Where-Object { $_.Value -ine $LookupDomainsToTrusts[$(($($AdObjectToCheckDn) -split ',DC=')[1..999] -join '.')] }).value | Select-Object -Unique)) {
+ Write-Host " $($TrustToCheckForFSPs) @$(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz')@"
+ $Search.searchroot = New-Object System.DirectoryServices.DirectoryEntry("GC://$($TrustToCheckForFSPs)")
+ $Search.filter = "(&(objectclass=foreignsecurityprincipal)$LdapFilterSIDs)"
+
+ foreach ($fsp in $Search.FindAll()) {
+ if (($fsp.path -ne '') -and ($null -ne $fsp.path)) {
+ # A Foreign Security Principal (FSP) is created in each (sub)domain in which it is granted permissions
+ # A FSP it can only be member of a domain local group - so we set the searchroot to the (sub)domain of the Foreign Security Principal
+ # FSPs have on tokengroups attribute, which would not contain domain local groups anyhow
+ # member:1.2.840.113556.1.4.1941:= (LDAP_MATCHING_RULE_IN_CHAIN) returns groups containing a specific DN as member, incl. nesting
+ Write-Verbose " Found ForeignSecurityPrincipal $($fsp.properties.cn) in $((($fsp.path -split ',DC=')[1..999] -join '.'))"
+
+ if ($((($fsp.path -split ',DC=')[1..999] -join '.')) -iin @(($LookupDomainsToTrusts.GetEnumerator() | Where-Object { $_.Value -ine $LookupDomainsToTrusts[$(($($AdObjectToCheckDn) -split ',DC=')[1..999] -join '.')] }).name)) {
+ try {
+ $Search.searchroot = New-Object System.DirectoryServices.DirectoryEntry("LDAP://$((($fsp.path -split ',DC=')[1..999] -join '.'))")
+ $Search.filter = "(&(objectClass=group)(groupType:1.2.840.113556.1.4.803:=4)(member:1.2.840.113556.1.4.1941:=$($fsp.Properties.distinguishedname)))"
+
+ foreach ($group in $Search.findall()) {
+ $sid = (New-Object System.Security.Principal.SecurityIdentifier $($group.properties.objectsid), 0).value
+ Write-Verbose " $sid"
+ ConvertSidToGuidAndFillResult $sid $AdObjectToCheckDn ' '
+
+ foreach ($SidHistorySid in @($group.properties.sidhistory | Where-Object { $_ })) {
+ $sid = (New-Object System.Security.Principal.SecurityIdentifier $SidHistorySid, 0).value
+ Write-Verbose " SidHistory: $sid"
+ }
+ }
+ } catch {
+ Write-Host " Error: $($error[0].exception)" -ForegroundColor red
+ }
+ } else {
+ Write-Verbose " Ignoring, because '$($fsp.path)' is not part of a trust in TrustsToCheckForGroups."
+ }
+ }
+ }
+ }
+ }
+ } catch {
+ @(
+ 'ERROR',
+ "$($_ | Out-String)",
+ "AdObjectToCheck: $($AdObjectToCheck)",
+ "AdObjectToCheckDn: $($AdObjectToCheckDn)",
+ "AdObjectToCheckGuid $($AdObjectToCheckGuid)"
+ ) | ForEach-Object {
+ Write-Host " $_" -ForegroundColor Red
+ }
+ }
+}
+
+
+Write-Host
+Write-Host "Final MemberOfRecurse result @$(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz')@"
+$script:MemberOfRecurse = $script:MemberOfRecurse | Select-Object -Property * -Unique | Sort-Object -Property 'Original object', 'MemberOf recurse group canonicalName', 'MemberOf recurse group objectGUID'
+$script:MemberOfRecurse | Format-Table
+
+
+Write-Host
+Write-Host "Configure and start Export-RecipientPermissions (demo) @$(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz')@"
+$params = @{
+ ExportFromOnPrem = $true
+ UseDefaultCredential = $true
+
+ ExportMailboxAccessRights = $true
+ ExportMailboxAccessRightsSelf = $false
+ ExportMailboxAccessRightsInherited = $true
+ ExportMailboxFolderPermissions = $true
+ ExportMailboxFolderPermissionsAnonymous = $false
+ ExportMailboxFolderPermissionsDefault = $false
+ ExportMailboxFolderPermissionsOwnerAtLocal = $false
+ ExportMailboxFolderPermissionsMemberAtLocal = $false
+ ExportSendAs = $true
+ ExportSendAsSelf = $false
+ ExportSendOnBehalf = $true
+ ExportManagedBy = $true
+ ExportLinkedMasterAccount = $true
+ ExportPublicFolderPermissions = $true
+ ExportPublicFolderPermissionsAnonymous = $false
+ ExportPublicFolderPermissionsDefault = $false
+ ExportForwarders = $true
+ ExportManagementRoleGroupMembers = $true
+ ExportDistributionGroupMembers = 'None'
+ ExportGroupMembersRecurse = $false
+ ExpandGroups = $false
+ ExportGuids = $true
+ ExportGrantorsWithNoPermissions = $false
+ ExportTrustees = 'All'
+
+ RecipientProperties = @()
+ GrantorFilter = $null
+ TrusteeFilter = $null
+ ExportFileFilter = "if (`$ExportFileLine.'Trustee AD ObjectGUID' -iin $('@(''' + (@($script:MemberOfRecurse.'MemberOf recurse group objectGUID') -join ''', ''') + ''')')) { `$true } else { `$false }"
+
+ ExportFile = '.\export\Export-RecipientPermissions_Result_MemberOfRecurse.csv'
+ ErrorFile = '.\export\Export-RecipientPermissions_Error_MemberOfRecurse.csv'
+ DebugFile = $null
+
+ verbose = $false
+}
+
+Write-Host ' Parameters ($params hashtable)'
+$params | Format-Table
+
+Write-Host ' Run Export-RecipientPermissions with parameters from $params (demo)'
+Write-Host " '& ..\..\Export-RecipientPermissions.ps1 @params'"
+
+
+Write-Host
+Write-Host "End script @$(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz')@"
\ No newline at end of file