diff --git a/README.md b/README.md index 8fceeb6..4252620 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@

PSADTree

-Tree like cmdlets for Active Directory Principals! +Tree-like cmdlets for Active Directory principals!

[![build](https://github.com/santisq/PSADTree/actions/workflows/ci.yml/badge.svg)](https://github.com/santisq/PSADTree/actions/workflows/ci.yml) @@ -10,7 +10,8 @@
-PSADTree is a PowerShell Module with cmdlets that emulate the [`tree` command](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/tree) for Active Directory Principals. +PSADTree is a PowerShell module that brings `tree`-like visualization to Active Directory group structures — perfect for spotting nested membership and circular references at a glance. + This Module currently includes two cmdlets: - [Get-ADTreeGroupMember](docs/en-US/Get-ADTreeGroupMember.md) for AD Group Members. @@ -42,7 +43,9 @@ Set-Location ./PSADTree ## Requirements -This Module uses the [`System.DirectoryServices.AccountManagement` Namespace](https://learn.microsoft.com/en-us/dotnet/api/system.directoryservices.accountmanagement?view=dotnet-plat-ext-7.0) to query Active Directory, its System Requirement is __Windows OS__ and is compatible with __Windows PowerShell v5.1__ or [__PowerShell 7+__](https://github.com/PowerShell/PowerShell). +- Windows operating system (uses Windows-specific Active Directory .NET APIs) +- PowerShell 5.1 (Windows PowerShell) or PowerShell 7.4+ +- Read permissions on the Active Directory objects you want to query ## Usage @@ -110,9 +113,70 @@ ChildDomain group ├── TestGroup005 ↔ Processed G ChildDomain group └── TestGroup006 ↔ Processed Group ``` +### Retrieve and inspect additional properties + +```powershell +PS ..\PSADTree> $tree = Get-ADTreeGroupMember TestGroup001 -Properties * +PS ..\PSADTree> $user = $tree | Where-Object ObjectClass -EQ user | Select-Object -First 1 +PS ..\PSADTree> $user.AdditionalProperties + +Key Value +--- ----- +objectClass {top, person, organizationalPerson, user} +cn John Doe +sn Doe +c US +l Elizabethtown +st NC +title Accounting Specialist +postalCode 28337 +physicalDeliveryOfficeName Accounting Office +telephoneNumber 910-862-8720 +givenName John +initials B +distinguishedName CN=John Doe,OU=Accounting,OU=Mylab Users,DC=mylab,DC=local +instanceType 4 +whenCreated 9/18/2025 4:53:58 PM +whenChanged 9/18/2025 4:53:58 PM +displayName John Doe +uSNCreated 19664 +memberOf CN=TestGroup001,OU=Mylab Groups,DC=mylab,DC=local +uSNChanged 19668 +department Accounting +company Active Directory Pro +streetAddress 2628 Layman Avenue +nTSecurityDescriptor System.DirectoryServices.ActiveDirectorySecurity +name John Doe +objectGUID {225, 241, 160, 222…} +userAccountControl 512 +badPwdCount 0 +codePage 0 +countryCode 0 +badPasswordTime 0 +lastLogoff 0 +lastLogon 0 +pwdLastSet 0 +primaryGroupID 513 +objectSid {1, 5, 0, 0…} +accountExpires 9223372036854775807 +logonCount 0 +sAMAccountName john.doe +sAMAccountType 805306368 +userPrincipalName john.doe@mylab.com +objectCategory CN=Person,CN=Schema,CN=Configuration,DC=mylab,DC=local +dSCorePropagationData 1/1/1601 12:00:00 AM +mail john.doe@mylab.com +``` + +>[!TIP] +> +> - `-Properties *` retrieves __all__ available attributes from each object. +> - Use friendly names (e.g. `Country` → `c`, `City` → `l`, `PasswordLastSet` → `pwdLastSet`) or raw LDAP names — the key in `.AdditionalProperties` matches what you requested. +> - See the full list of supported friendly names in the [source code `LdapMap.cs`](https://github.com/santisq/PSADTree/tree/main/src/PSADTree/LdapMap.cs) + ### Get group members recursively, include only groups and display all processed groups -The `-Recursive` switch indicates that the cmdlet should traverse all the group hierarchy. +The `-Recursive` switch indicates that the cmdlet should traverse traverse the entire group hierarchy. The `-Group` switch limits the members tree view to nested groups only. By default, previously processed groups will be marked as _"Processed Group"_ and their hierarchy will not be displayed. The `-ShowAll` switch indicates that the cmdlet should display the hierarchy of all previously processed groups. diff --git a/docs/en-US/Get-ADTreeGroupMember.md b/docs/en-US/Get-ADTreeGroupMember.md index 6390bf7..341ea2f 100644 --- a/docs/en-US/Get-ADTreeGroupMember.md +++ b/docs/en-US/Get-ADTreeGroupMember.md @@ -9,7 +9,7 @@ schema: 2.0.0 ## SYNOPSIS -`tree` like cmdlet for Active Directory group members. +Displays Active Directory group members in a tree-like structure, including nested members and circular group detection. ## SYNTAX @@ -17,13 +17,14 @@ schema: 2.0.0 ```powershell Get-ADTreeGroupMember - [-Group] [-Identity] [-Server ] [-Credential ] [-Depth ] [-ShowAll] + [-Group] [-Exclude ] + [-Properties ] [] ``` @@ -31,73 +32,100 @@ Get-ADTreeGroupMember ```powershell Get-ADTreeGroupMember - [-Group] [-Identity] [-Server ] [-Credential ] [-Recursive] [-ShowAll] + [-Group] [-Exclude ] + [-Properties ] [] ``` ## DESCRIPTION -The `Get-ADTreeGroupMember` cmdlet gets the Active Directory members of a specified group and displays them in a tree like structure. The members of a group can be users, groups, computers and service accounts. This cmdlet also helps identifying Circular Nested Groups. +The `Get-ADTreeGroupMember` cmdlet retrieves the members of an Active Directory group—including nested members—and displays them in a tree-like hierarchical structure. Group members can include users, groups, computers, and service accounts. + +This format makes it easy to visualize group nesting and quickly identify circular nested groups (infinite recursion loops caused by circular membership). ## EXAMPLES ### Example 1: Get the members of a group ```powershell -PS ..\PSADTree\> Get-ADTreeGroupMember TestGroup001 +PS> Get-ADTreeGroupMember TestGroup001 ``` -By default, this cmdlet uses `-Depth` with a default value of `3`. +By default, this cmdlet uses `-Depth 3`. ### Example 2: Get the members of a group recursively ```powershell -PS ..\PSADTree\> Get-ADTreeGroupMember TestGroup001 -Recursive +PS> Get-ADTreeGroupMember TestGroup001 -Recursive ``` +The `-Recursive` switch retrieves all nested members regardless of depth. + ### Example 3: Get the members of all groups under an Organizational Unit ```powershell -PS ..\PSADTree\> Get-ADGroup -Filter * -SearchBase 'OU=myOU,DC=myDomain,DC=com' | +PS> Get-ADGroup -Filter * -SearchBase 'OU=myOU,DC=myDomain,DC=com' | Get-ADTreeGroupMember ``` -You can pipe strings containing an identity to this cmdlet. [__`ADGroup`__](https://learn.microsoft.com/en-us/dotnet/api/microsoft.activedirectory.management.adgroup?view=activedirectory-management-10.0) instances piped to this cmdlet are also supported. +You can pipe strings containing group identity to this cmdlet or [__`ADGroup`__](https://learn.microsoft.com/en-us/dotnet/api/microsoft.activedirectory.management.adgroup) objects to this cmdlet. -### Example 4: Find any Circular Nested Groups from previous example +### Example 4: Find circular nested groups ```powershell -PS ..\PSADTree\> Get-ADComputer -Filter * -SearchBase 'OU=myOU,DC=myDomain,DC=com' | +PS> Get-ADComputer -Filter * -SearchBase 'OU=myOU,DC=myDomain,DC=com' | Get-ADTreeGroupMember -Recursive -Group | Where-Object IsCircular ``` -The `-Group` switch limits the members tree view to nested groups only. +The `-Group` switch limits output to nested groups only (excluding users, computers, etc.). -### Example 5: Get group members in a different Domain +### Example 5: Get group members in a different domain ```powershell -PS ..\PSADTree\> Get-ADTreeGroupMember TestGroup001 -Server otherDomain +PS> Get-ADTreeGroupMember TestGroup001 -Server otherDomain.com ``` -### Example 6: Get group members including processed groups +### Example 6: Display hierarchy even for previously processed groups ```powershell -PS ..\PSADTree\> Get-ADTreeGroupMember TestGroup001 -ShowAll +PS> Get-ADTreeGroupMember TestGroup001 -ShowAll ``` -By default, previously processed groups will be marked as _"Processed Group"_ and their hierarchy will not be displayed. -The `-ShowAll` switch indicates that the cmdlet should display the hierarchy of all previously processed groups. +By default, groups that have already been processed (to avoid redundant recursion) are marked as _"Processed Group"_ and their subtree is not expanded. +The `-ShowAll` switch forces full hierarchy display for all groups. > [!NOTE] +> Using `-ShowAll` does not incur a significant performance penalty because the cmdlet caches group data internally. + +### Example 7: Retrieve and inspect additional properties + +```powershell +# Retrieve specific properties (friendly names are preserved as keys) +PS> $tree = Get-ADTreeGroupMember TestGroup001 -Properties Country, City, nTSecurityDescriptor + +# View department for all user objects +PS> $tree | Where-Object ObjectClass -EQ 'user' | + Select-Object *, @{ Name='Country'; Expression={ $_.AdditionalProperties['Country'] }} + +# Retrieve all available attributes +PS> $tree = Get-ADTreeGroupMember TestGroup001 -Properties * + +# Inspect the full dictionary for the first object +PS> $tree[0].AdditionalProperties # ReadOnlyDictionary +``` + +>[!TIP] > -> The use of this switch should not infer in a great performance cost, for more details see the parameter details. +> - `-Properties *` retrieves __all__ available attributes from each object. +> - Use friendly names (e.g. `Country` → `c`, `City` → `l`, etc) or raw LDAP names — the key in `.AdditionalProperties` matches what you requested. +> - See the full list of supported friendly names in the [source code `LdapMap.cs`](https://github.com/santisq/PSADTree/tree/main/src/PSADTree/LdapMap.cs) ## PARAMETERS @@ -105,12 +133,12 @@ The `-ShowAll` switch indicates that the cmdlet should display the hierarchy of Specifies a user account that has permission to perform this action. The default is the current user. -Type a user name, such as __User01__ or __Domain01\User01__, or enter a __PSCredential__ object generated by the `Get-Credential` cmdlet. If you type a user name, you're prompted to enter the password. +Type a user name, such as __User01__ or __Domain01\User01__, or enter a __PSCredential__ object generated by the `Get-Credential` cmdlet. If you type a user name, you will be prompted to enter the password. ```yaml Type: PSCredential Parameter Sets: (All) -Aliases: +Aliases: cred Required: False Position: Named @@ -122,12 +150,12 @@ Accept wildcard characters: False ### -Depth Determines the number of nested groups and their members included in the recursion. -By default, only 3 levels of recursion are included. `Get-ADTreeGroupMember` emits a warning if the levels exceed this number. +By default, only 3 levels of recursion are included. `Get-ADTreeGroupMember` emits a warning if the actual nesting exceeds this number. ```yaml Type: Int32 Parameter Sets: Depth -Aliases: +Aliases: d Required: False Position: Named @@ -145,12 +173,12 @@ Wildcard characters are accepted. > [!NOTE] > > - Patterns are tested against the principal's `.SamAccountName` property. -> - When the matched principal is of type `group`, all child principals are also excluded from the output. +> - When the matched principal is of type `group`, __all__ child principals are also excluded from the output. ```yaml Type: String[] Parameter Sets: (All) -Aliases: +Aliases: ex Required: False Position: Named @@ -166,7 +194,7 @@ The `-Group` switch indicates that the cmdlet should display nested group member ```yaml Type: SwitchParameter Parameter Sets: (All) -Aliases: +Aliases: g Required: False Position: Named @@ -177,15 +205,15 @@ Accept wildcard characters: False ### -Identity -Specifies an Active Directory group by providing one of the following property values: +Specifies the Active Directory group to retrieve members from. You can identify the group using one of the following values: -- A DistinguishedName -- A GUID -- A SID (Security Identifier) -- A sAMAccountName -- A UserPrincipalName +- DistinguishedName +- GUID +- SID (Security Identifier) +- sAMAccountName +- UserPrincipalName -See [`IdentityType` Enum](https://learn.microsoft.com/en-us/dotnet/api/system.directoryservices.accountmanagement.identitytype?view=dotnet-plat-ext-7.0) for more information. +For more information, see the [`IdentityType` enumeration](https://learn.microsoft.com/en-us/dotnet/api/system.directoryservices.accountmanagement.identitytype). ```yaml Type: String @@ -199,14 +227,52 @@ Accept pipeline input: True (ByPropertyName, ByValue) Accept wildcard characters: False ``` +### -Properties + +Specifies one or more additional properties (LDAP attributes) to retrieve for each Active Directory object (user, group, computer, etc.) in the tree. Retrieved values are added to the read-only dictionary in the `.AdditionalProperties` property of each output object (`TreeUser`, `TreeGroup`, `TreeComputer`). + +__Behavior:__ + +- `*` → Retrieves __all__ available attributes from the object. +- One or more property names → Only properties that exist on the object and have a non-null value are included. + +__Supported input styles:__ + +- Friendly/PowerShell-style names (as in the Active Directory module), e.g., `City`, `Country`, `Department`, `EmailAddress`, `PasswordLastSet`, `LastBadPasswordAttempt` +- Raw LDAP attribute names, e.g., `l`, `c`, `department`, `mail`, `pwdLastSet`, `badPasswordTime`, `whenCreated` + +When a friendly name is used, the key in `.AdditionalProperties` matches the friendly name (not the LDAP name). + +__Special handling:__ + +- `nTSecurityDescriptor` → Returned as a security descriptor object (similar to `Get-Acl` output) +- Large integer / FILETIME attributes (such as `pwdLastSet`, `accountExpires`, `lastLogonTimestamp`, `badPasswordTime`, etc.) → Converted to `long` (64-bit FileTime ticks) + +Non-existent properties (e.g. `Title` on a computer object) are silently ignored. + +```yaml +Type: String[] +Parameter Sets: (All) +Aliases: prop, attrs, attributes + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -Recursive -Specifies that the cmdlet should get all group members of the specified group. +Retrieves __all__ nested group members recursively (no depth limit). + +> [!NOTE] +> This switch and `-Depth` are mutually exclusive. If `-Recursive` is specified, `-Depth` is ignored. ```yaml Type: SwitchParameter Parameter Sets: Recursive -Aliases: +Aliases: rec Required: False Position: Named @@ -217,23 +283,23 @@ Accept wildcard characters: False ### -Server -Specifies the AD DS instance to connect to by providing one of the following values for a corresponding domain name or directory server. +Specifies the Active Directory server (or domain) to bind to. Valid values include: -Domain name values: +__Domain name formats__: -- Fully qualified domain name +- Fully qualified domain name (FQDN) - NetBIOS name -Directory server values: +__Server formats__: -- Fully qualified directory server name +- Fully qualified server name - NetBIOS name -- Fully qualified directory server name and port +- Fully qualified server name with port (e.g. `dc01.contoso.com:3268`) ```yaml Type: String Parameter Sets: (All) -Aliases: +Aliases: s, dc Required: False Position: Named @@ -244,19 +310,20 @@ Accept wildcard characters: False ### -ShowAll -By default, previously processed groups will be marked as _"Processed Group"_ and their hierarchy will not be displayed. -This switch forces the cmdlet to display the full hierarchy including previously processed groups. +By default, groups that have already been processed are marked as _"Processed Group"_ and their hierarchy is not expanded (to avoid redundant output and recursion). + +The `-ShowAll` switch forces the cmdlet to display the full hierarchy of all groups, even those previously processed. > [!NOTE] > -> This cmdlet uses a caching mechanism to ensure that Active Directory Groups are only queried once per Identity. -> This caching mechanism is also used to reconstruct the pre-processed group's hierarchy when the `-ShowAll` switch is used, thus not incurring a performance cost. -> The intent behind this switch is to not clutter the cmdlet's output by default. +> This cmdlet caches group data to query each unique group only once. +> The `-ShowAll` switch reuses this cache to reconstruct hierarchies without additional AD queries, so it does not cause a significant performance penalty. +> The default behavior (hiding processed groups) keeps output clean and focused. ```yaml Type: SwitchParameter Parameter Sets: (All) -Aliases: +Aliases: a Required: False Position: Named @@ -273,18 +340,40 @@ This cmdlet supports the common parameters. For more information, see [about_Com ### System.String -You can pipe strings containing an identity to this cmdlet. [__`ADGroup`__](https://learn.microsoft.com/en-us/dotnet/api/microsoft.activedirectory.management.adgroup?view=activedirectory-management-10.0) instances piped to this cmdlet are also supported. +You can pipe strings that represent a group identity (DistinguishedName, GUID, SID, sAMAccountName, or UserPrincipalName) to this cmdlet. + +### Microsoft.ActiveDirectory.Management.ADGroup + +[__`ADGroup`__](https://learn.microsoft.com/en-us/dotnet/api/microsoft.activedirectory.management.adgroup) objects are also accepted (typically piped from `Get-ADGroup` or similar cmdlets). ## OUTPUTS ### PSADTree.TreeGroup +Represents an Active Directory group in the tree structure. + ### PSADTree.TreeUser +Represents an Active Directory user in the tree structure. + ### PSADTree.TreeComputer +Represents an Active Directory computer in the tree structure. + ## NOTES `treegroupmember` is the alias for this cmdlet. +The cmdlet uses internal caching to avoid redundant queries to Active Directory and efficiently detect/handle circular group nesting. + ## RELATED LINKS + +[__`Principal` Class__](https://learn.microsoft.com/en-us/dotnet/api/system.directoryservices.accountmanagement.principal) + +[__`DirectoryEntry` Class__](https://learn.microsoft.com/en-us/dotnet/api/system.directoryservices.directoryentry) + +[__`Get-ADGroupMember`__](https://learn.microsoft.com/en-us/powershell/module/activedirectory/get-adgroupmember) + +[__`Get-ADGroup`__](https://learn.microsoft.com/en-us/powershell/module/activedirectory/get-adgroup) + +[__`Get-ADUser`__](https://learn.microsoft.com/en-us/powershell/module/activedirectory/get-aduser) diff --git a/docs/en-US/Get-ADTreePrincipalGroupMembership.md b/docs/en-US/Get-ADTreePrincipalGroupMembership.md index 50d6033..2158337 100644 --- a/docs/en-US/Get-ADTreePrincipalGroupMembership.md +++ b/docs/en-US/Get-ADTreePrincipalGroupMembership.md @@ -9,7 +9,7 @@ schema: 2.0.0 ## SYNOPSIS -`tree` like cmdlet for Active Directory Principals Group Membership. +Displays the group membership of an Active Directory principal in a tree-like structure, including nested groups and circular membership detection. ## SYNTAX @@ -23,6 +23,7 @@ Get-ADTreePrincipalGroupMembership [-Depth ] [-ShowAll] [-Exclude ] + [-Properties ] [] ``` @@ -36,42 +37,47 @@ Get-ADTreePrincipalGroupMembership [-Recursive] [-ShowAll] [-Exclude ] + [-Properties ] [] ``` ## DESCRIPTION -The `Get-ADTreePrincipalGroupMembership` cmdlet gets the Active Directory groups that have a specified user, computer, group, or service account as a member and displays them in a tree like structure. This cmdlet also helps identifying Circular Nested Groups. +The `Get-ADTreePrincipalGroupMembership` cmdlet retrieves the group membership of a specified Active Directory principal (user, computer, group, or service account) and displays it in a tree-like hierarchical structure. + +This format makes it easy to visualize nested group membership and quickly identify circular nested groups (where a principal is indirectly a member of itself through a loop). ## EXAMPLES ### Example 1: Get group memberships for a user ```powershell -PS ..\PSADTree\> Get-ADTreePrincipalGroupMembership john.doe +PS> Get-ADTreePrincipalGroupMembership john.doe ``` -By default, this cmdlet uses `-Depth` with a default value of `3`. +By default, this cmdlet uses `-Depth 3`. ### Example 2: Get the recursive group memberships for a user ```powershell -PS ..\PSADTree\> Get-ADTreePrincipalGroupMembership john.doe -Recursive +PS> Get-ADTreePrincipalGroupMembership john.doe -Recursive ``` +The `-Recursive` switch retrieves all nested group memberships regardless of depth. + ### Example 3: Get group memberships for all computers under an Organizational Unit ```powershell -PS ..\PSADTree\> Get-ADComputer -Filter * -SearchBase 'OU=myOU,DC=myDomain,DC=com' | +PS> Get-ADComputer -Filter * -SearchBase 'OU=myOU,DC=myDomain,DC=com' | Get-ADTreePrincipalGroupMembership ``` -You can pipe strings containing an identity to this cmdlet. [__`ADObject`__](https://learn.microsoft.com/en-us/dotnet/api/microsoft.activedirectory.management.adobject?view=activedirectory-management-10.0) instances piped to this cmdlet are also supported. +You can pipe strings containing a principal identity or [__`ADObject`__](https://learn.microsoft.com/en-us/dotnet/api/microsoft.activedirectory.management.adobject) objects to this cmdlet. -### Example 4: Find any Circular Nested Groups from previous example +### Example 4: Find circular nested groups ```powershell -PS ..\PSADTree\> Get-ADComputer -Filter * -SearchBase 'OU=myOU,DC=myDomain,DC=com' | +PS> Get-ADComputer -Filter * -SearchBase 'OU=myOU,DC=myDomain,DC=com' | Get-ADTreePrincipalGroupMembership -Recursive | Where-Object IsCircular ``` @@ -79,21 +85,40 @@ PS ..\PSADTree\> Get-ADComputer -Filter * -SearchBase 'OU=myOU,DC=myDomain,DC=co ### Example 5: Get group memberships for a user in a different Domain ```powershell -PS ..\PSADTree\> Get-ADTreePrincipalGroupMembership john.doe -Server otherDomain +PS> Get-ADTreePrincipalGroupMembership john.doe -Server otherDomain.com ``` -### Example 6: Get group memberships for a user, including processed groups +### Example 6: Display hierarchy even for previously processed groups ```powershell -PS ..\PSADTree\> Get-ADTreePrincipalGroupMembership john.doe -ShowAll +PS> Get-ADTreePrincipalGroupMembership john.doe -ShowAll ``` -By default, previously processed groups will be marked as _"Processed Group"_ and their hierarchy will not be displayed. -The `-ShowAll` switch indicates that the cmdlet should display the hierarchy of all previously processed groups. +By default, groups that have already been processed (to avoid redundant recursion) are marked as _"Processed Group"_ and their subtree is not expanded. +The `-ShowAll` switch forces full hierarchy display for all groups. > [!NOTE] +> Using `-ShowAll` does not incur a significant performance penalty because the cmdlet caches group data internally. + +### Example 7: Retrieve and inspect additional properties + +```powershell +# Retrieve specific properties (friendly names become the keys) +PS> $tree = Get-ADTreePrincipalGroupMembership john.doe -Properties PasswordLastSet, Department, City, nTSecurityDescriptor + +# Show Department for the principal (if it's a user or has that property) +PS> $tree | Select-Object *, @{Name='Department'; Expression={ $_.AdditionalProperties['Department'] }} + +# Or get everything +PS> $tree = Get-ADTreePrincipalGroupMembership john.doe -Properties * +PS> $tree[0].AdditionalProperties # ReadOnlyDictionary +``` + +>[!TIP] > -> The use of this switch should not infer in a great performance cost, for more details see the parameter details. +> - `-Properties *` retrieves __all__ available attributes from each object. +> - Use friendly names (e.g. `Country` → `c`, `City` → `l`, etc) or raw LDAP names — the key in `.AdditionalProperties` matches what you requested. +> - See the full list of supported friendly names in the [source code `LdapMap.cs`](https://github.com/santisq/PSADTree/tree/main/src/PSADTree/LdapMap.cs) ## PARAMETERS @@ -101,12 +126,12 @@ The `-ShowAll` switch indicates that the cmdlet should display the hierarchy of Specifies a user account that has permission to perform this action. The default is the current user. -Type a user name, such as __User01__ or __Domain01\User01__, or enter a __PSCredential__ object generated by the `Get-Credential` cmdlet. If you type a user name, you're prompted to enter the password. +Type a user name, such as __User01__ or __Domain01\User01__, or enter a __PSCredential__ object generated by the `Get-Credential` cmdlet. If you type a user name, you will be prompted to enter the password. ```yaml Type: PSCredential Parameter Sets: (All) -Aliases: +Aliases: cred Required: False Position: Named @@ -117,13 +142,13 @@ Accept wildcard characters: False ### -Depth -Determines the number of nested group memberships included in the recursion. -By default, only 3 levels of recursion are included. `Get-ADTreePrincipalGroupMembership` emits a warning if the levels exceed this number. +Determines the number of nested groups and their members included in the recursion. +By default, only 3 levels of recursion are included. `Get-ADTreePrincipalGroupMembership` emits a warning if the actual nesting exceeds this number. ```yaml Type: Int32 Parameter Sets: Depth -Aliases: +Aliases: d Required: False Position: Named @@ -140,12 +165,13 @@ Wildcard characters are accepted. > [!NOTE] > -> Patterns are tested against the principal's `.SamAccountName` property. +> - Patterns are tested against the principal's `.SamAccountName` property. +> - When the matched principal is of type `group`, __all__ child principals are also excluded from the output. ```yaml Type: String[] Parameter Sets: (All) -Aliases: +Aliases: ex Required: False Position: Named @@ -156,15 +182,15 @@ Accept wildcard characters: True ### -Identity -Specifies an Active Directory principal by providing one of the following property values: +Specifies the Active Directory principal to retrieve members from. You can identify the group using one of the following values: -- A DistinguishedName -- A GUID -- A SID (Security Identifier) -- A sAMAccountName -- A UserPrincipalName +- DistinguishedName +- GUID +- SID (Security Identifier) +- sAMAccountName +- UserPrincipalName -See [`IdentityType` Enum](https://learn.microsoft.com/en-us/dotnet/api/system.directoryservices.accountmanagement.identitytype?view=dotnet-plat-ext-7.0) for more information. +For more information, see the [`IdentityType` enumeration](https://learn.microsoft.com/en-us/dotnet/api/system.directoryservices.accountmanagement.identitytype). ```yaml Type: String @@ -178,14 +204,52 @@ Accept pipeline input: True (ByPropertyName, ByValue) Accept wildcard characters: False ``` +### -Properties + +Specifies one or more additional properties (LDAP attributes) to retrieve for each Active Directory object (user, group, computer, etc.) in the tree. Retrieved values are added to the read-only dictionary in the `.AdditionalProperties` property of each output object (`TreeUser`, `TreeGroup`, `TreeComputer`). + +__Behavior:__ + +- `*` → Retrieves __all__ available attributes from the object. +- One or more property names → Only properties that exist on the object and have a non-null value are included. + +__Supported input styles:__ + +- Friendly/PowerShell-style names (as in the Active Directory module), e.g., `City`, `Country`, `Department`, `EmailAddress`, `PasswordLastSet`, `LastBadPasswordAttempt` +- Raw LDAP attribute names, e.g., `l`, `c`, `department`, `mail`, `pwdLastSet`, `badPasswordTime`, `whenCreated` + +When a friendly name is used, the key in `.AdditionalProperties` matches the friendly name (not the LDAP name). + +__Special handling:__ + +- `nTSecurityDescriptor` → Returned as a security descriptor object (similar to `Get-Acl` output) +- Large integer / FILETIME attributes (such as `pwdLastSet`, `accountExpires`, `lastLogonTimestamp`, `badPasswordTime`, etc.) → Converted to `long` (64-bit FileTime ticks) + +Non-existent properties (e.g. `Title` on a computer object) are silently ignored. + +```yaml +Type: String[] +Parameter Sets: (All) +Aliases: prop, attrs, attributes + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -Recursive -Specifies that the cmdlet should get all group membership of the specified principal. +Retrieves __all__ nested group members recursively (no depth limit). + +> [!NOTE] +> This switch and `-Depth` are mutually exclusive. If `-Recursive` is specified, `-Depth` is ignored. ```yaml Type: SwitchParameter Parameter Sets: Recursive -Aliases: +Aliases: rec Required: False Position: Named @@ -196,23 +260,23 @@ Accept wildcard characters: False ### -Server -Specifies the AD DS instance to connect to by providing one of the following values for a corresponding domain name or directory server. +Specifies the Active Directory server (or domain) to bind to. Valid values include: -Domain name values: +__Domain name formats__: -- Fully qualified domain name +- Fully qualified domain name (FQDN) - NetBIOS name -Directory server values: +__Server formats__: -- Fully qualified directory server name +- Fully qualified server name - NetBIOS name -- Fully qualified directory server name and port +- Fully qualified server name with port (e.g. `dc01.contoso.com:3268`) ```yaml Type: String Parameter Sets: (All) -Aliases: +Aliases: s, dc Required: False Position: Named @@ -223,19 +287,20 @@ Accept wildcard characters: False ### -ShowAll -By default, previously processed groups will be marked as _"Processed Group"_ and their hierarchy will not be displayed. -This switch forces the cmdlet to display the full hierarchy including previously processed groups. +By default, groups that have already been processed are marked as _"Processed Group"_ and their hierarchy is not expanded (to avoid redundant output and recursion). + +The `-ShowAll` switch forces the cmdlet to display the full hierarchy of all groups, even those previously processed. > [!NOTE] > -> This cmdlet uses a caching mechanism to ensure that Active Directory Groups are only queried once per Identity. -> This caching mechanism is also used to reconstruct the pre-processed group's hierarchy when the `-ShowAll` switch is used, thus not incurring a performance cost. -> The intent behind this switch is to not clutter the cmdlet's output by default. +> This cmdlet caches group data to query each unique group only once. +> The `-ShowAll` switch reuses this cache to reconstruct hierarchies without additional AD queries, so it does not cause a significant performance penalty. +> The default behavior (hiding processed groups) keeps output clean and focused. ```yaml Type: SwitchParameter Parameter Sets: (All) -Aliases: +Aliases: a Required: False Position: Named @@ -252,18 +317,40 @@ This cmdlet supports the common parameters. For more information, see [about_Com ### System.String -You can pipe strings containing an identity to this cmdlet. [`ADObject`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.activedirectory.management.adobject?view=activedirectory-management-10.0) instances piped to this cmdlet are also supported. +You can pipe strings that represent a principal identity (DistinguishedName, GUID, SID, sAMAccountName, or UserPrincipalName) to this cmdlet. + +### Microsoft.ActiveDirectory.Management.ADObject + +[__`ADObject`__](https://learn.microsoft.com/en-us/dotnet/api/microsoft.activedirectory.management.adobject) objects are also accepted (typically piped from `Get-ADUser`, `Get-ADComputer`, `Get-ADGroup`, etc.). ## OUTPUTS ### PSADTree.TreeGroup +Represents an Active Directory group in the tree structure. + ### PSADTree.TreeUser +Represents an Active Directory user in the tree structure. + ### PSADTree.TreeComputer +Represents an Active Directory computer in the tree structure. + ## NOTES `treeprincipalmembership` is the alias for this cmdlet. +The cmdlet uses internal caching to avoid redundant queries to Active Directory and efficiently detect/handle circular group nesting. + ## RELATED LINKS + +[__`Principal` Class__](https://learn.microsoft.com/en-us/dotnet/api/system.directoryservices.accountmanagement.principal) + +[__`DirectoryEntry` Class__](https://learn.microsoft.com/en-us/dotnet/api/system.directoryservices.directoryentry) + +[__`Get-ADGroupMember`__](https://learn.microsoft.com/en-us/powershell/module/activedirectory/get-adgroupmember) + +[__`Get-ADGroup`__](https://learn.microsoft.com/en-us/powershell/module/activedirectory/get-adgroup) + +[__`Get-ADUser`__](https://learn.microsoft.com/en-us/powershell/module/activedirectory/get-aduser) diff --git a/docs/en-US/PSADTree.md b/docs/en-US/PSADTree.md index 82e6f4b..cd4bf55 100644 --- a/docs/en-US/PSADTree.md +++ b/docs/en-US/PSADTree.md @@ -1,23 +1,51 @@ --- Module Name: PSADTree Module Guid: e49013dc-4106-4a95-aebc-b2669cbadeab -Download Help Link: +Download Help Link: https://github.com/santisq/PSADTree Help Version: 1.0.0.0 Locale: en-US --- -# PSADTree Module +# about_PSADTree -## Description +## SHORT DESCRIPTION -PSADTree is a PowerShell Module with cmdlets that emulate the [`tree` command](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/tree) for Active Directory Principals. +Displays Active Directory group membership hierarchies in a tree-like format (similar to the Windows `tree` command). -## PSADTree Cmdlets +## LONG DESCRIPTION -### [Get-ADTreeGroupMember](Get-ADTreeGroupMember.md) +**PSADTree** is a lightweight PowerShell module that provides two cmdlets to visualize Active Directory group structures: -The `Get-ADTreeGroupMember` cmdlet displays members of an Active Directory Group in a tree structure. +- [`Get-ADTreeGroupMember`](Get-ADTreeGroupMember.md) – Shows the members (users, groups, computers, etc.) of a group, including nested membership, in a tree view. + Helps identify nested groups and detect circular references. -### [Get-ADTreePrincipalGroupMembership](Get-ADTreePrincipalGroupMembership.md) +- [`Get-ADTreePrincipalGroupMembership`](Get-ADTreePrincipalGroupMembership.md) – Shows all groups that a principal (user, computer, group, or service account) belongs to, including nested paths, in a tree view. + Useful for auditing effective permissions and spotting circular nesting. -The `Get-ADTreePrincipalGroupMembership` cmdlet displays an Active Directory Principal group membership in a tree structure. +Both cmdlets support: + +- Depth limiting and full recursion +- Additional property retrieval (`-Properties`) +- Caching to avoid redundant AD queries +- Exclusion patterns +- Cross-domain support (`-Server`) + +They are particularly useful for troubleshooting complex group nesting, security reviews, and understanding effective group membership without manually traversing AD. + +## REQUIREMENTS + +- PowerShell 7.4+ or later (or Windows PowerShell 5.1) +- Appropriate permissions to read AD objects + +## EXAMPLES + +```powershell +# View nested members of a group (default depth 3) +Get-ADTreeGroupMember "Domain Admins" + +# View all groups a user belongs to (recursive) +Get-ADTreePrincipalGroupMembership john.doe -Recursive + +# Get full membership tree with extra properties +Get-ADTreePrincipalGroupMembership john.doe -Properties Department, Title, PasswordLastSet -Recursive +``` diff --git a/module/PSADTree.Format.ps1xml b/module/PSADTree.Format.ps1xml index 9eef827..1c3a2af 100644 --- a/module/PSADTree.Format.ps1xml +++ b/module/PSADTree.Format.ps1xml @@ -12,19 +12,12 @@ - - Left - Right @@ -35,11 +28,8 @@ - - $_.Domain -replace '^DC=|(?<!\\),.+' + [PSADTree.Internal._FormattingInternals]::GetDomain($_) ObjectClass diff --git a/module/PSADTree.psd1 b/module/PSADTree.psd1 index 0e1db7f..557e3a7 100644 --- a/module/PSADTree.psd1 +++ b/module/PSADTree.psd1 @@ -9,14 +9,14 @@ @{ # Script module or binary module file associated with this manifest. RootModule = if ($PSEdition -eq 'Core') { - 'bin/net6.0/PSADTree.dll' + 'bin/net8.0-windows/PSADTree.dll' } else { 'bin/net472/PSADTree.dll' } # Version number of this module. - ModuleVersion = '1.1.6' + ModuleVersion = '1.2.0' # Supported PSEditions # CompatiblePSEditions = @() @@ -55,7 +55,7 @@ # ProcessorArchitecture = '' # Modules that must be imported into the global environment prior to importing this module - # RequiredModules = @() + # RequiredModules = @() # Assemblies that must be loaded prior to importing this module RequiredAssemblies = @('System.DirectoryServices.AccountManagement') @@ -134,14 +134,14 @@ # RequireLicenseAcceptance = $false # External dependent modules of this module - # ExternalModuleDependencies = @() + # ExternalModuleDependencies = @('Microsoft.PowerShell.Security') } # End of PSData hashtable } # End of PrivateData hashtable # HelpInfo URI of this module - # HelpInfoURI = '' + HelpInfoURI = 'https://github.com/santisq/PSADTree/blob/main/docs/en-US/PSADTree.md' # Default prefix for commands exported from this module. Override the default prefix using Import-Module -Prefix. # DefaultCommandPrefix = '' diff --git a/src/PSADTree/Commands/GetADTreeGroupMemberCommand.cs b/src/PSADTree/Commands/GetADTreeGroupMemberCommand.cs index 068d268..5b51134 100644 --- a/src/PSADTree/Commands/GetADTreeGroupMemberCommand.cs +++ b/src/PSADTree/Commands/GetADTreeGroupMemberCommand.cs @@ -10,13 +10,11 @@ namespace PSADTree.Commands; VerbsCommon.Get, "ADTreeGroupMember", DefaultParameterSetName = DepthParameterSet)] [Alias("treegroupmember")] -[OutputType( - typeof(TreeGroup), - typeof(TreeUser), - typeof(TreeComputer))] +[OutputType(typeof(TreeGroup), typeof(TreeUser), typeof(TreeComputer))] public sealed class GetADTreeGroupMemberCommand : PSADTreeCmdletBase { [Parameter] + [Alias("g")] public SwitchParameter Group { get; set; } protected override Principal GetFirstPrincipal() => GroupPrincipal.FindByIdentity(Context, Identity); @@ -26,7 +24,7 @@ protected override void HandleFirstPrincipal(Principal principal) if (principal is GroupPrincipal group && !ShouldExclude(principal)) { string source = group.DistinguishedName; - PushToStack(new TreeGroup(source, group), group); + PushToStack(new TreeGroup(source, group, Properties), group); } } @@ -38,12 +36,11 @@ protected override void BuildFromAD( { IEnumerable members = groupPrincipal.ToSafeSortedEnumerable( selector: group => group.GetMembers(), - cmdlet: this, - comparer: Comparer); + cmdlet: this); foreach (Principal member in members) { - IDisposable? disposable = null; + Principal? disposable = null; try { if (member is { DistinguishedName: null } || @@ -92,9 +89,15 @@ private void ProcessPrincipal( { TreeObjectBase treeObject = principal switch { - UserPrincipal user => AddTreeObject(new TreeUser(source, parent, user, depth)), - ComputerPrincipal computer => AddTreeObject(new TreeComputer(source, parent, computer, depth)), - GroupPrincipal group => ProcessGroup(parent, group, source, depth), + UserPrincipal user => + AddTreeObject(new TreeUser(source, parent, user, Properties, depth)), + + ComputerPrincipal computer => + AddTreeObject(new TreeComputer(source, parent, computer, Properties, depth)), + + GroupPrincipal group => + ProcessGroup(parent, group, source, depth), + _ => throw new ArgumentOutOfRangeException(nameof(principal)), }; diff --git a/src/PSADTree/Commands/GetADTreePrincipalGroupMembershipCommand.cs b/src/PSADTree/Commands/GetADTreePrincipalGroupMembershipCommand.cs index 06e65d6..13ea5f7 100644 --- a/src/PSADTree/Commands/GetADTreePrincipalGroupMembershipCommand.cs +++ b/src/PSADTree/Commands/GetADTreePrincipalGroupMembershipCommand.cs @@ -10,10 +10,7 @@ namespace PSADTree.Commands; VerbsCommon.Get, "ADTreePrincipalGroupMembership", DefaultParameterSetName = DepthParameterSet)] [Alias("treeprincipalmembership")] -[OutputType( - typeof(TreeGroup), - typeof(TreeUser), - typeof(TreeComputer))] +[OutputType(typeof(TreeGroup), typeof(TreeUser), typeof(TreeComputer))] public sealed class GetADTreePrincipalGroupMembershipCommand : PSADTreeCmdletBase { protected override Principal GetFirstPrincipal() => Principal.FindByIdentity(Context, Identity); @@ -24,15 +21,15 @@ protected override void HandleFirstPrincipal(Principal principal) switch (principal) { case UserPrincipal user: - HandleOther(new TreeUser(source, user), principal); + HandleOther(new TreeUser(source, user, Properties), principal); break; case ComputerPrincipal computer: - HandleOther(new TreeComputer(source, computer), principal); + HandleOther(new TreeComputer(source, computer, Properties), principal); break; case GroupPrincipal group: - HandleGroup(new TreeGroup(source, group), group); + HandleGroup(new TreeGroup(source, group, Properties), group); break; default: @@ -53,15 +50,14 @@ void HandleOther(TreeObjectBase treeObject, Principal principal) IEnumerable principalMembership = principal.ToSafeSortedEnumerable( selector: principal => principal.GetGroups(Context), - cmdlet: this, - comparer: Comparer); + cmdlet: this); foreach (Principal parent in principalMembership) { if (!ShouldExclude(parent)) { GroupPrincipal groupPrincipal = (GroupPrincipal)parent; - TreeGroup treeGroup = new(source, null, groupPrincipal, 1); + TreeGroup treeGroup = new(source, null, groupPrincipal, Properties, 1); PushToStack(treeGroup, groupPrincipal); } } @@ -76,8 +72,7 @@ protected override void BuildFromAD( { IEnumerable principalMembership = groupPrincipal.ToSafeSortedEnumerable( selector: principal => principal.GetGroups(Context), - cmdlet: this, - comparer: Comparer); + cmdlet: this); foreach (Principal group in principalMembership) { diff --git a/src/PSADTree/Dbg.cs b/src/PSADTree/Dbg.cs deleted file mode 100644 index 1dee2c7..0000000 --- a/src/PSADTree/Dbg.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; - -namespace PSADTree; - -internal static class Dbg -{ - [Conditional("DEBUG")] - public static void Assert([DoesNotReturnIf(false)] bool condition) => - Debug.Assert(condition); -} diff --git a/src/PSADTree/Extensions/MiscExtensions.cs b/src/PSADTree/Extensions/MiscExtensions.cs new file mode 100644 index 0000000..29e0a8c --- /dev/null +++ b/src/PSADTree/Extensions/MiscExtensions.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; +using System.DirectoryServices; +using System.DirectoryServices.AccountManagement; +using System.Linq; +using PSADTree.Internal; + +namespace PSADTree.Extensions; + +internal static class MiscExtensions +{ + internal static DirectoryEntry GetDirectoryEntry(this Principal principal) + => (DirectoryEntry)principal.GetUnderlyingObject(); + + internal static ReadOnlyDictionary? GetAdditionalProperties( + this Principal principal, + string[] properties) + { + if (properties.Length == 0) + return null; + + DirectoryEntry entry = principal.GetDirectoryEntry(); + + if (properties.Any(e => e == "*")) + return entry.GetAllAttributes(); + + Dictionary additionalProperties = new( + capacity: properties.Length, + StringComparer.OrdinalIgnoreCase); + + foreach (string property in properties) + { + // already processed + if (additionalProperties.ContainsKey(property)) + continue; + + if (!LdapMap.TryGetValue(property, out string? ldapDn)) + { + ldapDn = property; + } + + if (IsSecurityDescriptor(property)) + { + additionalProperties[property] = entry.GetSecurityDescriptorAsPSObject(); + continue; + } + + object? value = entry.Properties[ldapDn]?.Value; + + if (value is null) continue; + if (IsIAdsLargeInteger(value, out long? fileTime)) + { + additionalProperties[property] = fileTime; + continue; + } + + additionalProperties[property] = value; + } + + return additionalProperties is { Count: 0 } ? null : new(additionalProperties); + } + + private static ReadOnlyDictionary GetAllAttributes(this DirectoryEntry entry) + { + Dictionary additionalProperties = new( + capacity: entry.Properties.Count, + StringComparer.OrdinalIgnoreCase); + + foreach (string property in entry.Properties.PropertyNames) + { + if (IsSecurityDescriptor(property)) + { + additionalProperties[property] = entry.GetSecurityDescriptorAsPSObject(); + continue; + } + + object? value = entry.Properties[property]?.Value; + + if (value is null) continue; + if (IsIAdsLargeInteger(value, out long? fileTime)) + { + additionalProperties[property] = fileTime; + continue; + } + + additionalProperties[property] = value; + } + + return new(additionalProperties); + } + + private static bool IsIAdsLargeInteger( + object value, + [NotNullWhen(true)] out long? fileTime) + { + fileTime = default; + if (value is not IAdsLargeInteger largeInt) + return false; + + fileTime = (largeInt.HighPart << 32) + largeInt.LowPart; + return true; + } + + private static bool IsSecurityDescriptor(string ldapDn) + => ldapDn.Equals("nTSecurityDescriptor", StringComparison.OrdinalIgnoreCase); + + internal static UserAccountControl? GetUserAccountControl(this AuthenticablePrincipal principal) + { + DirectoryEntry entry = principal.GetDirectoryEntry(); + object? uac = entry.Properties["userAccountControl"]?.Value; + if (uac is null) return null; + return (UserAccountControl)Convert.ToUInt32(uac); + } + + internal static bool IsEnabled(this UserAccountControl uac) + => !uac.HasFlag(UserAccountControl.ACCOUNTDISABLE); +} diff --git a/src/PSADTree/Extensions/TreeExtensions.cs b/src/PSADTree/Extensions/TreeExtensions.cs index 28686ba..5ea2336 100644 --- a/src/PSADTree/Extensions/TreeExtensions.cs +++ b/src/PSADTree/Extensions/TreeExtensions.cs @@ -88,11 +88,8 @@ internal static TreeObjectBase[] Format( #if NETCOREAPP [SkipLocalsInit] -#endif - private static unsafe string ReplaceAt(this string input, int index, char newChar) - { -#if NETCOREAPP - return string.Create( + private static string ReplaceAt(this string input, int index, char newChar) + => string.Create( input.Length, (input, index, newChar), static (buffer, state) => { @@ -100,6 +97,8 @@ private static unsafe string ReplaceAt(this string input, int index, char newCha buffer[state.index] = state.newChar; }); #else + private static unsafe string ReplaceAt(this string input, int index, char newChar) + { if (input.Length > 0x200) { char[] chars = input.ToCharArray(); @@ -119,14 +118,13 @@ private static unsafe string ReplaceAt(this string input, int index, char newCha pChars[index] = newChar; return new string(pChars, 0, input.Length); -#endif } +#endif internal static IEnumerable ToSafeSortedEnumerable( this TPrincipal principal, Func> selector, - PSCmdlet cmdlet, - PSADTreeComparer comparer) + PSCmdlet cmdlet) where TPrincipal : Principal { List principals = []; @@ -156,7 +154,7 @@ internal static IEnumerable ToSafeSortedEnumerable( return principals .OrderBy(static e => e.StructuralObjectClass == "group") - .ThenBy(static e => e, comparer); + .ThenBy(static e => e, PSADTreeComparer.Value); } internal static string GetDefaultNamingContext(this string distinguishedName) => diff --git a/src/PSADTree/IADsLargeInteger.cs b/src/PSADTree/IADsLargeInteger.cs new file mode 100644 index 0000000..99346e0 --- /dev/null +++ b/src/PSADTree/IADsLargeInteger.cs @@ -0,0 +1,28 @@ +using System.Runtime.InteropServices; +using System.Security; + +namespace PSADTree; + +[ComImport, + Guid("9068270b-0939-11d1-8be1-00c04fd8d503"), + InterfaceType(ComInterfaceType.InterfaceIsDual)] +public interface IAdsLargeInteger +{ + long HighPart + { + [SuppressUnmanagedCodeSecurity] + get; + + [SuppressUnmanagedCodeSecurity] + set; + } + + long LowPart + { + [SuppressUnmanagedCodeSecurity] + get; + + [SuppressUnmanagedCodeSecurity] + set; + } +} diff --git a/src/PSADTree/Internal/_FormattingInternals.cs b/src/PSADTree/Internal/_FormattingInternals.cs index 4cb3171..75af3b5 100644 --- a/src/PSADTree/Internal/_FormattingInternals.cs +++ b/src/PSADTree/Internal/_FormattingInternals.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using System.Management.Automation; +using System.Text.RegularExpressions; namespace PSADTree.Internal; @@ -8,7 +9,13 @@ namespace PSADTree.Internal; [EditorBrowsable(EditorBrowsableState.Never)] public static class _FormattingInternals { + private static Regex s_getDomain = new( + @"^DC=|(? treeObject.Source; + [Hidden, EditorBrowsable(EditorBrowsableState.Never)] - public static string GetSource(TreeObjectBase treeObject) => - treeObject.Source; + public static string GetDomain(TreeObjectBase treeObject) => s_getDomain.Replace(treeObject.Domain, string.Empty); } diff --git a/src/PSADTree/Internal/_SecurityDescriptorInternals.cs b/src/PSADTree/Internal/_SecurityDescriptorInternals.cs new file mode 100644 index 0000000..b10a3a5 --- /dev/null +++ b/src/PSADTree/Internal/_SecurityDescriptorInternals.cs @@ -0,0 +1,84 @@ +using System; +using System.ComponentModel; +using System.DirectoryServices; +using System.Management.Automation; +using System.Reflection; +using System.Security.AccessControl; +using System.Security.Principal; +using System.Text; + +namespace PSADTree.Internal; + +#pragma warning disable IDE1006 + +[EditorBrowsable(EditorBrowsableState.Never)] +public static class _SecurityDescriptorInternals +{ + private readonly static Type _target = typeof(NTAccount); + private static readonly MethodInfo _getOwner; + private static readonly MethodInfo _getGroup; + private static readonly MethodInfo _getSddlForm; + private static readonly MethodInfo _getAccessRules; + private static readonly MethodInfo _getAccessToString; + + static _SecurityDescriptorInternals() + { + Type type = typeof(_SecurityDescriptorInternals); + _getOwner = type.GetMethod(nameof(GetOwner))!; + _getGroup = type.GetMethod(nameof(GetGroup))!; + _getSddlForm = type.GetMethod(nameof(GetSddlForm))!; + _getAccessRules = type.GetMethod(nameof(GetAccessRules))!; + _getAccessToString = type.GetMethod(nameof(GetAccessToString))!; + } + + private static ActiveDirectorySecurity GetBaseObject(PSObject target) + => (ActiveDirectorySecurity)target.BaseObject; + + public static IdentityReference? GetOwner(PSObject target) + => GetBaseObject(target).GetOwner(_target); + + public static IdentityReference? GetGroup(PSObject target) + => GetBaseObject(target).GetGroup(_target); + + public static string GetSddlForm(PSObject target) + => GetBaseObject(target).GetSecurityDescriptorSddlForm(AccessControlSections.All); + + public static AuthorizationRuleCollection GetAccessRules(PSObject target) + => GetBaseObject(target).GetAccessRules(true, true, _target); + + public static string GetAccessToString(PSObject target) + { + StringBuilder builder = new(); + foreach (ActiveDirectoryAccessRule rule in GetAccessRules(target)) + builder.AppendLine($"{rule.IdentityReference} {rule.AccessControlType}"); + + return builder.ToString(); + } + + internal static PSObject GetSecurityDescriptorAsPSObject(this DirectoryEntry entry) + => PSObject.AsPSObject(entry.ObjectSecurity) + .AddProperty("Path", entry.Path) + .AddCodeProperty("Owner", _getOwner) + .AddCodeProperty("Group", _getGroup) + .AddCodeProperty("Sddl", _getSddlForm) + .AddCodeProperty("Access", _getAccessRules) + .AddCodeProperty("AccessToString", _getAccessToString); + + private static PSObject AddProperty( + this PSObject psObject, + string name, + object? value) + { + psObject.Properties.Add(new PSNoteProperty(name, value), preValidated: true); + return psObject; + } + + private static PSObject AddCodeProperty( + this PSObject psObject, + string name, + MethodInfo method) + { + psObject.Properties.Add(new PSCodeProperty(name, method), preValidated: true); + return psObject; + } +} diff --git a/src/PSADTree/LdapCompleter.cs b/src/PSADTree/LdapCompleter.cs new file mode 100644 index 0000000..030c4b7 --- /dev/null +++ b/src/PSADTree/LdapCompleter.cs @@ -0,0 +1,29 @@ +using System.Collections; +using System.Collections.Generic; +using System.Management.Automation; +using System.Management.Automation.Language; + +namespace PSADTree; + +public sealed class LdapCompleter : IArgumentCompleter +{ + public IEnumerable CompleteArgument( + string commandName, + string parameterName, + string wordToComplete, + CommandAst commandAst, + IDictionary fakeBoundParameters) + { + foreach (string key in LdapMap.Keys) + { + if (key.StartsWith(wordToComplete, System.StringComparison.OrdinalIgnoreCase)) + { + yield return new CompletionResult( + key, + key, + CompletionResultType.ParameterValue, + $"LDAP DisplayName: {LdapMap.Get(key)}"); + } + } + } +} diff --git a/src/PSADTree/LdapMap.cs b/src/PSADTree/LdapMap.cs new file mode 100644 index 0000000..b83045e --- /dev/null +++ b/src/PSADTree/LdapMap.cs @@ -0,0 +1,287 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; + +namespace PSADTree; + +internal static class LdapMap +{ + internal static ReadOnlyDictionary Instance { get; } + + internal static bool TryGetValue(string property, [NotNullWhen(true)] out string? ldapDn) + => Instance.TryGetValue(property, out ldapDn); + + internal static string Get(string property) => Instance[property]; + + internal static ReadOnlyDictionary.KeyCollection Keys { get => Instance.Keys; } + + + static LdapMap() + { + Dictionary map = new(StringComparer.OrdinalIgnoreCase) + { + ["AccountExpirationDate"] = "accountExpires", + ["AccountLockoutTime"] = "lockoutTime", + ["AccountPassword"] = "unicodePwd", + ["ADAMAllowReversiblePasswordEncryption"] = "ms-DS-UserEncryptedTextPasswordAllowed", + ["ADAMDisabled"] = "msDS-UserAccountDisabled", + ["ADAMLockedOut"] = "ms-DS-UserAccountAutoLocked", + ["ADAMPasswordExpired"] = "msDS-UserPasswordExpired", + ["ADAMPasswordNeverExpires"] = "msDS-UserDontExpirePassword", + ["ADAMPasswordNotRequired"] = "ms-DS-UserPasswordNotRequired", + ["AllowedDNSSuffixes"] = "msDS-AllowedDNSSuffixes", + ["AllowedPasswordReplicationPolicy"] = "msDS-RevealOnDemandGroup", + ["AllowedToActOnBehalfOf"] = "msDS-AllowedToActOnBehalfOfOtherIdentity", + ["AppliesTo"] = "msDS-PSOAppliesTo", + ["AssignedAuthNPolicyBL"] = "msDS-AssignedAuthNPolicyBL", + ["AssignedAuthNPolicySiloBL"] = "msDS-AssignedAuthNPolicySiloBL", + ["AttributeSyntax"] = "attributeSyntax", + ["AuthenticatedAccounts"] = "msDS-AuthenticatedToAccountlist", + ["AuthenticationPolicy"] = "msDS-AssignedAuthNPolicy", + ["AuthenticationPolicySilo"] = "msDS-AssignedAuthNPolicySilo", + ["AuthenticationPolicySiloMembers"] = "msDS-AuthNPolicySiloMembers", + ["AuthNPolicyEnforce"] = "msDS-AuthNPolicyEnforced", + ["AuthNPolicySiloEnforce"] = "msDS-AuthNPolicySiloEnforced", + ["BadLogonCount"] = "badPwdCount", + ["BehaviorVersion"] = "msDS-Behavior-Version", + ["CanonicalName"] = "canonicalName", + ["Certificates"] = "userCertificate", + ["City"] = "l", + ["CNumConsecutiveSyncFailures"] = "cNumConsecutiveSyncFailures", + ["CNumFailures"] = "cNumFailures", + ["CommonName"] = "cn", + ["Company"] = "company", + ["ComplexityEnabled"] = "msDS-PasswordComplexityEnabled", + ["ComputerAllowedToAuthenticateTo"] = "msDS-ComputerAllowedToAuthenticateTo", + ["ComputerAuthenticationPolicy"] = "msDS-ComputerAuthNPolicy", + ["ComputerAuthNPolicyBL"] = "msDS-ComputerAuthNPolicyBL", + ["ComputerTGTLifetime"] = "msDS-ComputerTGTLifetime", + ["Cost"] = "cost", + ["Country"] = "c", + ["CreationTimeStamp"] = "createTimeStamp", + ["DefaultLockoutDuration"] = "lockoutDuration", + ["DefaultLockoutObservationWindow"] = "lockoutObservationWindow", + ["DefaultLockoutThreshold"] = "lockoutThreshold", + ["DefaultMaxPasswordAge"] = "maxPwdAge", + ["DefaultMinPasswordAge"] = "minPwdAge", + ["DefaultMinPasswordLength"] = "minPwdLength", + ["DefaultPasswordHistoryCount"] = "pwdHistoryLength", + ["DefaultPasswordProperties"] = "pwdProperties", + ["DeniedPasswordReplicationPolicy"] = "msDS-NeverRevealGroup", + ["Department"] = "department", + ["Description"] = "description", + ["DisplayName"] = "displayName", + ["DistinguishedName"] = "distinguishedName", + ["Division"] = "division", + ["DNSHostName"] = "dNSHostName", + ["DNSRoot"] = "dnsRoot", + ["DwLastResult"] = "dwLastResult", + ["DwLastSyncResult"] = "dwLastSyncResult", + ["DwReplicaFlags"] = "dwReplicaFlags", + ["DwVersion"] = "dwVersion", + ["EmailAddress"] = "mail", + ["EmployeeID"] = "employeeID", + ["EmployeeNumber"] = "employeeNumber", + ["Enabled"] = "Enabled", + ["EnabledScopes"] = "msDS-EnabledFeatureBL", + ["Fax"] = "facsimileTelephoneNumber", + ["FeatureGUID"] = "msDS-OptionalFeatureGUID", + ["FeatureScope"] = "msDS-OptionalFeatureFlags", + ["FTimeCreated"] = "ftimeCreated", + ["FTimeDeleted"] = "ftimeDeleted", + ["FTimeEnqueued"] = "ftimeEnqueued", + ["FTimeFirstFailure"] = "ftimeFirstFailure", + ["FTimeLastOriginatingChange"] = "ftimeLastOriginatingChange", + ["FTimeLastSyncAttempt"] = "ftimeLastSyncAttempt", + ["FTimeLastSyncSuccess"] = "ftimeLastSyncSuccess", + ["GivenName"] = "givenName", + ["GroupType"] = "groupType", + ["HomeDirectory"] = "homeDirectory", + ["HomeDrive"] = "homeDrive", + ["HomePhone"] = "homePhone", + ["HostComputers"] = "msDS-HostServiceAccountBL", + ["IncomingTrust"] = "msDS-TDOEgressBL", + ["Initials"] = "initials", + ["InstanceType"] = "instanceType", + ["InterSiteTopologyGenerator"] = "interSiteTopologyGenerator", + ["IsDefunct"] = "isDefunct", + ["IsDeleted"] = "isDeleted", + ["LastBadPasswordAttempt"] = "badPasswordTime", + ["LastKnownParent"] = "lastKnownParent", + ["LastKnownRDN"] = "msDS-LastKnownRDN", + ["LastLogonReplicationInterval"] = "msDS-LogonTimeSyncInterval", + ["LastLogonTimeStamp"] = "lastLogonTimestamp", + ["LdapDisplayName"] = "lDAPDisplayName", + ["LinkedGroupPolicyObjects"] = "gpLink", + ["Location"] = "location", + ["LockoutDuration"] = "msDS-LockoutDuration", + ["LockoutObservationWindow"] = "msDS-LockoutObservationWindow", + ["LockoutThreshold"] = "msDS-LockoutThreshold", + ["LogonWorkstations"] = "userWorkstations", + ["ManagedBy"] = "managedBy", + ["Manager"] = "manager", + ["MaxPasswordAge"] = "msDS-MaximumPasswordAge", + ["Member"] = "member", + ["MemberRulesInCAP"] = "msAuthz-MemberRulesInCentralAccessPolicy", + ["MemberRulesInCAPBL"] = "msAuthz-MemberRulesInCentralAccessPolicyBL", + ["MembersOfResourcePropertyList"] = "msDS-MembersOfResourcePropertyList", + ["MinPasswordAge"] = "msDS-MinimumPasswordAge", + ["MinPasswordLength"] = "msDS-MinimumPasswordLength", + ["MobilePhone"] = "mobile", + ["ModifiedTimeStamp"] = "modifyTimeStamp", + ["MsaGroupMembership"] = "msDS-GroupMSAMembership", + ["MsaManagedPasswordInterval"] = "msDS-ManagedPasswordInterval", + ["MSAuthzCentralAccessPolicyID"] = "msAuthz-CentralAccessPolicyID", + ["MSAuthzEffectiveDACL"] = "msAuthz-EffectiveSecurityPolicy", + ["MSAuthzLastEffectiveDACL"] = "msAuthz-LastEffectiveSecurityPolicy", + ["MSAuthzProposedDACL"] = "msAuthz-ProposedSecurityPolicy", + ["MSAuthzResourceCondition"] = "msAuthz-ResourceCondition", + ["MsDSAppliesToResourceTypes"] = "msDS-AppliesToResourceTypes", + ["MsDSClaimAttributeSource"] = "msDS-ClaimAttributeSource", + ["MsDSClaimIsSingleValued"] = "msDS-ClaimIsSingleValued", + ["MsDSClaimIsValueSpaceRestricted"] = "msDS-ClaimIsValueSpaceRestricted", + ["MsDSClaimPossibleValues"] = "msDS-ClaimPossibleValues", + ["MsDSClaimSharesPossibleValuesWithBL"] = "msDS-ClaimSharesPossibleValuesWithBL", + ["MsDSClaimSource"] = "msDS-ClaimSource", + ["MsDSClaimSourceType"] = "msDS-ClaimSourceType", + ["MsDSClaimTypeAppliesToClass"] = "msDS-ClaimTypeAppliesToClass", + ["MsDSClaimValueType"] = "msDS-ClaimValueType", + ["MSDShasFullReplicaNCs"] = "msDS-hasFullReplicaNCs", + ["MSDShasMasterNCs"] = "msDS-hasMasterNCs", + ["MSDSIsPossibleValuesPresent"] = "msDS-IsPossibleValuesPresent", + ["MsDSIsUsedAsResourceSecurityAttribute"] = "msDS-IsUsedAsResourceSecurityAttribute", + ["MSDSIsUserCachableAtRodc"] = "msDS-IsUserCachableAtRodc", + ["MsDSMembersOfResourcePropertyListBL"] = "msDS-MembersOfResourcePropertyListBL", + ["MSDSPortLDAP"] = "msDS-PortLDAP", + ["MsDSSClaimSharesPossibleValuesWith"] = "msDS-ClaimSharesPossibleValuesWith", + ["MsDSURI"] = "msDS-URI", + ["MSDSUserAccountControlComputed"] = "msDS-User-Account-Control-Computed", + ["MsDSValueTypeReference"] = "msDS-ValueTypeReference", + ["MSDSValueTypeReferenceBL"] = "msDS-ValueTypeReferenceBL", + ["Name"] = "name", + ["NCName"] = "nCName", + ["NETBIOSName"] = "nETBIOSName", + ["NTMixedDomainMode"] = "ntMixedDomain", + ["NTSecurityDescriptor"] = "nTSecurityDescriptor", + ["ObjectCategory"] = "objectCategory", + ["ObjectClass"] = "objectClass", + ["ObjectGUID"] = "objectGUID", + ["ObjectSid"] = "objectSid", + ["Office"] = "physicalDeliveryOfficeName", + ["OfficePhone"] = "telephoneNumber", + ["Options"] = "Options", + ["OpType"] = "OpType", + ["Organization"] = "o", + ["OS"] = "operatingSystem", + ["OSHotfix"] = "operatingSystemHotfix", + ["OSServicePack"] = "operatingSystemServicePack", + ["OSVersion"] = "operatingSystemVersion", + ["OtherName"] = "middleName", + ["OutgoingTrust"] = "msDS-TDOIngressBL", + ["PartiallyReplicatedNamingContexts"] = "hasPartialReplicaNCs", + ["PasswordHistoryCount"] = "msDS-PasswordHistoryLength", + ["PasswordLastSet"] = "pwdLastSet", + ["POBox"] = "postOfficeBox", + ["PostalCode"] = "postalCode", + ["Precedence"] = "msDS-PasswordSettingsPrecedence", + ["PrimaryGroup"] = "primaryGroupID", + ["ProfilePath"] = "profilePath", + ["PszAsyncIntersiteTransportDN"] = "pszAsyncIntersiteTransportDN", + ["PszAttributeName"] = "pszAttributeName", + ["PszDsaAddress"] = "pszDsaAddress", + ["PszDsaDN"] = "pszDsaDN", + ["PszLastOriginatingDsaDN"] = "pszLastOriginatingDsaDN", + ["PszNamingContext"] = "pszNamingContext", + ["PszObjectDn"] = "pszObjectDn", + ["PszSourceDsaAddress"] = "pszSourceDsaAddress", + ["PszSourceDsaDN"] = "pszSourceDsaDN", + ["PublicKeyRequiredPasswordRolling"] = "msDS-ExpirePasswordsOnSmartCardOnlyAccounts", + ["ReplicateFromDirectoryServer"] = "fromServer", + ["ReplicateSingleObject"] = "replicateSingleObject", + ["ReplicationAttributeMetadata"] = "msDS-ReplAttributeMetaData", + ["ReplicationAttributeMetadataObjectType"] = "DS_REPL_ATTR_META_DATA", + ["ReplicationAttributeValueMetadata"] = "msDS-ReplValueMetaData", + ["ReplicationAttributeValueMetadataObjectType"] = "DS_REPL_VALUE_META_DATA", + ["ReplicationConnectionFailures"] = "msDS-ReplConnectionFailures", + ["ReplicationFailuresObjectType"] = "DS_REPL_KCC_DSA_FAILURE", + ["ReplicationFrequency"] = "replInterval", + ["ReplicationInboundPartners"] = "msDS-NCReplInboundNeighbors", + ["ReplicationLinkFailures"] = "msDS-ReplLinkFailures", + ["ReplicationOutboundPartners"] = "msDS-NCReplOutboundNeighbors", + ["ReplicationPartnersObjectType"] = "DS_REPL_NEIGHBOR", + ["ReplicationQueue"] = "msDS-ReplPendingOps", + ["ReplicationQueueObjectType"] = "DS_REPL_OP", + ["ReplicationSchedule"] = "schedule", + ["ReplicationUpToDatenessVector"] = "msDS-NCReplCursors", + ["ReplicationUpToDatenessVectorObjectType"] = "DS_REPL_CURSOR", + ["RequiredDomainMode"] = "msDS-RequiredDomainBehaviorVersion", + ["RequiredForestMode"] = "msDS-RequiredForestBehaviorVersion", + ["ResultantPSO"] = "msDS-ResultantPSO", + ["RevealedAccounts"] = "msDS-RevealedList", + ["ReversibleEncryptionEnabled"] = "msDS-PasswordReversibleEncryptionEnabled", + ["RollingNTLMSecret"] = "msDS-StrongNTLMPolicy", + ["Rule"] = "msDS-TransformationRules", + ["SamAccountName"] = "sAMAccountName", + ["ScriptPath"] = "scriptPath", + ["SDRightsEffective"] = "sdRightsEffective", + ["SearchFlags"] = "searchFlags", + ["ServerReference"] = "serverReference", + ["ServerReferenceBL"] = "serverReferenceBL", + ["ServiceAccount"] = "msDS-HostServiceAccount", + ["ServiceAllowedNTLMNetworkAuthentication"] = "msDS-ServiceAllowedNTLMNetworkAuthentication", + ["ServiceAllowedToAuthenticateFrom"] = "msDS-ServiceAllowedToAuthenticateFrom", + ["ServiceAllowedToAuthenticateTo"] = "msDS-ServiceAllowedToAuthenticateTo", + ["ServiceAuthenticationPolicy"] = "msDS-ServiceAuthNPolicy", + ["ServiceAuthNPolicyBL"] = "msDS-ServiceAuthNPolicyBL", + ["ServicePrincipalNames"] = "servicePrincipalName", + ["ServiceTGTLifetime"] = "msDS-ServiceTGTLifetime", + ["Site"] = "siteObject", + ["SiteLinksIncluded"] = "siteLinkList", + ["SitesIncluded"] = "siteList", + ["SourceXmlAttribute"] = "sourceXmlAttribute", + ["SPNSuffixes"] = "msDS-SPNSuffixes", + ["State"] = "st", + ["Street"] = "street", + ["StreetAddress"] = "streetAddress", + ["Subnet"] = "siteObjectBL", + ["SupportedEncryptionTypes"] = "msDS-SupportedEncryptionTypes", + ["Surname"] = "sn", + ["SystemFlags"] = "systemFlags", + ["Target"] = "trustPartner", + ["Title"] = "title", + ["TransportType"] = "transportType", + ["TrustAttributes"] = "trustAttributes", + ["TrustDirection"] = "trustDirection", + ["TrustedPolicy"] = "msDS-EgressClaimsTransformationPolicy", + ["TrustingPolicy"] = "msDS-IngressClaimsTransformationPolicy", + ["TrustType"] = "trustType", + ["UlOptions"] = "ulOptions", + ["UlPriority"] = "ulPriority", + ["UlSerialNumber"] = "ulSerialNumber", + ["UnicodePwd"] = "unicodePwd", + ["UniversalGroupCachingRefreshSite"] = "msDS-Preferred-GC-Site", + ["UPNSuffixes"] = "uPNSuffixes", + ["UserAccountControl"] = "userAccountControl", + ["UserAccountControlComputed"] = "msDS-User-Account-Control-Computed", + ["UserAllowedNTLMNetworkAuthentication"] = "msDS-UserAllowedNTLMNetworkAuthentication", + ["UserAllowedToAuthenticateFrom"] = "msDS-UserAllowedToAuthenticateFrom", + ["UserAllowedToAuthenticateTo"] = "msDS-UserAllowedToAuthenticateTo", + ["UserAuthenticationPolicy"] = "msDS-UserAuthNPolicy", + ["UserAuthNPolicyBL"] = "msDS-UserAuthNPolicyBL", + ["UserPrincipalName"] = "userPrincipalName", + ["UserTGTLifetime"] = "msDS-UserTGTLifetime", + ["UsnAttributeFilter"] = "usnAttributeFilter", + ["UsnLastObjChangeSynced"] = "usnLastObjChangeSynced", + ["UsnLocalChange"] = "usnLocalChange", + ["UsnOriginatingChange"] = "usnOriginatingChange", + ["UuidAsyncIntersiteTransportObjGuid"] = "uuidAsyncIntersiteTransportObjGuid", + ["UuidDsaObjGuid"] = "uuidDsaObjGuid", + ["UuidLastOriginatingDsaInvocationID"] = "uuidLastOriginatingDsaInvocationID", + ["UuidSourceDsaInvocationID"] = "uuidSourceDsaInvocationID", + ["UuidSourceDsaObjGuid"] = "uuidSourceDsaObjGuid", + }; + + Instance = new ReadOnlyDictionary(map); + } +} diff --git a/src/PSADTree/Nullable.cs b/src/PSADTree/Nullable.cs index 97c2603..ad74d3b 100644 --- a/src/PSADTree/Nullable.cs +++ b/src/PSADTree/Nullable.cs @@ -1,140 +1,150 @@ #if !NETCOREAPP -namespace System.Diagnostics.CodeAnalysis +namespace System.Diagnostics.CodeAnalysis; + +/// Specifies that null is allowed as an input even if the corresponding type disallows it. +[AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property, Inherited = false)] +[ExcludeFromCodeCoverage] +internal sealed class AllowNullAttribute : Attribute { } + +/// Specifies that null is disallowed as an input even if the corresponding type allows it. +[AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property, Inherited = false)] +[ExcludeFromCodeCoverage] +internal sealed class DisallowNullAttribute : Attribute { } + +/// Specifies that an output may be null even if the corresponding type disallows it. +[AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, Inherited = false)] +[ExcludeFromCodeCoverage] +internal sealed class MaybeNullAttribute : Attribute { } + +/// Specifies that an output will not be null even if the corresponding type allows it. Specifies that an input argument was not null when the call returns. +[AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, Inherited = false)] +[ExcludeFromCodeCoverage] +internal sealed class NotNullAttribute : Attribute { } + +/// Specifies that when a method returns , the parameter may be null even if the corresponding type disallows it. +[AttributeUsage(AttributeTargets.Parameter, Inherited = false)] +[ExcludeFromCodeCoverage] +internal sealed class MaybeNullWhenAttribute : Attribute { - /// Specifies that null is allowed as an input even if the corresponding type disallows it. - [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property, Inherited = false)] - internal sealed class AllowNullAttribute : Attribute { } + /// Initializes the attribute with the specified return value condition. + /// + /// The return value condition. If the method returns this value, the associated parameter may be null. + /// + public MaybeNullWhenAttribute(bool returnValue) => ReturnValue = returnValue; + + /// Gets the return value condition. + public bool ReturnValue { get; } +} - /// Specifies that null is disallowed as an input even if the corresponding type allows it. - [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property, Inherited = false)] - internal sealed class DisallowNullAttribute : Attribute { } +/// Specifies that when a method returns , the parameter will not be null even if the corresponding type allows it. +[AttributeUsage(AttributeTargets.Parameter, Inherited = false)] +[ExcludeFromCodeCoverage] +internal sealed class NotNullWhenAttribute : Attribute +{ + /// Initializes the attribute with the specified return value condition. + /// + /// The return value condition. If the method returns this value, the associated parameter will not be null. + /// + public NotNullWhenAttribute(bool returnValue) => ReturnValue = returnValue; + + /// Gets the return value condition. + public bool ReturnValue { get; } +} - /// Specifies that an output may be null even if the corresponding type disallows it. - [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, Inherited = false)] - internal sealed class MaybeNullAttribute : Attribute { } +/// Specifies that the output will be non-null if the named parameter is non-null. +[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, AllowMultiple = true, Inherited = false)] +[ExcludeFromCodeCoverage] +internal sealed class NotNullIfNotNullAttribute : Attribute +{ + /// Initializes the attribute with the associated parameter name. + /// + /// The associated parameter name. The output will be non-null if the argument to the parameter specified is non-null. + /// + public NotNullIfNotNullAttribute(string parameterName) => ParameterName = parameterName; + + /// Gets the associated parameter name. + public string ParameterName { get; } +} - /// Specifies that an output will not be null even if the corresponding type allows it. Specifies that an input argument was not null when the call returns. - [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, Inherited = false)] - internal sealed class NotNullAttribute : Attribute { } +/// Applied to a method that will never return under any circumstance. +[AttributeUsage(AttributeTargets.Method, Inherited = false)] +[ExcludeFromCodeCoverage] +internal sealed class DoesNotReturnAttribute : Attribute { } - /// Specifies that when a method returns , the parameter may be null even if the corresponding type disallows it. - [AttributeUsage(AttributeTargets.Parameter, Inherited = false)] - internal sealed class MaybeNullWhenAttribute : Attribute - { - /// Initializes the attribute with the specified return value condition. - /// - /// The return value condition. If the method returns this value, the associated parameter may be null. - /// - public MaybeNullWhenAttribute(bool returnValue) => ReturnValue = returnValue; - - /// Gets the return value condition. - public bool ReturnValue { get; } - } +/// Specifies that the method will not return if the associated Boolean parameter is passed the specified value. +[AttributeUsage(AttributeTargets.Parameter, Inherited = false)] +[ExcludeFromCodeCoverage] +internal sealed class DoesNotReturnIfAttribute : Attribute +{ + /// Initializes the attribute with the specified parameter value. + /// + /// The condition parameter value. Code after the method will be considered unreachable by diagnostics if the argument to + /// the associated parameter matches this value. + /// + public DoesNotReturnIfAttribute(bool parameterValue) => ParameterValue = parameterValue; + + /// Gets the condition parameter value. + public bool ParameterValue { get; } +} - /// Specifies that when a method returns , the parameter will not be null even if the corresponding type allows it. - [AttributeUsage(AttributeTargets.Parameter, Inherited = false)] - internal sealed class NotNullWhenAttribute : Attribute - { - /// Initializes the attribute with the specified return value condition. - /// - /// The return value condition. If the method returns this value, the associated parameter will not be null. - /// - public NotNullWhenAttribute(bool returnValue) => ReturnValue = returnValue; - - /// Gets the return value condition. - public bool ReturnValue { get; } - } +/// Specifies that the method or property will ensure that the listed field and property members have not-null values. +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Property, Inherited = false, AllowMultiple = true)] +[ExcludeFromCodeCoverage] +internal sealed class MemberNotNullAttribute : Attribute +{ + /// Initializes the attribute with a field or property member. + /// + /// The field or property member that is promised to be not-null. + /// + public MemberNotNullAttribute(string member) => Members = new[] { member }; + + /// Initializes the attribute with the list of field and property members. + /// + /// The list of field and property members that are promised to be not-null. + /// + public MemberNotNullAttribute(params string[] members) => Members = members; + + /// Gets field or property member names. + public string[] Members { get; } +} - /// Specifies that the output will be non-null if the named parameter is non-null. - [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, AllowMultiple = true, Inherited = false)] - internal sealed class NotNullIfNotNullAttribute : Attribute +/// Specifies that the method or property will ensure that the listed field and property members have not-null values when returning with the specified return value condition. +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Property, Inherited = false, AllowMultiple = true)] +[ExcludeFromCodeCoverage] +internal sealed class MemberNotNullWhenAttribute : Attribute +{ + /// Initializes the attribute with the specified return value condition and a field or property member. + /// + /// The return value condition. If the method returns this value, the associated parameter will not be null. + /// + /// + /// The field or property member that is promised to be not-null. + /// + public MemberNotNullWhenAttribute(bool returnValue, string member) { - /// Initializes the attribute with the associated parameter name. - /// - /// The associated parameter name. The output will be non-null if the argument to the parameter specified is non-null. - /// - public NotNullIfNotNullAttribute(string parameterName) => ParameterName = parameterName; - - /// Gets the associated parameter name. - public string ParameterName { get; } + ReturnValue = returnValue; + Members = new[] { member }; } - /// Applied to a method that will never return under any circumstance. - [AttributeUsage(AttributeTargets.Method, Inherited = false)] - internal sealed class DoesNotReturnAttribute : Attribute { } - - /// Specifies that the method will not return if the associated Boolean parameter is passed the specified value. - [AttributeUsage(AttributeTargets.Parameter, Inherited = false)] - internal sealed class DoesNotReturnIfAttribute : Attribute + /// Initializes the attribute with the specified return value condition and list of field and property members. + /// + /// The return value condition. If the method returns this value, the associated parameter will not be null. + /// + /// + /// The list of field and property members that are promised to be not-null. + /// + public MemberNotNullWhenAttribute(bool returnValue, params string[] members) { - /// Initializes the attribute with the specified parameter value. - /// - /// The condition parameter value. Code after the method will be considered unreachable by diagnostics if the argument to - /// the associated parameter matches this value. - /// - public DoesNotReturnIfAttribute(bool parameterValue) => ParameterValue = parameterValue; - - /// Gets the condition parameter value. - public bool ParameterValue { get; } + ReturnValue = returnValue; + Members = members; } - /// Specifies that the method or property will ensure that the listed field and property members have not-null values. - [AttributeUsage(AttributeTargets.Method | AttributeTargets.Property, Inherited = false, AllowMultiple = true)] - internal sealed class MemberNotNullAttribute : Attribute - { - /// Initializes the attribute with a field or property member. - /// - /// The field or property member that is promised to be not-null. - /// - public MemberNotNullAttribute(string member) => Members = new[] { member }; - - /// Initializes the attribute with the list of field and property members. - /// - /// The list of field and property members that are promised to be not-null. - /// - public MemberNotNullAttribute(params string[] members) => Members = members; - - /// Gets field or property member names. - public string[] Members { get; } - } + /// Gets the return value condition. + public bool ReturnValue { get; } - /// Specifies that the method or property will ensure that the listed field and property members have not-null values when returning with the specified return value condition. - [AttributeUsage(AttributeTargets.Method | AttributeTargets.Property, Inherited = false, AllowMultiple = true)] - internal sealed class MemberNotNullWhenAttribute : Attribute - { - /// Initializes the attribute with the specified return value condition and a field or property member. - /// - /// The return value condition. If the method returns this value, the associated parameter will not be null. - /// - /// - /// The field or property member that is promised to be not-null. - /// - public MemberNotNullWhenAttribute(bool returnValue, string member) - { - ReturnValue = returnValue; - Members = new[] { member }; - } - - /// Initializes the attribute with the specified return value condition and list of field and property members. - /// - /// The return value condition. If the method returns this value, the associated parameter will not be null. - /// - /// - /// The list of field and property members that are promised to be not-null. - /// - public MemberNotNullWhenAttribute(bool returnValue, params string[] members) - { - ReturnValue = returnValue; - Members = members; - } - - /// Gets the return value condition. - public bool ReturnValue { get; } - - /// Gets field or property member names. - public string[] Members { get; } - } + /// Gets field or property member names. + public string[] Members { get; } } #endif diff --git a/src/PSADTree/PSADTree.csproj b/src/PSADTree/PSADTree.csproj index 4866383..c583673 100644 --- a/src/PSADTree/PSADTree.csproj +++ b/src/PSADTree/PSADTree.csproj @@ -1,12 +1,11 @@ - net6.0;net472 + net8.0-windows;net472 enable true PSADTree latest - CA1416 @@ -14,12 +13,10 @@ - - - - + + + + diff --git a/src/PSADTree/PSADTreeCmdletBase.cs b/src/PSADTree/PSADTreeCmdletBase.cs index ecd17b3..aed33a0 100644 --- a/src/PSADTree/PSADTreeCmdletBase.cs +++ b/src/PSADTree/PSADTreeCmdletBase.cs @@ -21,6 +21,8 @@ public abstract class PSADTreeCmdletBase : PSCmdlet, IDisposable private WildcardPattern[]? _exclusionPatterns; + private string[]? _properties; + private const WildcardOptions WildcardPatternOptions = WildcardOptions.Compiled | WildcardOptions.CultureInvariant | WildcardOptions.IgnoreCase; @@ -37,8 +39,6 @@ public abstract class PSADTreeCmdletBase : PSCmdlet, IDisposable internal TreeBuilder Builder { get; } = new(); - internal PSADTreeComparer Comparer { get; } = new(); - [Parameter( Position = 0, Mandatory = true, @@ -48,26 +48,41 @@ public abstract class PSADTreeCmdletBase : PSCmdlet, IDisposable public string? Identity { get; set; } [Parameter] + [Alias("s", "dc")] public string Server { get; set; } = Environment.UserDomainName; [Parameter] [Credential] + [Alias("cred")] public PSCredential? Credential { get; set; } [Parameter(ParameterSetName = DepthParameterSet)] [ValidateRange(0, int.MaxValue)] + [Alias("d")] public int Depth { get; set; } = 3; [Parameter(ParameterSetName = RecursiveParameterSet)] + [Alias("rec")] public SwitchParameter Recursive { get; set; } [Parameter] + [Alias("a")] public SwitchParameter ShowAll { get; set; } [Parameter] [SupportsWildcards] + [Alias("ex")] public string[]? Exclude { get; set; } + [Parameter] + [ArgumentCompleter(typeof(LdapCompleter))] + [Alias("prop", "attrs", "attributes")] + public string[] Properties + { + get => _properties ??= []; + set => _properties = [.. value.Where(e => !string.IsNullOrWhiteSpace(e))]; + } + protected override void BeginProcessing() { try @@ -225,7 +240,7 @@ protected TreeGroup ProcessGroup( return treeGroup; } - treeGroup = new TreeGroup(source, parent, group, depth); + treeGroup = new TreeGroup(source, parent, group, Properties, depth); PushToStack(treeGroup, group); return treeGroup; } diff --git a/src/PSADTree/PSADTreeComparer.cs b/src/PSADTree/PSADTreeComparer.cs index 29ace4e..34a462d 100644 --- a/src/PSADTree/PSADTreeComparer.cs +++ b/src/PSADTree/PSADTreeComparer.cs @@ -6,6 +6,10 @@ namespace PSADTree; #pragma warning disable CS8767 internal sealed class PSADTreeComparer : IComparer { + internal static PSADTreeComparer Value { get; } + + static PSADTreeComparer() => Value = new(); + public int Compare(Principal lhs, Principal rhs) => lhs.StructuralObjectClass == "group" && rhs.StructuralObjectClass == "group" ? rhs.SamAccountName.CompareTo(lhs.SamAccountName) // Groups in descending order diff --git a/src/PSADTree/TreeComputer.cs b/src/PSADTree/TreeComputer.cs index ae7590d..0c128df 100644 --- a/src/PSADTree/TreeComputer.cs +++ b/src/PSADTree/TreeComputer.cs @@ -1,28 +1,43 @@ using System.DirectoryServices.AccountManagement; +using PSADTree.Extensions; namespace PSADTree; public sealed class TreeComputer : TreeObjectBase { + public UserAccountControl? UserAccountControl { get; } + + public bool? Enabled { get; } + private TreeComputer( TreeComputer computer, TreeGroup parent, string source, int depth) : base(computer, parent, source, depth) - { } + { + UserAccountControl = computer.UserAccountControl; + Enabled = computer.Enabled; + } internal TreeComputer( string source, TreeGroup? parent, ComputerPrincipal computer, + string[] properties, int depth) - : base(source, parent, computer, depth) - { } + : base(source, parent, computer, properties, depth) + { + UserAccountControl = computer.GetUserAccountControl(); + Enabled = UserAccountControl?.IsEnabled(); + } - internal TreeComputer(string source, ComputerPrincipal computer) - : base(source, computer) - { } + internal TreeComputer(string source, ComputerPrincipal computer, string[] properties) + : base(source, computer, properties) + { + UserAccountControl = computer.GetUserAccountControl(); + Enabled = UserAccountControl?.IsEnabled(); + } internal override TreeObjectBase Clone(TreeGroup parent, string source, int depth) => new TreeComputer(this, parent, source, depth); diff --git a/src/PSADTree/TreeGroup.cs b/src/PSADTree/TreeGroup.cs index 6aee386..27cce7a 100644 --- a/src/PSADTree/TreeGroup.cs +++ b/src/PSADTree/TreeGroup.cs @@ -33,8 +33,8 @@ private TreeGroup( IsCircular = group.IsCircular; } - internal TreeGroup(string source, GroupPrincipal group) - : base(source, group) + internal TreeGroup(string source, GroupPrincipal group, string[] properties) + : base(source, group, properties) { _children = []; } @@ -43,8 +43,9 @@ internal TreeGroup( string source, TreeGroup? parent, GroupPrincipal group, + string[] properties, int depth) - : base(source, parent, group, depth) + : base(source, parent, group, properties, depth) { _children = []; } diff --git a/src/PSADTree/TreeObjectBase.cs b/src/PSADTree/TreeObjectBase.cs index b9d8680..1aba91c 100644 --- a/src/PSADTree/TreeObjectBase.cs +++ b/src/PSADTree/TreeObjectBase.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.ObjectModel; using System.DirectoryServices.AccountManagement; using System.Security.Principal; using PSADTree.Extensions; @@ -33,6 +34,8 @@ public abstract class TreeObjectBase public SecurityIdentifier ObjectSid { get; } + public ReadOnlyDictionary? AdditionalProperties { get; } + protected TreeObjectBase( TreeObjectBase treeObject, TreeGroup? parent, @@ -52,9 +55,10 @@ protected TreeObjectBase( UserPrincipalName = treeObject.UserPrincipalName; Description = treeObject.Description; DisplayName = treeObject.DisplayName; + AdditionalProperties = treeObject.AdditionalProperties; } - protected TreeObjectBase(string source, Principal principal) + protected TreeObjectBase(string source, Principal principal, string[] properties) { Source = source; SamAccountName = principal.SamAccountName; @@ -67,14 +71,16 @@ protected TreeObjectBase(string source, Principal principal) UserPrincipalName = principal.UserPrincipalName; Description = principal.Description; DisplayName = principal.DisplayName; + AdditionalProperties = principal.GetAdditionalProperties(properties); } protected TreeObjectBase( string source, TreeGroup? parent, Principal principal, + string[] properties, int depth) - : this(source, principal) + : this(source, principal, properties) { Depth = depth; Hierarchy = principal.SamAccountName.Indent(depth); diff --git a/src/PSADTree/TreeUser.cs b/src/PSADTree/TreeUser.cs index 9abf626..19d063c 100644 --- a/src/PSADTree/TreeUser.cs +++ b/src/PSADTree/TreeUser.cs @@ -1,28 +1,43 @@ using System.DirectoryServices.AccountManagement; +using PSADTree.Extensions; namespace PSADTree; public sealed class TreeUser : TreeObjectBase { + public UserAccountControl? UserAccountControl { get; } + + public bool? Enabled { get; } + private TreeUser( TreeUser user, TreeGroup parent, string source, int depth) : base(user, parent, source, depth) - { } + { + UserAccountControl = user.UserAccountControl; + Enabled = user.Enabled; + } internal TreeUser( string source, TreeGroup? parent, UserPrincipal user, + string[] properties, int depth) - : base(source, parent, user, depth) - { } + : base(source, parent, user, properties, depth) + { + UserAccountControl = user.GetUserAccountControl(); + Enabled = UserAccountControl?.IsEnabled(); + } - internal TreeUser(string source, UserPrincipal user) - : base(source, user) - { } + internal TreeUser(string source, UserPrincipal user, string[] properties) + : base(source, user, properties) + { + UserAccountControl = user.GetUserAccountControl(); + Enabled = UserAccountControl?.IsEnabled(); + } internal override TreeObjectBase Clone(TreeGroup parent, string source, int depth) => new TreeUser(this, parent, source, depth); diff --git a/src/PSADTree/UserAccountControl.cs b/src/PSADTree/UserAccountControl.cs new file mode 100644 index 0000000..ccfbcf1 --- /dev/null +++ b/src/PSADTree/UserAccountControl.cs @@ -0,0 +1,32 @@ +using System; + +namespace PSADTree; + +[Flags] +public enum UserAccountControl : uint +{ + None = 0, + SCRIPT = 0x00000001, // 1 + ACCOUNTDISABLE = 0x00000002, // 2 + // HOMEDIR_REQUIRED = 0x00000008, // Obsolete / ignored since ~2000 + LOCKOUT = 0x00000010, // 16 + PASSWD_NOTREQD = 0x00000020, // 32 + PASSWD_CANT_CHANGE = 0x00000040, // 64 (not stored, computed) + ENCRYPTED_TEXT_PWD_ALLOWED = 0x00000080, // 128 + TEMP_DUPLICATE_ACCOUNT = 0x00000100, // 256 + NORMAL_ACCOUNT = 0x00000200, // 512 + INTERDOMAIN_TRUST_ACCOUNT = 0x00000800, // 2048 + WORKSTATION_TRUST_ACCOUNT = 0x00001000, // 4096 + SERVER_TRUST_ACCOUNT = 0x00002000, // 8192 + DONT_EXPIRE_PASSWORD = 0x00010000, // 65536 + MNS_LOGON_ACCOUNT = 0x00020000, // 131072 + SMARTCARD_REQUIRED = 0x00040000, // 262144 + TRUSTED_FOR_DELEGATION = 0x00080000, // 524288 + NOT_DELEGATED = 0x00100000, // 1048576 + USE_DES_KEY_ONLY = 0x00200000, // 2097152 + DONT_REQ_PREAUTH = 0x00400000, // 4194304 + PASSWORD_EXPIRED = 0x00800000, // 8388608 (computed) + TRUSTED_TO_AUTH_FOR_DELEGATION = 0x01000000, // 16777216 + PARTIAL_SECRETS_ACCOUNT = 0x04000000, // 67108864 (RODC) + USE_AES_KEYS = 0x80000000U, // 2147483648 (AES Kerberos support) +}