Public/Schema/Get-ADSchemaClassAttribute.ps1

<#
    .SYNOPSIS
    List the attributes of an Active Directory class, or the classes using an attribute, and say where each one
    actually comes from.
 
    .DESCRIPTION
    Works in two modes:
 
    - by class, with -ClassName: every attribute the class exposes, mandatory and optional;
    - by attribute, with -AttributeName: every class exposing that attribute. Wildcards are supported.
 
    In both modes the 'Source' column tells where the attribute is really defined, which is the part a plain schema
    listing does not give you. An attribute can reach a class three different ways:
 
    - 'Direct', defined on the class itself through mustContain, mayContain, systemMustContain or systemMayContain;
    - inherited from a parent class, following the subClassOf chain up to 'top';
    - brought in by an auxiliary class, through auxiliaryClass or systemAuxiliaryClass.
 
    That last case is the one that matters on an extended schema. When Exchange or a third party product adds an
    auxiliary class to 'user', the attributes show up on every user object without ever appearing in the class
    definition itself. Knowing which auxiliary class carries an attribute is what tells you which product owns it,
    and what you would break by removing it.
 
    Each row also carries the confidentiality of the attribute:
 
    - 'Confidential', the attribute IS marked confidential, bit 128 of 'searchFlags'. Reading it then requires the
      CONTROL_ACCESS right rather than a plain READ_PROPERTY.
    - 'CanBeConfidential', the attribute COULD be. Only attributes outside the base schema can: Microsoft states
      that bit 128 is "ignored for base schema attributes (systemFlags=0x10)". An attribute with the bit set and
      CanBeConfidential = False is protected in appearance only.
 
    Reference: https://learn.microsoft.com/troubleshoot/windows-server/windows-security/mark-attribute-as-confidential
 
    .PARAMETER ClassName
    LDAP display name of the class to inspect, such as 'user', 'group' or 'computer'.
 
    .PARAMETER AttributeName
    LDAP display name of the attribute to look for across every class. Wildcards are supported, so 'mail*',
    '*password*' or 'ms-Mcs-Adm*' work.
 
    .PARAMETER Server
    Domain controller or domain to query. Defaults to the one selected by the ActiveDirectory module.
 
    .EXAMPLE
    Get-ADSchemaClassAttribute -ClassName 'user'
 
    Returns every attribute of the user class, with the class each one is defined on.
 
    .EXAMPLE
    Get-ADSchemaClassAttribute -ClassName 'user' | Where-Object { $_.Source -ne 'Direct' } | Group-Object Source
 
    Shows which parent and auxiliary classes actually populate the user class, and how many attributes each brings.
 
    .EXAMPLE
    Get-ADSchemaClassAttribute -ClassName 'user' | Where-Object { $_.Required -eq 'Mandatory' }
 
    Returns only the mandatory attributes, the ones a creation cannot omit.
 
    .EXAMPLE
    Get-ADSchemaClassAttribute -AttributeName 'mail'
 
    Returns every class exposing the 'mail' attribute.
 
    .EXAMPLE
    Get-ADSchemaClassAttribute -AttributeName 'ms-Mcs-Adm*'
 
    Returns the legacy LAPS attributes and the classes carrying them. Wildcards are supported.
 
    .EXAMPLE
    Get-ADSchemaClassAttribute -ClassName 'computer' | Where-Object { $_.Confidential }
 
    Returns the attributes of the computer class that are marked confidential, which is where a LAPS password
    should show up.
 
    .EXAMPLE
    Get-ADSchemaClassAttribute -ClassName 'user' | Where-Object { $_.CanBeConfidential -and -not $_.Confidential }
 
    Returns the attributes of the user class that could be marked confidential and are not. Extended attributes
    holding sensitive data usually belong here.
 
    .OUTPUTS
    System.Management.Automation.PSCustomObject, one per class and attribute pair.
 
    .NOTES
    Version : 2.0 - August 2026. Migrated from the ActiveDirectory-Toolbox repository, where it was named
    Get-ADSchemaClassAttributev2.
    Author : Bastien Perez - ITPro-Tips (https://itpro-tips.com)
 
    .LINK
    https://itpro-tips.com
#>


function Get-ADSchemaClassAttribute {
    [CmdletBinding(DefaultParameterSetName = 'Attribute')]
    [OutputType([PSCustomObject])]
    param (
        [Parameter(Mandatory, ParameterSetName = 'Class', Position = 0)]
        [string]$ClassName,

        [Parameter(Mandatory, ParameterSetName = 'Attribute', Position = 0)]
        [string]$AttributeName,

        [string]$Server
    )

    $ErrorActionPreference = 'Stop'

    # Common parameters forwarded to every ActiveDirectory cmdlet call
    $adParameters = @{ ErrorAction = 'Stop' }

    if ($PSBoundParameters.ContainsKey('Server')) {
        $adParameters.Add('Server', $Server)
    }

    try {
        $rootDSE = Get-ADRootDSE @adParameters
    }
    catch {
        Write-Error "Unable to read the RootDSE: $($_.Exception.Message)"
        return
    }

    $schemaNamingContext = $rootDSE.schemaNamingContext

    Write-Verbose "[i] Reading the class definitions from $schemaNamingContext"

    $getClassParams = $adParameters.Clone()
    $getClassParams.SearchBase = $schemaNamingContext
    $getClassParams.Filter = "objectCategory -eq 'classSchema'"
    $getClassParams.Properties = @(
        'lDAPDisplayName', 'subClassOf', 'auxiliaryClass', 'systemAuxiliaryClass',
        'mustContain', 'systemMustContain', 'mayContain', 'systemMayContain'
    )

    try {
        $classesFound = @(Get-ADObject @getClassParams)
    }
    catch {
        Write-Error "Unable to query the schema partition: $($_.Exception.Message)"
        return
    }

    # Indexed by LDAP display name so the inheritance chain can be walked without querying again
    $classesByName = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase)

    foreach ($classFound in $classesFound) {
        if (-not [string]::IsNullOrWhiteSpace($classFound.lDAPDisplayName)) {
            $classesByName[$classFound.lDAPDisplayName] = $classFound
        }
    }

    Write-Verbose "[i] $($classesByName.Count) classes read"

    # Confidentiality lives on the attributeSchema objects, read once for the whole schema. Three properties on a
    # few thousand objects is cheaper than one query per attribute found.
    Write-Verbose '[i] Reading the attribute definitions'

    $getAttributeParams = $adParameters.Clone()
    $getAttributeParams.SearchBase = $schemaNamingContext
    $getAttributeParams.Filter = "objectCategory -eq 'attributeSchema'"
    $getAttributeParams.Properties = @('lDAPDisplayName', 'searchFlags', 'systemFlags')

    $attributesByName = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase)

    try {
        foreach ($attributeFound in (Get-ADObject @getAttributeParams)) {
            if (-not [string]::IsNullOrWhiteSpace($attributeFound.lDAPDisplayName)) {
                $attributesByName[$attributeFound.lDAPDisplayName] = $attributeFound
            }
        }
    }
    catch {
        Write-Warning "Unable to read the attribute definitions, the confidentiality columns will be empty: $($_.Exception.Message)"
    }

    # Resolve every attribute reachable from a class: defined on it, inherited from its parents, or brought in by an
    # auxiliary class. Returns a dictionary of attribute name to the class it is defined on and whether it is
    # mandatory. Memoised, since the same parent classes are walked over and over in attribute mode.
    $resolvedClasses = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase)

    # Shared counter, incremented every time the recursion refuses to walk a class it is already inside. A result
    # computed while that happened is truncated, so it must not be cached. Held in a hashtable because a nested
    # function cannot assign to a plain variable of its parent scope.
    $resolutionState = @{ CycleHits = 0 }

    function Resolve-ClassAttribute {
        param (
            [string]$Name,
            [System.Collections.Generic.HashSet[string]]$Visited
        )

        if ($resolvedClasses.ContainsKey($Name)) {
            return $resolvedClasses[$Name]
        }

        # 'top' is its own parent, and an auxiliary class can be reachable through several paths
        if (-not $Visited.Add($Name)) {
            $resolutionState.CycleHits++
            return [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase)
        }

        $cycleHitsBefore = $resolutionState.CycleHits

        $attributes = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase)

        if (-not $classesByName.ContainsKey($Name)) {
            Write-Verbose "[*] Class '$Name' referenced but not found in the schema"
            return $attributes
        }

        $classObject = $classesByName[$Name]

        # Inherited first, so a definition on the class itself overwrites it and wins the 'Source' column
        $parents = @()

        foreach ($property in @('subClassOf', 'auxiliaryClass', 'systemAuxiliaryClass')) {
            if ($null -ne $classObject.$property) {
                $parents += @($classObject.$property)
            }
        }

        foreach ($parent in $parents) {
            if ([string]::IsNullOrWhiteSpace($parent) -or ($parent -eq $Name)) {
                continue
            }

            foreach ($inherited in (Resolve-ClassAttribute -Name $parent -Visited $Visited).GetEnumerator()) {
                # A mandatory definition anywhere wins over an optional one
                if ((-not $attributes.ContainsKey($inherited.Key)) -or ($inherited.Value.Required -eq 'Mandatory')) {
                    $attributes[$inherited.Key] = $inherited.Value
                }
            }
        }

        $directSets = @(
            @{ Properties = @('mustContain', 'systemMustContain'); Required = 'Mandatory' }
            @{ Properties = @('mayContain', 'systemMayContain'); Required = 'Optional' }
        )

        foreach ($directSet in $directSets) {
            foreach ($property in $directSet.Properties) {
                if ($null -eq $classObject.$property) {
                    continue
                }

                foreach ($attribute in @($classObject.$property)) {
                    if ([string]::IsNullOrWhiteSpace($attribute)) {
                        continue
                    }

                    # Defined on the class itself, this always replaces an inherited definition
                    $attributes[$attribute] = [PSCustomObject]@{
                        Required = $directSet.Required
                        Source   = $Name
                    }
                }
            }
        }

        # Only cache a complete result. If the recursion hit a cycle below this class, what was built is partial
        # and caching it would poison every later lookup.
        if ($resolutionState.CycleHits -eq $cycleHitsBefore) {
            $resolvedClasses[$Name] = $attributes
        }

        return $attributes
    }

    # Build one output row per class and attribute pair
    function New-AttributeInfo {
        param (
            [string]$Class,
            [string]$Attribute,
            [string]$Required,
            [string]$DefinedOn
        )

        $confidential = $null
        $canBeConfidential = $null
        $searchFlags = $null

        if ($attributesByName.ContainsKey($Attribute)) {
            $definition = $attributesByName[$Attribute]
            $flags = ConvertFrom-ADSearchFlags -SearchFlags $definition.searchFlags
            $confidential = $flags.Confidential
            $searchFlags = $flags.SearchFlags

            # systemFlags bit 0x10 is FLAG_SCHEMA_BASE_OBJECT, and the confidential bit is ignored on those
            $systemFlags = 0

            if ($null -ne $definition.systemFlags) {
                $systemFlags = [int]$definition.systemFlags
            }

            $canBeConfidential = (-not [bool]($systemFlags -band 0x10))
        }

        # 'Direct' rather than repeating the class name, so the interesting rows stand out at a glance
        if ($DefinedOn -eq $Class) {
            $source = 'Direct'
        }
        else {
            $source = $DefinedOn
        }

        return [PSCustomObject][ordered]@{
            Class             = $Class
            Attribute         = $Attribute
            Required          = $Required
            Source            = $source
            Confidential      = $confidential
            CanBeConfidential = $canBeConfidential
            SearchFlags       = $searchFlags
        }
    }

    [System.Collections.Generic.List[PSCustomObject]]$results = @()

    if ($PSCmdlet.ParameterSetName -eq 'Class') {

        if (-not $classesByName.ContainsKey($ClassName)) {
            Write-Error "Class '$ClassName' not found in the schema"
            return
        }

        $resolvedName = $classesByName[$ClassName].lDAPDisplayName
        $attributes = Resolve-ClassAttribute -Name $resolvedName -Visited ([System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase))

        foreach ($attribute in ($attributes.Keys | Sort-Object)) {
            $results.Add((New-AttributeInfo -Class $resolvedName -Attribute $attribute -Required $attributes[$attribute].Required -DefinedOn $attributes[$attribute].Source))
        }
    }
    else {
        Write-Verbose "[i] Looking for '$AttributeName' across $($classesByName.Count) classes"

        foreach ($name in ($classesByName.Keys | Sort-Object)) {
            $attributes = Resolve-ClassAttribute -Name $name -Visited ([System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase))

            foreach ($attribute in ($attributes.Keys | Sort-Object)) {
                # -like covers both the exact name and the wildcard forms
                if ($attribute -like $AttributeName) {
                    $results.Add((New-AttributeInfo -Class $name -Attribute $attribute -Required $attributes[$attribute].Required -DefinedOn $attributes[$attribute].Source))
                }
            }
        }

        if ($results.Count -eq 0) {
            Write-Warning "No class exposes an attribute matching '$AttributeName'"
        }
    }

    return $results
}