Field notes · powershell

Reconciling group membership without lockouts

Safe Active Directory reconciliation separates adds from removals, protects critical groups, previews every change, and requires exact approval before changes.

·7 min read

Group reconciliation is easy to describe and dangerous to oversimplify. Compare expected membership with actual membership, then add the missing entries and remove the extras. The first half is usually recoverable. The second half can lock someone out, break a service, or erase a legitimate exception that the matrix never knew about.

I therefore automate the report and safe additions first. Removals remain fail-closed until a human approves the exact user, group, reason, and rollback plan.

Key takeaways

  • Compare stable identifiers, not display text.
  • Distinguish direct membership from effective nested membership.
  • Generate separate Add, Keep, Review, and RemoveCandidate records.
  • Never infer policy from whatever access currently exists.
  • Protect privileged, infrastructure, and break-glass groups from bulk removal.
  • Require -WhatIf, exact approval, logs, and readback for every removal.
  • Treat partial success as a reportable state, not a reason to hide results.

What should a reconciliation report compare?

Compare the user’s expected direct baseline and approved exceptions with the current direct memberships that are in scope for the policy. Keep effective nested access as a separate view because a direct edit to a parent group can affect many people who never appear as direct members of the resource group.

The basic sets are:

expected = role baseline + active approved exceptions
actual   = current direct memberships in managed scope

missing  = expected minus actual
extra    = actual minus expected
correct  = intersection(expected, actual)

The dangerous word is extra. It means only “not explained by this matrix.” It does not yet mean “unauthorized.” The membership may come from a special duty, project, emergency, manual application workflow, or stale policy documentation.

I label that set RemoveCandidate or Review, never Remove, until ownership and approval are known.

The expected side comes from the schema in Designing an Access Matrix PowerShell Can Enforce. Individual exceptions are joined separately so the matrix remains a reusable baseline.

How do nested groups change the answer?

Nested groups separate direct membership from effective access. A user can gain access through a child group without appearing directly in the parent, and a recursive query may return leaf users while hiding the group path that explains why they are included.

Microsoft’s Get-ADGroupMember documentation states that -Recursive returns members through the hierarchy that do not contain child objects. That is useful for an effective-member view, but it is not the same inventory as direct group membership.

I keep three questions separate:

  1. Which groups directly contain this user?
  2. Which managed groups should directly contain this user?
  3. Which resource groups effectively include this user through nesting?

Microsoft’s Get-ADPrincipalGroupMembership retrieves groups that contain a specified principal and requires a global catalog for the search. That can help build the direct view, but the reconciliation logic still needs a documented domain and nesting scope.

I do not flatten nested access and then try to remove the user from every parent group. The user may not be a direct member, and the child group may represent a valid role shared by many people. Reconciliation should explain the path before proposing a change.

Why separate additions from removals?

Missing baseline access has a clear approved source and is usually easy to undo. An unexplained current membership is ambiguous and can represent critical access. Running both directions in one unattended loop gives the least-understood change the same confidence as the best-understood one.

My staged model is:

Action Default handling Verification
Keep Report only Current state already matches
Add Preview, approve, apply idempotently Read group membership back
Review Route to owner Record decision or exception
RemoveCandidate Never apply in ordinary reconciliation Exact approval and rollback required
Manual Create tracked task Verify in the target system

The onboarding workflow already adds missing baseline groups idempotently. If a run partially fails, a completion script can retry only the remaining groups and skip those already held. That is a much safer automation surface than removing every membership the role map does not recognize.

This is the same caution I use when revoking access everywhere during offboarding: the inventory must be broader than the automation, and every system needs a verified end state.

What should the preview contain?

The preview should identify the user, stable role ID, group, direct or inherited relationship, proposed action, policy reason, exception status, owner, and whether the target is protected. It should be an object list that can be reviewed and exported, not just colored console text.

A synthetic record might look like this:

[pscustomobject]@{
    User             = 'sample.user'
    RoleId           = 'parks-field'
    Group             = 'FILE-Parks-Modify'
    MembershipType    = 'Direct'
    ProposedAction    = 'Add'
    Reason            = 'Missing approved baseline membership.'
    Protected         = $false
    ExceptionRecordId = $null
}

Before mutation, I require the preview count and an immutable identifier for the change set. Enforcement consumes that exact approved set. If directory state changes between preview and apply, the script should revalidate and stop or produce a new preview rather than quietly adapting.

For one result or many, PowerShell callers should capture output as an array:

$changes = @(Get-GroupReconciliation -Identity 'sample.user')
Write-Host "Proposed changes: $($changes.Count)"

Without @(...), one object can become a scalar and make indexing or count-based approval checks behave differently from a multi-change run.

Which groups should automation never remove from?

Protect domain-wide administrators, service and infrastructure groups, break-glass access, directory synchronization, application service identities, and any group whose ownership or downstream effect is unknown. The protected list should be an allow-deny policy outside the ordinary role map.

I also block removal when:

  • The user’s role or exception data is missing.
  • The group owner is unknown.
  • The membership is inherited rather than direct.
  • The target cannot be resolved uniquely.
  • The user is a service, shared, emergency, or privileged account.
  • The directory read is incomplete or stale.
  • The proposed set exceeds a configured change threshold.

A threshold is not a safety proof, but it catches a malformed role map that suddenly proposes removing dozens of groups from every user. The script should stop and preserve the preview for investigation.

The role-based access control loop is explicitly not privileged access management. Protected groups require a separate owner and control process even if they are technically visible to PowerShell.

What does a safe removal workflow require?

A safe removal requires exact approval, a final current-state check, PowerShell preview support, a pre-change membership snapshot, one bounded mutation, readback, and a tested command to restore the membership. Bulk confirmation suppression should never be the first line of the runbook.

Microsoft documents that Remove-ADGroupMember supports -WhatIf and prompts for confirmation by default. That prompt is a last guard, not a substitute for policy review.

The enforcement function should use SupportsShouldProcess:

function Remove-ApprovedGroupMembership {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    param(
        [Parameter(Mandatory = $true)]
        [string]$SamAccountName,

        [Parameter(Mandatory = $true)]
        [string]$GroupName
    )

    $ErrorActionPreference = 'Stop'

    $target = "$SamAccountName from $GroupName"
    if ($PSCmdlet.ShouldProcess($target, 'Remove direct membership')) {
        Remove-ADGroupMember -Identity $GroupName `
            -Members $SamAccountName -Confirm:$false -ErrorAction Stop
    }
}

The snippet is compatible with Windows PowerShell 5.1 and uses ASCII only. In a complete script I would pass a fully qualified directory server, resolve both objects before ShouldProcess, and refuse protected groups before reaching this function.

After removal, read the direct membership again. If the readback does not match, mark the change failed. If the removal succeeded but an application still grants access through another group or cached token, report that separately rather than pretending the directory mutation completed the business outcome.

How should partial failure and rollback be recorded?

Record every attempted change independently with its pre-state, approval ID, timestamp, operator, result, error category, and readback. Do not erase successful rows because a later group failed, and do not rerun the whole batch without reconciling current state again.

A useful result set distinguishes:

  • AppliedAndVerified
  • AlreadyCurrent
  • SkippedProtected
  • NeedsOwnerReview
  • FailedBeforeChange
  • ChangedButReadbackFailed
  • RolledBackAndVerified
  • RollbackFailed

Rollback for an accidental group removal is normally an approved add operation, but that does not guarantee immediate application access. Kerberos tickets, cloud synchronization, application caches, and nested group evaluation can delay the effective result. The incident procedure should account for those layers.

I keep the original preview and final result together. That makes it possible to answer what was approved, what code attempted, and what the directory actually showed afterward.

Frequently asked questions

Reconciliation should make drift visible first. Destructive enforcement is a separate, more highly controlled decision.

Should extra memberships be removed automatically after a grace period?

Not merely because time passed. The grace period can trigger review, but removal still needs a known owner, policy reason, direct-membership proof, and exact approval.

Does -WhatIf guarantee the change is safe?

No. It shows what a correctly implemented function intends to call. It does not prove the access matrix is right, the target resolves uniquely, or the user has no valid exception.

Can reconciliation cover Microsoft 365 and vendor applications too?

Yes as a reporting model, but each system needs its own identity, readback, mutation, propagation, and rollback behavior. Do not reuse Active Directory assumptions for a cloud group or vendor role.