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
}
}
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:
User
-> Organizational-Person
-> Person
-> Top
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 classUser
class has Security-Principal
and Mail-Recipient
classes attachedauxiliaryClass
, systemAuxiliaryClass
attributes of the class schemaI'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).