Search icon
Script icon
If icon
Log icon
SlackIncomingWebhook icon
Pause icon
Loop icon
Modify icon
Fail icon
Schedule icon

Remove LDAP Group Members Whose Entries No Longer Exist

Find LDAP group member DNs that no longer resolve, remove them with an LDIF delete, then re-search to confirm the cleanup landed.

Categories
Infrastructure

A group entry stores membership as a list of DNs. Delete a user or move them to another OU and the DN in the group does not follow. LDAP has no referential integrity of its own, so nothing cleans the stale value up. Membership drifts away from reality: access reviews read the wrong roster, groupOfNames entries fill with junk, anything that resolves members to real people starts hitting errors. This blueprint finds those dangling values and removes them.

Safety

Two rules decide what this flow will not do.

A groupOfNames entry needs at least one member by schema, so a group that would drop below the floor is refused, reported by name and left exactly as it was. The operator decides whether that group should be deleted, re-populated or kept. Removing the last member would be rejected by the server anyway. io.kestra.plugin.ldap.Modify does not fail the task on a rejected record, so an unguarded run would report success while changing nothing.

A member DN outside the existence search's own subtree is reported as out of scope and never removed, because that search could not have seen it. If the existence search comes back empty or the removable count passes the max_removals ceiling, the run reports and refuses to write at all. Both cases mean the search is mis-scoped rather than the directory being broken.

OpenLDAP can be configured with the refint overlay, which strips a reference when it sees the delete that broke it. That is worth having, but it is not a substitute for this audit. It does nothing for a DN that never resolved (an import or restore that loaded groups before users), nothing for changes made offline with slapadd, nothing on 389 Directory Server unless the referential integrity plugin is switched on. It also gives up on the last member of a group, because removing it would break the schema, which is the exact case this flow refuses and reports.

How it works

  1. detect_groups (io.kestra.plugin.ldap.Search) reads every group entry under base_dn with its object classes and its member values, into an LDIF file.
  2. resolve_entries (io.kestra.plugin.ldap.Search) reads the DN of every entry under resolve_base_dn. That is the set of DNs that exist.
  3. analyze (io.kestra.plugin.scripts.python.Script) parses both LDIF files and diffs them. DN comparison is done on normalised DNs (case folded, RDN whitespace collapsed, escaped commas respected), so a case difference is not mistaken for a missing entry. Each member value comes out as resolving, dangling or out of scope. Groups are then split into targets and refusals on the schema floor. The target list is dropped entirely if the existence search looks mis-scoped.
  4. findings_alert (io.kestra.plugin.core.flow.If) logs the result and posts it to Slack whenever there is anything to report, so a read-only run still surfaces the drift.
  5. remediate_gate runs only when auto_remediate is true, dry_run is false, there is a target and the write is safe. It pauses for a human, then remediate_members (io.kestra.plugin.core.flow.Loop, concurrencyLimit: 1) builds one LDIF delete record per group and applies it with io.kestra.plugin.ldap.Modify.
  6. verify_groups and verify_entries re-read the directory and analyze_verify re-runs the diff over the targeted values only. assert_clean fails the run through io.kestra.plugin.core.execution.Fail if any targeted value is still in its group. The errors block then alerts Slack.

The re-search is the gate on purpose. ldap.Modify logs a rejected record and carries on with a task state of success, so the task state proves nothing about whether the directory changed. Only reading the group again does.

What you get

  • Every group member DN that points at an entry that does not exist, listed per group.
  • A read-only default. Nothing is written unless two inputs are flipped and a human approves.
  • Resolving members untouched: the LDIF names only the dangling values under a single delete, so a live member is never rewritten.
  • A refusal, by name, for any group whose schema would be violated by the cleanup.
  • A re-search that fails the run when a value is still there, which is the only way to catch a silently rejected modify.
  • A Slack alert on detection and a second alert on failure.

Who it's for

  • Identity and IT teams on OpenLDAP or 389 Directory Server where users are deleted or moved between OUs.
  • Security teams running access reviews who need the group roster to match the people who exist.
  • Platform teams whose applications resolve group members at login and fail on a DN that is not there.

Why orchestrate this with Kestra

Doing this by hand means an ldapsearch for groups, an ldapsearch per member DN to see if it resolves, hand-written LDIF, ldapmodify, then another search to check. Kestra runs the whole sequence as one audited flow with the approval, the schema floor and the re-check built in. The read-only default and the disabled schedule make it safe to leave in a namespace.

Tested end to end

Run against OpenLDAP (osixia/openldap:1.5.0) on Kestra 2.0.5. Seed: five users under ou=people plus four groups under ou=groups. app-admins held two members that both resolve. app-readers held two resolving members plus the DNs of two deleted users. legacy-batch held nothing but the DN of a deleted user. uniq-team was a groupOfUniqueNames with one resolving and one deleted uniqueMember.

A pass before the deletions reported groups=4 resolved_entries=12 dangling=0. After deleting two users the same pass reported groups=4 resolved_entries=10 affected_groups=2 dangling=3 removable=2 refused=1, naming both dangling DNs in app-readers and refusing legacy-batch because removing its one dangling member would leave zero. A dry_run pass left the member lists byte for byte identical under ldapsearch. A declined approval did the same. An approved pass removed exactly the two values from app-readers, left its two resolving members in place, left app-admins and the refused legacy-batch untouched, then the re-search reported removed_values=2 still_present=0.

The gate was then proved on purpose. The same approved pass was run bound as an unprivileged account that can read the tree but not write it. The directory rejected the modify with Insufficient access (50). The io.kestra.plugin.ldap.Modify task still finished in state SUCCESS, then the re-search caught it: removed_values=0 still_present=1, the run FAILED on assert_clean and the errors block posted to Slack. A bad bind password failed the first search with result code 49 and fired the same alert.

Prerequisites

An LDAP directory reachable from Kestra and a bind account allowed to search the tree and modify group entries.

Secrets

  • LDAP_PASSWORD: bind password for the account named in bind_dn.
  • SLACK_WEBHOOK_URL: Slack incoming webhook for the findings and failure alerts.

Inputs

  • dry_run (default true) and auto_remediate (default false): both guard every write.
  • ldap_host, ldap_port, bind_dn, base_dn: connection and group search scope.
  • group_filter and member_attribute: which entries count as groups and which attribute holds the DNs.
  • resolve_base_dn and resolve_filter: the existence search. A member outside this subtree is never removed.
  • protected_object_classes and min_members_required: the schema floor that refuses a group instead of emptying it.
  • max_removals (default 200): ceiling on removals per run.

Quick start

  1. Add the LDAP_PASSWORD and SLACK_WEBHOOK_URL secrets to your namespace.
  2. Run with the defaults. The flow reports dangling member values and writes nothing.
  3. Read the report. Confirm the refused groups and anything marked out of scope are what you expect.
  4. Run again with dry_run false and auto_remediate true, then approve the pause to remove the values.
  5. Enable the schedule once you trust the report. It stays read-only until you change its inputs.

How to extend

  • Point base_dn at one OU of groups and resolve_base_dn at the whole tree, so scoping the audit never makes a member look missing.
  • Set member_attribute to uniqueMember for groupOfUniqueNames entries. Run the flow once per attribute if your directory uses both.
  • Add your own attribute to the attributes list on both search tasks for a custom membership schema.
  • Lower max_removals in a large directory so a mis-scoped search can never remove more than you would review by hand.
  • Add a step that writes the removed values to an archive entry before the delete, for an audit trail outside the Kestra logs.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.