AD Permissions Analyzer — Implementation Plan¶
Target: PowerShell 7+ script to produce a 100% comprehensive inventory of every DACL ACE on every object in a single Active Directory domain, across all naming contexts, for least-privilege analysis.
Consumer: This document is consumed by Claude Code as the implementation specification. House style (foreach, .Where({}), typed collections, CmdletBinding, structured JSONL logging, no Set-StrictMode, no ForEach-Object/Where-Object) is mandatory throughout.
1. Locked-in Scope¶
| Decision | Value |
|---|---|
| Domain scope | Single domain (parameterized; default = current) |
| Naming contexts | All: Domain NC, Configuration NC, Schema NC, all DNS application partitions |
| SD scope | DACL + Owner (SACL and Group excluded) — Owner is captured and surfaced as a synthetic ACE row in detail CSV |
| SD control flags | Captured: SE_DACL_PROTECTED surfaced as IsDaclProtected column |
| Object-specific ACE decoding | Yes — resolve property, property-set, extended-right, and child-class GUIDs to human names |
| Inherited ACE handling | Capture all ACEs; flag inherited; resolve and record the source DN where the ACE was originally defined |
| Trustee group expansion | Yes — transitive expansion of group trustees to effective members |
| Orphaned/unresolvable SID handling | Categorize and emit (see §10) |
| Output | Two CSV files: per-ACE detail + per-trustee pivot |
| File splitting | Single detail file (no per-NC/per-class split) |
| Execution context | Admin workstation or utility server; online LDAP only |
| Target object count | ~30,000 objects (design ceiling) |
| Parallelism | Runspace pools only (no ForEach-Object -Parallel, no Start-Job) |
| Use case | Pure inventory snapshot |
2. Explicitly Out of Scope¶
- SACL / audit ACE enumeration
- SD's Group identifier (rarely meaningful in modern AD)
- Multi-domain / forest-wide enumeration
- Cross-forest trust traversal
- Offline
ntdsutil ifmsnapshot mode - Risky-pattern flagging (GenericAll/WriteDacl/DCSync detection, etc.)
- Baseline diffing or anomaly detection
- Remediation actions
- GPO permission analysis (the GPO AD object's ACL is in scope; SYSVOL file ACLs are not)
These are deliberately excluded per user direction. Do not add them. If the user later requests them, they become a separate script or a feature flag — not silent additions.
3. High-Level Architecture¶
┌─────────────────────────────────────────────────────────────────┐
│ Phase 1: Discovery & Map Building (single-threaded) │
│ • Bind to RootDSE, enumerate NCs │
│ • Build GUID → name maps (extended rights, schema) │
│ • Build SID → principal cache (initialize, lazy-fill) │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Phase 2: Object Enumeration (single-threaded per NC) │
│ • Paged LDAP search per NC via S.DS.Protocols │
│ • Retrieve: distinguishedName, objectClass, objectGUID, │
│ nTSecurityDescriptor (with SecurityDescriptorFlagControl) │
│ • Batch into work units of N objects (default 250) │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Phase 3: ACE Parsing (runspace pool, parallel) │
│ • Parse nTSecurityDescriptor → ActiveDirectorySecurity │
│ • For each ACE: extract, decode, resolve GUIDs │
│ • Emit raw ACE records │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Phase 4: Trustee Resolution & Group Expansion (single-threaded)│
│ • Resolve all distinct trustee SIDs │
│ • Transitively expand groups → effective members │
│ • Categorize orphans │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Phase 5: Inheritance Source Resolution (single-threaded) │
│ • For each inherited ACE, walk parent chain │
│ • Match against explicit ACEs collected in Phase 3 │
│ • Record InheritanceSourceDN │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Phase 6: Output (streaming) │
│ • Write detail CSV (one row per ACE × effective trustee) │
│ • Write pivot CSV (one row per trustee, aggregated) │
└─────────────────────────────────────────────────────────────────┘
Why this order: Maps must exist before ACE decoding. Trustee/inheritance resolution needs the full ACE set to be complete (e.g., to find the explicit parent ACE for an inherited child ACE). Output is last, written via streaming StreamWriter to avoid pinning the whole result set in memory.
4. Script Parameters¶
Invoke-ADPermissionAnalysis
-Domain <string> # FQDN; default: current user's domain
-Server <string> # Specific DC; default: locator
-Credential <PSCredential> # Optional; default: current context
-OutputDirectory <string> # Required
-DetailFileName <string> # Default: "ADPermissions_Detail_<yyyyMMdd-HHmmss>.csv"
-PivotFileName <string> # Default: "ADPermissions_Pivot_<yyyyMMdd-HHmmss>.csv"
-LogFileName <string> # Default: "ADPermissions_<yyyyMMdd-HHmmss>.jsonl"
-BatchSize <int> # Default: 250 objects per runspace work unit
-ThreadCount <int> # Default: [Environment]::ProcessorCount
# [ValidateRange(1, 32)] — values >16 typically waste effort (LDAP becomes the bottleneck)
-PageSize <int> # Default: 1000 (LDAP page size)
# [ValidateRange(100, 1000)] — server-side cap is 1000 in AD
-IncludeNamingContexts <string[]> # Default: @('Domain','Configuration','Schema','DNS')
# Allow filtering for testing
-SkipTransitiveExpansion <switch> # Escape hatch; default off
All parameters: [Parameter()] with [ValidateNotNullOrEmpty()] / [ValidateRange()] as appropriate. Use [CmdletBinding()] on every function. Return objects, not text.
5. Function Decomposition¶
Each function lives in its own logical section with comment-based help (Synopsis, Description, Parameter, Example).
Phase 1 — Discovery & Maps¶
Get-ADNamingContext— returns[List[PSObject]]of NCs with DN, type (Domain/Config/Schema/DNS), and root DSE attributes.New-ADExtendedRightsMap— returns[Dictionary[guid,PSObject]]keyed byrightsGuid. Source:CN=Extended-Rights,CN=Configuration,.... Covers allcontrolAccessRightobjects: extended rights, validated writes, AND property sets — distinguish byvalidAccesses(0x100= extended right,0x08= validated write,0x30= property set). Each value carriesDisplayName,AppliesTo(schema GUIDs),ValidAccesses(mask),RightKind('ExtendedRight'/'ValidatedWrite'/'PropertySet').New-ADSchemaGuidMap— returns[Dictionary[guid,PSObject]]keyed byschemaIDGUID. Source: Schema NC. Each value carriesLdapDisplayName,ObjectCategory(attributeSchema vs classSchema). Used to resolve single attributes (ReadProperty/WritePropertyACEs targeting one attribute) and object classes (CreateChild/DeleteChild/InheritedObjectType). Property sets are not in this map — they're in ExtendedRightsMap.New-PropertySetMembersMap— returns[Dictionary[guid,List[string]]]keyed by property-setrightsGuid, value is the list oflDAPDisplayNames of member attributes. Source: Schema NCattributeSchemaobjects whoseattributeSecurityGUIDattribute is non-null — that GUID matches the property set'srightsGuid(an extended-right GUID, NOT a schema GUID). Reverse-indexed during construction.New-WellKnownSidMap— static[Dictionary[string,string]]of well-known SIDs not resolvable viaNTAccount.Translate()reliably (S-1-5-32-* etc. usually work, but pre-populate edge cases likeS-1-3-0/Creator Owner,S-1-5-10/Self,S-1-5-9/Enterprise DCs). See §10 for the full no-expand SID list this map must cover.
Phase 2 — Enumeration¶
Get-ADObjectAclBatch— paged LDAP search viaSystem.DirectoryServices.Protocols.LdapConnection+SearchRequest+PageResultRequestControl+SecurityDescriptorFlagControl(OWNER | DACL). Yields batches of[List[PSObject]](DN,structuralObjectClass(withobjectClasslast-value as fallback), ObjectGUID, rawnTSecurityDescriptorbyte[]). Implements early exit on empty result set.- LDAP connection auth:
LdapConnection.AuthType = AuthType.Negotiate(Kerberos with NTLM fallback). UseLdapDirectoryIdentifierwithconnectionless: $falseandfullyQualifiedDnsHostName: $true. LDAPS (port 636) and Basic auth are not supported in v1. - Why both OWNER and DACL flags: Owner is part of the SD and required for least-privilege analysis (the owner has implicit
READ_CONTROL/WRITE_DACregardless of DACL). Including OWNER inSecurityDescriptorFlagControladds zero LDAP roundtrips — same paged search. - Why S.DS.Protocols, not the AD module: AD module's
Get-ACLper object = 30k LDAP roundtrips. Paged S.DS.P withSecurityDescriptorFlagControlretrieves SD inline with the search and is 1–2 orders of magnitude faster. Memory stays bounded via paging.
Phase 3 — ACE Parsing (runs inside runspace pool)¶
ConvertFrom-NtSecurityDescriptor— accepts rawbyte[], returns a record carryingOwner(SecurityIdentifier),Dacl(ActiveDirectorySecurity), andIsDaclProtected(bool, fromActiveDirectorySecurity.AreAccessRulesProtectedwhich surfaces theSE_DACL_PROTECTEDcontrol bit). Built vianew-object [ActiveDirectorySecurity]+SetSecurityDescriptorBinaryForm. DACL + Owner.Add-OwnerAce— emits a synthetic ACE record from the parsed Owner SID:AceType = 'Synthetic.Owner',RightsDecoded = 'OwnerImplicit',AccessMask = (ReadControl | WriteDacl | WriteOwner),IsInherited = $false,IsDaclProtected = <inherited from object>,TrusteeSid = $owner.Value. One synthetic row per object regardless of DACL contents.ConvertFrom-AdAce— accepts a singleActiveDirectoryAccessRuleplus the parent object'sIsDaclProtectedflag, returns a flatPSObjectwith:ObjectDN,ObjectClass,ObjectGUID,TrusteeSid,AccessMask,AccessControlType(Allow/Deny),AceType(e.g.,AccessAllowedObject),RightsDecoded(e.g.,GenericAll, ReadProperty, WriteProperty),ObjectTypeGuid,ObjectTypeName(decoded via maps),ObjectTypeKind(Property/PropertySet/ExtendedRight/ClassChild/All/Unresolved),InheritedObjectTypeGuid,InheritedObjectTypeName,InheritanceFlags,IsInherited,IsDaclProtected,AceIndex(0-based ordinal within the source DACL),AceFlagsRaw.Invoke-AceParsingWorkUnit— runspace work-unit body. Accepts a batch + the GUID maps (ExtendedRightsMap,SchemaGuidMap,PropertySetMembersMap, passed by reference into the runspace). For each object: parse SD → emit synthetic Owner ACE → enumerate DACL ACEs → emit one record each. Returns[List[PSObject]]of ACE records.
Phase 4 — Trustee Resolution¶
Resolve-TrusteeSid— accepts SID string. Resolution order:- Cache lookup
[SecurityIdentifier]::new($sid).Translate([NTAccount])- Well-known SID map
- ForeignSecurityPrincipals container (
CN=ForeignSecurityPrincipals,<domainNC>) - Mark as
Orphaned - Returns
PSObject:Sid,Name(DOMAIN\sam or fallback),PrincipalType(User/Group/Computer/ManagedServiceAccount/GroupManagedServiceAccount/ForeignSecurityPrincipal/WellKnown/Orphaned/Unknown),DistinguishedName(when resolvable in-domain).ManagedServiceAccountcorresponds toobjectClass=msDS-ManagedServiceAccount(legacy sMSA);GroupManagedServiceAccountcorresponds toobjectClass=msDS-GroupManagedServiceAccount(current gMSA). Expand-GroupTransitive— for trustees of type Group, returns[List[PSObject]]of effective member principals. UseLDAP_MATCHING_RULE_IN_CHAIN(OID1.2.840.113556.1.4.1941) onmemberOffor performance: one query per group resolves the entire transitive closure. Cache group → expanded-member-set keyed by group SID. Detect and break circular cases (the matching rule handles cycles, but cap recursion as a safety net).Get-DistinctTrusteeSet— single pass over the ACE record list to dedupe SIDs before resolving. 30k × 30 ACEs avg = ~900k ACE rows but typically <5k distinct trustees.
Phase 5 — Inheritance Source¶
Resolve-InheritanceSource— for each ACE whereIsInherited = $true:- If the ACE's parent object has
IsDaclProtected = $true, logBatchErrorand markInheritanceSourceNote = 'InconsistentProtectedDacl'(an inherited ACE on a protected DACL is internally inconsistent). - Otherwise walk parent chain of
ObjectDNupward. - At each ancestor, direct-lookup the composite-key
$AceIndex(see §9) by(parent DN, TrusteeSid, AccessMask, ObjectTypeGuid); for each candidate verifyInheritanceFlagsPropagateToto the descendant. - The first match walking up is by definition the nearest matching ancestor — record as
InheritanceSourceDN. If none found (rare; possible with default schema ACEs that originate at the domain root or have no explicit form), recordInheritanceSourceDN = $nullandInheritanceSourceNote = 'SchemaDefaultOrUnresolved'. - Index construction: build a
[Dictionary[ValueTuple[string,string,uint32,guid], List[PSObject]]]over explicit ACEs only, keyed by(ObjectDN, TrusteeSid, AccessMask, ObjectTypeGuid). See §9 for the algorithm. Direct lookup at each ancestor → O(n × depth); old DN-only key was O(n × depth × candidates_per_dn).
Phase 6 — Output¶
Write-DetailCsv— opensStreamWriter, writes header, streams rows. One row per(ACE × effective trustee). Schema in §11.Write-PivotCsv— aggregates the in-memory ACE+trustee join. One row per effective trustee. Schema in §11.
Cross-cutting¶
Write-LogEvent— JSONL. Fields:timestamp(ISO-8601 UTC),level,phase,event,message,data(object). Used for milestones and errors only — no per-object spam. Emit phase-start / phase-end with counts. (NamedWrite-LogEvent— notWrite-Log— becauseLogis not an approved PowerShell verb; PSScriptAnalyzer rulePSUseApprovedVerbsenforces this.)New-RunspacePool— wraps[runspacefactory]::CreateRunspacePool.InitialSessionStatecarries the GUID maps (ExtendedRightsMap,SchemaGuidMap,PropertySetMembersMap) and the well-known SID map preloaded so each runspace doesn't rebuild them.Invoke-RunspacePoolWork— generic dispatcher: accepts pool + work-unit scriptblock + collection of input batches; returns aggregated results. UsesBeginInvoke/EndInvoke. Captures per-batch failures into a separate error list (does not throw — log and continue per house style).
6. Data Structures¶
| Structure | Type | Purpose |
|---|---|---|
$NamingContexts |
List[PSObject] |
NC list from Phase 1 |
$ExtendedRightsMap |
Dictionary[guid,PSObject] |
rightsGuid → name (extended rights, validated writes, property sets) |
$SchemaGuidMap |
Dictionary[guid,PSObject] |
schemaIDGUID → name (single attributes, object classes) |
$PropertySetMembersMap |
Dictionary[guid,List[string]] |
property-set rightsGuid → member attribute names |
$WellKnownSidMap |
Dictionary[string,string] |
SID → friendly name |
$AceRecords |
List[PSObject] |
All parsed ACEs (Phase 3 output) |
$AceIndex |
Dictionary[ValueTuple[string,string,uint32,guid], List[PSObject]] |
Inheritance composite-key index — see §9 |
$TrusteeCache |
Dictionary[string,PSObject] |
SID → resolved principal |
$GroupExpansionCache |
Dictionary[string,List[PSObject]] |
Group SID → transitive members |
$PivotStats |
Dictionary[string,PSObject] |
Effective-trustee SID → running aggregates (built during streaming detail write — see §12) |
$ErrorBag |
List[PSObject] |
Captured errors |
Memory note: 900k ACE rows × ~400 bytes ≈ ~360 MB pre-expansion. Acceptable on an admin workstation. Detail rows are NOT materialised in memory — Phase 6 streams (ACE × effective trustee) rows directly to the detail CSV StreamWriter while updating $PivotStats aggregates incrementally (see §12). Worst-case 5M expanded rows would be ~2 GB if held; streaming caps peak memory at the aggregate state size (~5k trustees × few KB ≈ ~5 MB). After detail CSV is written, the script writes pivot from $PivotStats and clears all caches via Remove-Variable.
7. ACE Decoding Rules¶
For each ActiveDirectoryAccessRule:
RightsDecoded — bitwise decompose ActiveDirectoryRights:
- Generic: GenericAll, GenericRead, GenericWrite, GenericExecute
- Standard: Delete, ReadControl, WriteDacl, WriteOwner, Synchronize, AccessSystemSecurity
- Object-specific: CreateChild, DeleteChild, ListChildren, Self, ReadProperty, WriteProperty, DeleteTree, ListObject, ExtendedRight
- Emit comma-delimited string of present flags.
ObjectType GUID interpretation — depends on AceType and RightsDecoded:
| Right | ObjectType GUID means | Resolution order |
|---|---|---|
ExtendedRight |
Extended right or validated write | ExtendedRightsMap (validAccesses=0x100 or 0x08) |
ReadProperty / WriteProperty |
Property set OR single attribute | 1. ExtendedRightsMap with validAccesses=0x30 → property set (also list members from PropertySetMembersMap); 2. SchemaGuidMap → single attribute; 3. else → unresolved |
CreateChild / DeleteChild |
Child object class | SchemaGuidMap (filtered to classSchema) |
Self |
Validated write | ExtendedRightsMap (validAccesses=0x08) |
| (none / zero GUID) | Applies to all properties / all child types | — |
InheritedObjectType GUID — only meaningful for ACEs that propagate; identifies the child class the ACE applies to when inherited (e.g., "applies to descendant user objects only"). Resolve via SchemaGuidMap (classSchema filter).
ObjectTypeKind field disambiguates which lookup produced the name. Values: Property (single attribute), PropertySet, ExtendedRight, ValidatedWrite, ClassChild, All (zero GUID), Unresolved (no map hit — record raw GUID for forensics).
IsDaclProtected propagation — every ACE record carries the parent object's IsDaclProtected flag (extracted in Phase 3 from ActiveDirectorySecurity.AreAccessRulesProtected). When true, the object's DACL is shielded from inheritance (SE_DACL_PROTECTED / "Disable inheritance" in the GUI), and no inherited ACEs should appear on it. The Phase 5 inheritance-source resolver uses this as an internal-consistency check (see §9).
8. GUID Map Construction Details¶
ExtendedRightsMap source:
- LDAP search: CN=Extended-Rights,CN=Configuration,<forestDN>
- Filter: (objectClass=controlAccessRight)
- Attributes: cn, displayName, rightsGuid, appliesTo, validAccesses
- Key: [guid]$rightsGuid
- Covers all three kinds of controlAccessRight:
- Extended rights — validAccesses = 0x100 (ADS_RIGHT_DS_CONTROL_ACCESS). Examples: User-Force-Change-Password, DS-Replication-Get-Changes-All (DCSync).
- Validated writes — validAccesses = 0x08 (ADS_RIGHT_DS_SELF). Examples: Validated-DNS-Host-Name, Self-Membership.
- Property sets — validAccesses = 0x30 (ADS_RIGHT_DS_READ_PROP | ADS_RIGHT_DS_WRITE_PROP). Examples: Personal-Information, Phone-and-Mail-Options.
- Each value carries RightKind field ('ExtendedRight'/'ValidatedWrite'/'PropertySet') derived from validAccesses to simplify §7 lookup logic.
SchemaGuidMap source:
- LDAP search: <schemaNC>
- Filter: (|(objectClass=attributeSchema)(objectClass=classSchema))
- Attributes: lDAPDisplayName, schemaIDGUID, objectClass
- Key: [guid][byte[]]$schemaIDGUID
- Each value carries LdapDisplayName and ObjectCategory ('attributeSchema' or 'classSchema').
- Does NOT contain property sets. Property sets are controlAccessRight objects in the Configuration NC (Extended-Rights container), not schema objects. They live in ExtendedRightsMap. The link from a member attribute to its property set is via the property set's rightsGuid (an extended-right GUID), not its schemaIDGUID.
PropertySetMembersMap source:
- LDAP search: <schemaNC>
- Filter: (&(objectClass=attributeSchema)(attributeSecurityGUID=*)) — only attributes that are members of a property set
- Attributes: lDAPDisplayName, attributeSecurityGUID
- Key: [guid][byte[]]$attributeSecurityGUID (= the property set's rightsGuid)
- Value: List[string] of lDAPDisplayNames of attributes belonging to that property set.
- Built as a reverse index: each row contributes one entry to the list under its property set's key.
Page size: 1000 for all three. Maps are bounded: ~hundreds for extended rights, ~3000 for schema, ~hundreds of distinct property sets each with a few to dozens of member attributes.
9. Inheritance Source Resolution — Algorithm¶
Index construction (before the resolution loop):
Build a composite-key index over explicit ACEs only:
$AceIndex = Dictionary[ValueTuple[string, string, uint32, guid], List[PSObject]]()
foreach ($ace in $AceRecords) {
if ($ace.IsInherited) { continue }
$key = [ValueTuple[string, string, uint32, guid]]::new(
$ace.ObjectDN, # parent DN we'll lookup
$ace.TrusteeSid,
$ace.AccessMask,
$ace.ObjectTypeGuid ?? [guid]::Empty
)
if (-not $AceIndex.ContainsKey($key)) {
$AceIndex[$key] = [List[PSObject]]::new()
}
$AceIndex[$key].Add($ace)
}
This trades a small amount of memory for direct-lookup match (no .Where() scan per ancestor). Worst-case complexity drops from O(n × depth × candidates_per_dn) ≈ ~90M ops at the design ceiling to O(n × depth) ≈ ~9M ops.
Resolution loop:
For each ACE where IsInherited = true:
if ACE.IsDaclProtected:
# Internal-consistency check: a DACL_PROTECTED object should have
# NO inherited ACEs. If we see one, log a BatchError — likely a
# malformed SD or a parser bug.
Log-BatchError 'InheritedAceOnProtectedDacl' $ACE
ACE.InheritanceSourceDN = null
ACE.InheritanceSourceNote = 'InconsistentProtectedDacl'
continue
parent = Parent(ACE.ObjectDN)
while parent is not null and parent is within any enumerated NC:
key = (parent, ACE.TrusteeSid, ACE.AccessMask, ACE.ObjectTypeGuid ?? Empty)
candidates = $AceIndex[key] # may be empty
match = candidates.Where({
InheritanceFlagsPropagateTo(parent, ACE.ObjectDN, ACE.ObjectClass, $_.InheritanceFlags, $_.InheritedObjectTypeGuid)
})
if match.Count -gt 0:
ACE.InheritanceSourceDN = parent
break # first match walking up is by definition the nearest ancestor
parent = Parent(parent)
if not found:
ACE.InheritanceSourceDN = null
ACE.InheritanceSourceNote = 'SchemaDefaultOrUnresolved'
InheritanceFlagsPropagateTo is a helper that checks ContainerInherit / ObjectInherit / InheritOnly / NoPropagateInherit flags and the InheritedObjectType class filter against the descendant's class.
Edge cases to handle:
- ACEs originating from defaultSecurityDescriptor of the schema class (no explicit parent ACE exists for these — they're materialized at object creation). Mark SchemaDefaultOrUnresolved.
- Multiple matching ancestors (e.g., same ACE explicitly placed at two levels): record the nearest matching ancestor — the one closest to the descendant by parent-chain distance. The walk-up algorithm finds it first by construction.
- Inherited ACE seen on a DACL_PROTECTED object: log BatchError and mark InheritanceSourceNote = 'InconsistentProtectedDacl'. Should not occur for a well-formed SD.
- ACEs on cross-NC objects (rare): stay within the originating NC.
10. Orphaned/Unresolvable SID Handling — Recommendation¶
Categorize every trustee into exactly one PrincipalType:
| Category | Detection |
|---|---|
User / Group / Computer |
Resolved via in-domain LDAP lookup; objectClass determines type |
ManagedServiceAccount |
objectClass = msDS-ManagedServiceAccount (legacy sMSA) |
GroupManagedServiceAccount |
objectClass = msDS-GroupManagedServiceAccount (current gMSA) |
WellKnown |
Resolved via NTAccount.Translate() to BUILTIN\* or NT AUTHORITY\*, or matched in WellKnownSidMap |
ForeignSecurityPrincipal |
Found as object under CN=ForeignSecurityPrincipals,<domainNC> |
Orphaned |
All resolution paths failed; SID format valid; likely deleted account |
Unknown |
All resolution failed and SID itself is malformed (should never happen but handle defensively) |
Output row always contains the raw SID string regardless of category. Orphaned trustees still get a row in the detail CSV — they are exactly the kind of finding least-privilege reviews want to surface. Pivot CSV groups orphans together for visibility.
Do not throw on unresolvable SIDs. Log one structured event per distinct orphan SID at INFO level (event: "OrphanSid").
Well-known SIDs that MUST NOT be transitively expanded¶
Treat the following as terminal trustees in detail/pivot CSVs (no group-membership expansion). These either have implicit/runtime-computed memberships not retrievable via LDAP_MATCHING_RULE_IN_CHAIN, or have memberships so large that expansion produces a meaningless explosion of rows.
Cross-domain / well-known (full SIDs):
S-1-1-0 Everyone
S-1-5-7 Anonymous Logon
S-1-5-11 Authenticated Users
S-1-5-2 Network
S-1-5-4 Interactive
S-1-5-9 Enterprise Domain Controllers
S-1-5-10 Self / Principal Self
S-1-3-0 Creator Owner
S-1-3-1 Creator Group
Domain-relative (RID; prefix with current domain SID at runtime):
-498 Enterprise Read-only Domain Controllers
-513 Domain Users
-514 Domain Guests
-515 Domain Computers
-516 Domain Controllers
-521 Read-only Domain Controllers
BUILTIN aliases (S-1-5-32-*): treat as terminal — they resolve via NTAccount.Translate()
to local-machine groups and have no AD-wide membership semantics relevant to AD ACL analysis.
Implementation: a single [HashSet[string]] of full SIDs (well-known) plus a list of RIDs to combine with the runtime-discovered domain SID. Resolve-TrusteeSid checks this set before invoking Expand-GroupTransitive; matched trustees are returned with EffectiveTrusteeSid = AceTrusteeSid (themselves) and IsThroughGroup = $false.
11. Output Schema¶
Detail CSV — ADPermissions_Detail_<timestamp>.csv¶
One row per (target object × ACE × effective trustee) after group expansion.
| Column | Type | Notes |
|---|---|---|
ObjectDN |
string | Target object DN |
ObjectClass |
string | structuralObjectClass if present; else last value of objectClass |
ObjectGUID |
guid | |
NamingContext |
string | Domain / Configuration / Schema / DNS: |
AceTrusteeSid |
string | Raw SID from ACE |
AceTrusteeName |
string | Resolved name (may be raw SID for orphans) |
AceTrusteePrincipalType |
string | See §10 |
EffectiveTrusteeSid |
string | After group expansion; equals AceTrusteeSid for non-group ACEs |
EffectiveTrusteeName |
string | |
EffectiveTrusteePrincipalType |
string | |
EffectiveTrusteeDN |
string | When in-domain |
IsThroughGroup |
bool | True if effective trustee differs from ACE trustee |
GroupExpansionPath |
string | Semicolon-delimited group chain (e.g., "GroupA -> NestedGroupB"); empty for direct |
AceType |
string | E.g., AccessAllowedObject, AccessDeniedObject, or Synthetic.Owner |
AceIndex |
int | 0-based ordinal of the ACE within the source DACL (before group expansion). For Synthetic.Owner rows: -1. |
AccessControlType |
string | Allow / Deny |
RightsDecoded |
string | Comma-delimited rights. For Synthetic.Owner: 'OwnerImplicit' |
AccessMask |
uint32 | Raw mask for diffing. For Synthetic.Owner: (ReadControl \| WriteDacl \| WriteOwner) |
ObjectTypeGuid |
guid? | |
ObjectTypeName |
string | Decoded |
ObjectTypeKind |
string | Property / PropertySet / ExtendedRight / ValidatedWrite / ClassChild / All / Unresolved |
InheritedObjectTypeGuid |
guid? | |
InheritedObjectTypeName |
string | |
IsInherited |
bool | |
IsDaclProtected |
bool | True if the parent object's DACL has SE_DACL_PROTECTED set (inheritance disabled). All ACEs on a protected object share the same value. |
InheritanceSourceDN |
string | Empty for explicit; populated for inherited |
InheritanceSourceNote |
string | E.g., SchemaDefaultOrUnresolved, InconsistentProtectedDacl |
InheritanceFlags |
string | ContainerInherit / ObjectInherit / etc. |
AceFlagsRaw |
byte | Raw byte for forensics |
CollectedAt |
datetime | UTC ISO-8601 |
Synthetic Owner row: every object emits exactly one row with AceType = 'Synthetic.Owner' capturing the SD's owner. The owner has implicit READ_CONTROL/WRITE_DAC/WRITE_OWNER regardless of DACL, so this row makes that access path visible in least-privilege analysis. AceIndex = -1 distinguishes it from real DACL ACEs (which start at 0).
Pivot CSV — ADPermissions_Pivot_<timestamp>.csv¶
One row per effective trustee.
| Column | Type | Notes |
|---|---|---|
EffectiveTrusteeSid |
string | |
EffectiveTrusteeName |
string | |
EffectiveTrusteePrincipalType |
string | |
EffectiveTrusteeDN |
string | |
TotalAceCount |
int | All ACEs across all objects |
DirectAceCount |
int | Where IsThroughGroup = false |
IndirectAceCount |
int | Where IsThroughGroup = true |
DistinctObjectCount |
int | Distinct ObjectDNs touched |
AllowAceCount |
int | |
DenyAceCount |
int | |
ExplicitAceCount |
int | |
InheritedAceCount |
int | |
RightsSummary |
string | Top rights aggregated, e.g., "GenericAll:42; WriteProperty:118; ReadProperty:980" |
NamingContextsTouched |
string | Semicolon-delimited |
ObjectClassesTouched |
string | Semicolon-delimited with counts, e.g., "user:14; group:3; organizationalUnit:1" |
CollectedAt |
datetime |
CSV writing: Export-Csv is fine for small data, but for the detail file (potentially ~1M rows) use StreamWriter with manual CSV escaping (RFC 4180: quote fields containing ,, ", or newline; double internal quotes). Avoid pipeline overhead.
12. Performance & Parallelism¶
Sizing target: 30,000 objects, ~30 ACEs/object average → ~900,000 raw ACE rows pre-expansion. Group expansion may multiply detail rows 2–10x for ACEs assigned to large groups; design for ~5M detail rows worst case.
Sequential phases (single-threaded by design): - Phase 1 (maps): trivial cost - Phase 2 enumeration: bound by LDAP server throughput; paged search is inherently sequential per NC - Phase 4 (trustee resolution): single-threaded with cache; resolution is fast (< few seconds) - Phase 5 (inheritance): single-threaded but uses pre-built index → O(n × depth)
Parallel phase (runspace pool):
- Phase 3 ACE parsing. Work units = batches of 250 objects. Pool size = [Environment]::ProcessorCount.
- Each runspace receives the GUID maps via InitialSessionState.Variables so they aren't rebuilt or marshalled per batch.
- Each runspace returns a List[PSObject] of ACE records; main thread aggregates into $AceRecords.
Runspace pool pattern (sketch):
$iss = [InitialSessionState]::CreateDefault()
$iss.Variables.Add( [SessionStateVariableEntry]::new('ExtendedRightsMap', $extendedRightsMap, '') )
$iss.Variables.Add( [SessionStateVariableEntry]::new('SchemaGuidMap', $schemaGuidMap, '') )
$pool = [runspacefactory]::CreateRunspacePool(1, $ThreadCount, $iss, $Host)
$pool.Open()
# Submit batches via BeginInvoke; collect via EndInvoke; dispose handles
$pool.Close(); $pool.Dispose()
Memory hygiene:
- Set large temporaries to $null between phases ($batches = $null after Phase 3 completes).
- Remove-Variable for the GUID maps and caches at end of script.
- Stream-and-write per ACE (Phase 6): expand-and-write per ACE rather than building the full expanded list in memory. Pivot accumulates aggregates as detail rows are emitted, avoiding a second pass and keeping peak memory bounded at the aggregate state size (~5k trustees × few KB ≈ ~5 MB).
Phase 6 streaming pattern:
$detailWriter = [StreamWriter]::new($detailPath, $false, [Text.Encoding]::UTF8)
$detailWriter.WriteLine($DETAIL_HEADER)
$pivotStats = [Dictionary[string, PSObject]]::new()
foreach ($ace in $AceRecords) {
$effective = Resolve-EffectiveTrustees $ace # expand groups (Phase 4 cache)
foreach ($et in $effective) {
Write-DetailRow -Writer $detailWriter -Ace $ace -EffectiveTrustee $et
Update-PivotAggregate -Stats $pivotStats -EffectiveTrustee $et -Ace $ace
}
}
$detailWriter.Flush(); $detailWriter.Dispose()
# Pivot is now ready in $pivotStats — no second pass over $AceRecords needed
Write-PivotCsv -Path $pivotPath -Stats $pivotStats
This caps detail-side peak memory at the size of $AceRecords (pre-expansion ~360 MB) rather than 5M expanded rows × ~400 bytes ≈ ~2 GB.
Early exits: If any NC returns zero objects, log and skip. If the GUID maps are empty (corrupt schema or insufficient rights), throw — this is unrecoverable per house style.
13. Logging (JSONL)¶
Implemented by Write-LogEvent (named to satisfy PSUseApprovedVerbs — Log is not an approved PowerShell verb). One event per line. Required fields: timestamp (ISO-8601 UTC), level, phase, event, message. Optional: data (nested object), correlationId.
Events to emit (and only these — no per-object logging):
- ScriptStart (params summary)
- PhaseStart / PhaseEnd for each of the 6 phases (with counts and elapsed ms in data)
- NamingContextDiscovered (one per NC)
- MapBuilt (extended rights count, schema GUID count, property-set count)
- EnumerationProgress (every N batches, e.g., every 5000 objects)
- OrphanSid (one per distinct orphan)
- BatchError (per-batch parse failures from runspaces; also InheritedAceOnProtectedDacl per §9 inconsistency check)
- ScriptEnd (totals, elapsed, output paths)
Levels: INFO, WARN, ERROR. No DEBUG chatter at default verbosity.
Console progress: for the enumeration phase (potentially minutes-long on a 30k-object domain), emit Write-Progress ticks at the same cadence as EnumerationProgress JSONL events: per-NC percentage complete, current batch index, elapsed time. Do NOT use Write-Host (PSScriptAnalyzer rule PSAvoidUsingWriteHost). Write-Progress is the correct stream for interactive progress and is suppressed cleanly in non-interactive runs.
14. Error Handling¶
Per house style: throw only on unrecoverable, otherwise capture/log/continue.
| Condition | Action |
|---|---|
| LDAP bind failure | Throw |
| Cannot read RootDSE | Throw |
| Schema or Extended-Rights query returns 0 | Throw |
| Single object enumeration error | Log BatchError, skip object, continue |
| ACE parse failure on a specific object | Log, emit a placeholder ACE record with RightsDecoded = "PARSE_ERROR" and the raw byte length, continue |
| SID resolution failure | Categorize as Orphaned, no error |
| Group expansion failure on a specific group | Log WARN, fall back to direct trustee only, continue |
| Inheritance source not found | InheritanceSourceNote = "SchemaDefaultOrUnresolved", no error |
| CSV write failure | Throw (unrecoverable — output is the deliverable) |
All caught errors append to $ErrorBag. Final ScriptEnd log event includes errorCount.
Exit codes (for CLI / CI consumers):
| Code | Meaning |
|---|---|
| 0 | Success — errorCount = 0 |
| 1 | Unrecoverable error — LDAP bind, RootDSE read, schema/extended-rights query empty, CSV write failure |
| 2 | Success-with-warnings — errorCount > 0 (partial inventory written; check log to see which objects failed) |
Implemented via try/catch at top level: re-throw unrecoverable errors (PowerShell surfaces these as exit 1 by default, but explicit exit 1 in the top-level catch makes it deterministic). At end of script: if ($ErrorBag.Count -gt 0) { exit 2 } else { exit 0 }.
15. Dependencies¶
Required:
- Operating system: Windows. PowerShell 7+ on Windows. Required for System.DirectoryServices.ActiveDirectorySecurity parsing and NTAccount.Translate() SID resolution. Linux/macOS PowerShell 7 will load the type but fail at the parsing step — the script is Windows-only. (Verified empirically via CI: Ubuntu test job failed; switched to windows-latest.) Add to script header: # Windows-only — uses System.DirectoryServices types not available on .NET on Linux/macOS.
- PowerShell 7+
- .NET assemblies (built-in): System.DirectoryServices, System.DirectoryServices.Protocols, System.Security.Principal
- Network reachability to a writable DC for the target domain on port 389 (LDAP). Port 9389 (ADWS) is NOT used.
Authentication: Kerberos (Negotiate) only — LdapConnection.AuthType = AuthType.Negotiate. NTLM fallback within Negotiate is acceptable. LDAPS (port 636) and Basic auth are not supported in v1.
Optional / not used:
- ActiveDirectory PowerShell module — not required. The script uses S.DS.P directly for performance and to avoid the module's per-call overhead. If a function genuinely benefits from it (e.g., a fallback Get-ADObject for niche resolution), gate behind a capability check — do not hard-require.
Permissions needed by the executing principal:
- Read access to all enumerated NCs (Domain, Configuration, Schema, DNS partitions)
- Read access to nTSecurityDescriptor attribute on all objects (this is the non-trivial requirement — typically requires Domain Admins or an explicitly delegated principal). Document this prominently in script help.
16. Open Questions / Decisions Deferred¶
These were not raised during planning. Implementation may proceed with the noted defaults, but flag them at the top of the script header so future maintainers see them.
- ACE deduplication for identical inherited ACEs from the same source — when two parent containers contribute the same logical ACE to a descendant (rare but possible with explicit ACE replication), do we emit two rows or one with merged source DNs? Default: emit one row per logical ACE, with
InheritanceSourceDNset to the nearest match (per §9 algorithm). - Deny ACE precedence — the report includes both Allow and Deny ACEs but does not compute effective access (which would require resolving Allow/Deny conflicts per Windows access-check semantics). Inventory only.
- Well-known trustee expansion — well-known SIDs (Domain Users, Authenticated Users, Everyone, etc.) and BUILTIN aliases are treated as terminal trustees and not transitively expanded. The full skip list is enumerated in §10. Configurable via a future flag if needed.
- Tombstoned object handling — deleted-objects container is not enumerated by default. Confirm this is desired.
17. Validation¶
No live-LDAP smoke run is in scope: there is no lab DC available to this project. End-to-end validation against a real directory and the 30k-object performance pass are removed deliverables, not deferred ones. If a lab DC becomes available later, validation can be re-added as a new section and as additional steps in §18 — but until then, the implementation's correctness rests entirely on the Pester unit suites attached to each phase.
The unit suites that ship with the implementation cover, at the helper-function level:
- Phase 1: GUID-map builders (Extended-Rights, Schema, PropertySet members) and well-known SID map.
- Phase 2: paged-search shape, batch boundaries, raw-byte SD passthrough, GUID conversion,
structuralObjectClassfallback. - Phase 3: SD parsing, synthetic Owner row shape, RightsDecoded composition, all five
ObjectTypeKindclassifications,AceFlagsRawcomposition, PARSE_ERROR isolation, runspace-pool aggregation, BatchError capture. - Phase 4: trustee resolution cascade (cache → Translate → WellKnownSid → FSP → Orphan), well-known SID skip-set, in-chain group expansion with cycle/cap behaviour.
- Phase 5: composite-key index over explicit rows only, escape-aware
Get-ParentDistinguishedName,Test-InheritanceFlagsPropagateToflag-bit + class-filter rule,Resolve-InheritanceSourcedirect-parent / level-2 walk / DACL_PROTECTED short-circuit /SchemaDefaultOrUnresolvedfallback. - Phase 6: RFC-4180 escape, Detail-CSV column shape, Synthetic.Owner / PARSE_ERROR / inherited row passthrough, group fan-out, NC-label longest-suffix match, pivot accumulator counters, pivot summary formatters with sort tiebreaks, full pivot/detail reconciliation on a fixture (
sum(stats[*].TotalAceCount) == row count).
Anything that depends on real-directory state — schema-default ACE handling on a fresh user, DACL_PROTECTED + Convert anomaly behaviour, transitive expansion correctness on real nested groups, property-set name resolution for a real PropertySetMembersMap, throughput on a 30k-object domain — is uncovered by this plan. The script's behaviour on those cases is asserted by the unit suites against fabricated SDs; behaviour on real SDs is unverified. Operators running this against a production directory should treat the first run as exploratory and review the JSONL log for BatchError / PARSE_ERROR / OrphanSid / InheritedAceOnProtectedDacl events before acting on the CSV output.
18. Implementation Order for Claude Code¶
Build in this sequence so each phase is independently testable:
- Parameters + script skeleton +
Write-LogEvent - Phase 1: NC discovery + GUID maps —
ExtendedRightsMap,SchemaGuidMap,PropertySetMembersMap(testable standalone) - Phase 2: Enumeration with
SecurityDescriptorFlagControl(OWNER | DACL)(testable: count objects per NC) - Phase 3: SD parsing (
ConvertFrom-NtSecurityDescriptorreturns Owner + DACL + IsDaclProtected) and ACE parsing including syntheticAdd-OwnerAce— first single-threaded, then wrap in runspace pool - Phase 4: Trustee resolution + group expansion + well-known SID skip list (testable: known group SID → expected member list; well-known group → terminal)
- Phase 5: Inheritance source resolution with composite-key index + DACL_PROTECTED short-circuit
- Phase 6: Detail CSV writer (streaming, with
AceIndexandIsDaclProtectedcolumns andSynthetic.Ownerrows) - Phase 6: Pivot CSV writer (built incrementally during detail streaming via
$PivotStats)
Do not skip ahead. Each phase commits independently.
Step 8 is the final implementation step. There is no end-to-end smoke run and no 30k-object performance pass — both required a lab DC that is not available to this project (see §17). If lab access becomes available later, those validations would be additive new steps; they are not preconditions for considering the implementation complete.