Search code examples
powershellactive-directory

PowerShell - Return an array of all possible properties for an ADObject


I want to query a lot of domains for user data. I know not all domains have been configured the same and have the same capabilities. So before doing the query, I want to validate if the properties I would like to return are actually available within the given domain to prevent errors during execution.

To this end I have written the following function:

function Get-ADDomainSupportedProperty {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string]$Server = $Env:USERDNSDOMAIN,

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string]$ObjectType = "User",

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string[]]$Property
    )

    # Get all available properties in the domain
    $params = @{
        SearchBase = (Get-ADRootDSE -Server $Server).SchemanamingContext
        Filter     = { Name -eq $ObjectType }
        Server     = $Server
        Properties = "*"
    }
    $allProperties = Get-ADObject @params
    
    # Select all default properties
    $allDefaultProperties  = $allProperties | 
                                Get-Member -MemberType "Properties" |
                                Select-Object -ExpandProperty Name |
                                Where-Object { $_ -notin @("MayContain", "SystemMayContain") }
    # Select all extended properties
    $allExtendedProperties = $allProperties | 
                                Select-Object @{ Name = "Properties"; Expression = { $_.MayContain + $_.SystemMayContain }} |
                                Select-Object -ExpandProperty "Properties"

    $allAvailableProperties = $allDefaultProperties + $allExtendedProperties | Sort-Object -Unique

    # Return available properties
    if ($Property) {
        $Property | Where-Object { $_ -in $allAvailableProperties }
    } else {
        $allAvailableProperties
    }
}

This function mostly works as intended and will return an array of extended properties, yet it does not return some default properties (e.g. sn or Company).

Does anyone have a suggestion how I can improve my function to return ALL available properties?

Edit After the answers supplied by Matias, here is the finished function for people who want to use it as well.

<#
    .SYNOPSIS
        Retrieves the supported attributes for a specified Active Directory object type.

    .DESCRIPTION
        The Get-ADDomainSupportedAttribute function retrieves the supported attributes for a specified Active Directory object type.
        It allows you to specify the server, object type, and attributes to retrieve. You can also specify whether to retrieve only
        mandatory or optional attributes.

    .PARAMETER Server
        The server to connect to. Defaults to the users DNS domain.

    .PARAMETER ObjectType
        The type of Active Directory object to retrieve attributes for. Defaults to "User".

    .PARAMETER Attribute
        The specific attributes to retrieve. This parameter is part of the Attribute parameter set.

    .PARAMETER MandatoryOnly
        Switch to retrieve only mandatory attributes. This parameter is part of the Mandatory parameter set.

    .PARAMETER OptionalOnly
        Switch to retrieve only optional attributes. This parameter is part of the Optional parameter set.

    .EXAMPLE
        PS> Get-ADDomainSupportedAttribute -Server "dc.domain.local" -ObjectType "User"
        Retrieves all supported attributes for the "User" object type on the specified server.

    .EXAMPLE
        PS> Get-ADDomainSupportedAttribute -ObjectType "Computer" -MandatoryOnly
        Retrieves only the mandatory attributes for the "Computer" object type.

    .EXAMPLE
        PS> Get-ADDomainSupportedAttribute -Attribute "cn", "sn"
        Retrieves the specified attributes "cn" and "sn".

    .NOTES
        Created with help from the community (Mathias R. Jessen)
        https://stackoverflow.com/questions/79393539/powershell-return-an-array-of-all-possible-properties-for-an-adobject
#>

# Module-scoped hashtable to cache per-server schemas
$schemaCache = @{}

function Get-ADDomainSupportedAttribute {
    [CmdletBinding(DefaultParameterSetName = "Default")]
    param (
        [Parameter(Mandatory = $false, ParameterSetName = "Default")]
        [Parameter(Mandatory = $false, ParameterSetName = "Attribute")]
        [Parameter(Mandatory = $false, ParameterSetName = "Mandatory")]
        [Parameter(Mandatory = $false, ParameterSetName = "Optional")]
        [ValidateNotNullOrEmpty()]
        [string]$Server = $env:USERDNSDOMAIN,

        [Parameter(Mandatory = $false, ParameterSetName = "Default")]
        [Parameter(Mandatory = $false, ParameterSetName = "Attribute")]
        [Parameter(Mandatory = $false, ParameterSetName = "Mandatory")]
        [Parameter(Mandatory = $false, ParameterSetName = "Optional")]
        [ValidateNotNullOrEmpty()]
        [string]$ObjectType = "User",

        [Parameter(Mandatory = $false, ParameterSetName = "Attribute")]
        [ValidateNotNullOrEmpty()]
        [string[]]$Attribute,

        [Parameter(Mandatory = $false, ParameterSetName = "Mandatory")]
        [switch]$MandatoryOnly,

        [Parameter(Mandatory = $false, ParameterSetName = "Optional")]
        [switch]$OptionalOnly
    )

    # Add current server to cache if it isnt available yet
    if (-not $script:schemaCache.ContainsKey($Server)) {
        $classSchemaSearchParams = @{
            SearchBase = (Get-ADRootDSE -Server $Server).schemaNamingContext
            Server     = $Server
            Filter     = { objectClass -eq "classSchema" }
            Properties = "lDAPDisplayName", "auxiliaryClass", "systemAuxiliaryClass", "mayContain", "systemMayContain", "mustContain", "systemMustContain", "subClassOf"
        }

        $allClassSchemas = @{}
        Get-ADObject @classSchemaSearchParams | ForEach-Object { $allClassSchemas[$_.lDAPDisplayName] = $_ }

        $script:schemaCache[$Server] = $allClassSchemas
    }

    # Pick schema table from cache
    $allClassSchemas = $script:schemaCache[$Server]

    # Arrays to track discovered attributes and resolved classes
    $mayBag   = @()
    $mustBag  = @()
    $resolved = @()

    [array]$unresolved = @($ObjectType)

    while ($unresolved.Count) {
        # Pick the next class name to resolve attributes and relations for
        $next, $unresolved = $unresolved

        if (-not $allClassSchemas.ContainsKey($next)) {
            Write-Error "Class schema with LDAP Display Name '${next}' not found"
            return
        }

        # Look up the schema object for the given class
        $nextSchema = $allClassSchemas[$next]

        # Make sure we dont do duplicate work or end up in a cycle
        if ($nextSchema.lDAPDisplayName -in $resolved) { continue }

        # Grab attributes associated with current class schema
        $mayBag += @(
            $nextSchema.mayContain
            $nextSchema.systemMayContain
        )
        $mustBag += @(
            $nextSchema.mustContain
            $nextSchema.systemMustContain
        )

        # Queue parent class for resolution unless schema refers to self (top)
        if ($nextSchema.subClassOf -and $nextSchema.subClassOf -ne $nextSchema.lDAPDisplayName) {
            $unresolved += $nextSchema.subClassOf
        }

        # ... then queue up auxiliaries classes
        if ($nextSchema.auxiliaryClass) {
            $unresolved += @($nextSchema.auxiliaryClass)
        }
        if ($nextSchema.systemAuxiliaryClass) {
            $unresolved += @($nextSchema.systemAuxiliaryClass)
        }

        # ... and finally add current schema to list of already-resolved classes
        $resolved += $nextSchema.lDAPDisplayName
    }

    # Return available attributes
    if ($Attribute) {
        # Only return the attribute names in the $Attribute parameter
        $Attribute | Where-Object { $_ -in $mayBag + $mustBag | Sort-Object -Unique }
    } elseif ($MandatoryOnly) {
        # Only return the mandatory attribute names
        $mustBag | Sort-Object -Unique
    } elseif ($OptionalOnly) {
        # Only return the optional attribute names
        $mayBag | Sort-Object -Unique
    } else {
        # Return all attribute names
        $mayBag + $mustBag | Sort-Object -Unique
    }
}

Solution

  • The Active Directory schema model allows for composition through auxiliary classes - these classes can be associated with an existing structural class schema (like User), to extend the base functionality of a given kind of object.

    If you want to enumerate all possible attributes an object of a given class can contain you'll need to enumerate not just the class and its parents, but the auxiliary classes as well.

    To get the full list of possible attributes for a given structural object class, combine:

    • mayContain, mustContain, systemMayContain, systemMustContain values from:
      • Every class in the inheritance hierarchy:
        • eg. User -> Organizational-Person -> Person -> Top
        • you can "follow" the subClassOf attribute to resolve the parent tree until you reach the Top class, the subClassOf value of which will refer to itself - the format stored in subClassOf is always the ldap display name of the parent class
      • Every auxiliary class, transitively (ie. recursively resolved for every class in the hierarchy):
        • eg. User class has Security-Principal and Mail-Recipient classes attached
        • the immediately attached auxiliary classes can be located in the auxiliaryClass, systemAuxiliaryClass attributes of the class schema

    I'd strongly suggest fetching all the class schema details up front, and then indexing them with a hashtable - it's gonna make recursive resolution of parent and auxiliary classes easier and much faster:

    # query, fetch and index all class schemas
    $classSchemaSearchParams = @{
        SearchBase = (Get-ADRootDSE -Server $Server).schemaNamingContext
        Server     = $Server
        Filter = { objectClass -eq 'classSchema'}
        Properties = 'lDAPDisplayName','auxiliaryClass','systemAuxiliaryClass','mayContain','systemMayContain','mustContain','systemMustContain','subClassOf'
    }
    $allClassSchemas = @{}
    Get-ADObject @classSchemaSearchParams |ForEach-Object { $allClassSchemas[$_.lDAPDisplayName] = $_ }
    

    Now that we have all the schema relations available, we can resolve all attributes that can be attached to an object of a given class:

    # we'll use these to track discovered attributes and resolved classes
    $mayBag = @()
    $mustBag = @()
    $resolved = @()
    
    [array]$unresolved = @($ObjectType)
    
    while ($unresolved.Count) {
      # pick the next class name to resolve attributes and relations for
      $next, $unresolved = $unresolved
    
      if (-not $allClassSchemas.ContainsKey($next)){
        Write-Error "Class schema with LDAP Display Name '${next}' not found"
        return
      }
    
      # look up the schema object for the given class
      $nextSchema = $allClassSchemas[$next]
    
      # make sure we don't do duplicate work or end up in a cycle
      if ($nextSchema.lDAPDisplayName -in $resolved) {continue}
    
      # grab attributes associated with current class schema
      $mayBag += @(
        $nextSchema.mayContain
        $nextSchema.systemMayContain
      )
      $mustBag += @(
        $nextSchema.mayContain
        $nextSchema.systemMayContain
      )
    
      # queue parent class for resolution unless schema refers to self (top)
      if ($nextSchema.subClassOf -and $nextSchema.subClassOf -ne $nextSchema.lDAPDisplayName) {
        $unresolved += $nextSchema.subClassOf
      }
    
      # ... then queue up auxiliaries classes
      if ($nextSchema.auxiliaryClass) {
        $unresolved += @($nextSchema.auxiliaryClass)
      }
      if ($nextSchema.systemAuxiliaryClass) {
        $unresolved += @($nextSchema.systemAuxiliaryClass)
      }
    
      # ... and finally add current schema to list of already-resolved classes
      $resolved += $nextSchema.lDAPDisplayName
    }
    
    # these two lists now contain the full set of optional and mandatory 
    # attribute names for an object of the given object class
    $mayBag |Sort-Object -Unique
    $mustBag |Sort-Object -Unique
    

    You can encapsulate all of this directly in your function as-is, but since 99% of the time will be spent discovering and indexing the class schemas I'd personally prefer moving storage for the schema index outside of the function (eg. into module/script scope):

    # .../someModule.psm1
    
    # module-scoped hashtable to cache per-server schemas
    $schemaCache = @{}
    
    function Get-ADDomainSupportedProperty {
      param (
        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string]$Server = $env:USERDNSDOMAIN,
    
        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string]$ObjectType = "User",
    
        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string[]]$Property
      )
    
      if (-not $script:schemaCache.ContainsKey($Server)) {
          $classSchemaSearchParams = @{
          SearchBase = (Get-ADRootDSE -Server $Server).schemaNamingContext
          Server     = $Server
          Filter = { objectClass -eq 'classSchema'}
          Properties = 'lDAPDisplayName','auxiliaryClass','systemAuxiliaryClass','mayContain','systemMayContain','mustContain','systemMustContain','subClassOf'
        }
    
        $allClassSchemas = @{}
        Get-ADObject @classSchemaSearchParams |ForEach-Object { $allClassSchemas[$_.lDAPDisplayName] = $_ }
    
        $script:schemaCache[$Server] = $allClassSchemas
      }
    
      # pick schema table from cache
      $allClassSchemas = $script:schemaCache[$Server]
    
      # ... rest of code goes here
    }
    

    This way you'll only need to fetch the schema once per target server, significantly speeding up subsequent queries if you need to resolve attributes for multiple classes in the same domain/forest as long as you reuse the same -Server argument.

    For simple topologies with short RTTs between sites just use the domain name (eg. -Server domain.forest.tld). For more complex/expansive topologies use Get-ADDomainController to discover a nearby DC and reuse that as the -Server argument.

    Another option is to defer cache key construction until after calling Get-ADRootDSE - this way you can tie each schema cache to the hosting forest FQDN (might be useful in a multi-domain single-forest environment).