Schedule icon
Search icon
If icon
Switch icon
Write icon
IonToLdif icon
Modify icon
Log icon
SlackIncomingWebhook icon

LDAP Privileged Group Membership Gate

Audit whether an LDAP account still belongs to a privileged group, alert on lingering access, and optionally revoke it.

Categories
CoreInfrastructure

An offboarding ticket that moves an account out of the active people tree but forgets one admin group leaves behind exactly the kind of access nobody is watching for - the account looks gone, but it is still sitting inside cn=admins or whatever sensitive group it was never explicitly removed from. This blueprint checks both halves of that gap with io.kestra.plugin.ldap.Search: does the account still exist, and if so, is it still listed in the privileged group's own member attribute. It classifies the result into a three-state risk level, alerts on anything but a clean bill of health, and can revoke the one membership with io.kestra.plugin.ldap.Modify, behind two independent gates.

How it works

  1. periodic_membership_check (io.kestra.plugin.core.trigger.Schedule) runs every 6 hours, always forcing auto_remediate: "false" and dry_run: "true" through its own inputs: override regardless of this flow's defaults. Shipped disabled so you can validate a manual run first.
  2. check_account_exists (io.kestra.plugin.ldap.Search) filters people_base_dn by (uid={{ target_uid }}), sizeLimit: 1. Its only output is uri, an LDIF file that is empty when nothing matches.
  3. check_group_membership (io.kestra.plugin.ldap.Search) filters privileged_group_dn itself by (member=uid={{ target_uid }},{{ people_base_dn }}), sizeLimit: 1 - a non-empty result means that exact member DN is present on the group entry right now.
  4. evaluate_account_presence (io.kestra.plugin.core.flow.If) branches on read(outputs.check_account_exists.uri) != ''.
    • else (UNREACHABLE): alert_account_unreachable reports that the account has no entry at all.
    • then (account exists): nested evaluate_group_membership (io.kestra.plugin.core.flow.If) branches on read(outputs.check_group_membership.uri) != ''.
      • else (HEALTHY): log_membership_healthy records a clean check.
      • then (AT_RISK): route_remediation (io.kestra.plugin.core.flow.Switch on auto_remediate) either considers remediation ("true", gated again by dry_run through check_dry_run) or only logs that remediation is disabled ("false"). When both gates allow it, an Ion modify record naming only privileged_group_dn's member attribute is written, converted to LDIF with io.kestra.plugin.ldap.IonToLdif, and applied with io.kestra.plugin.ldap.Modify - the same Ion-to-LDIF shape this repo's own ldap-employee-offboarding-deprovision.yaml uses. alert_still_a_member always fires to Slack regardless of which remediation path ran.
  5. log_audit_complete always runs last, printing the headline numbers regardless of which branch fired.
  6. The errors block alerts Slack separately if the flow itself fails outright - an unreachable directory or bad bind credentials must not read as "no risk".

What you get

  • A direct, two-part answer - does the account exist, is it still in the group - instead of inferring either from an offboarding ticket's status field.
  • A distinct UNREACHABLE state so an account that never existed (or a typo'd target_uid) is never misread as a clean HEALTHY result.
  • Remediation scoped to exactly one DELETE of one member value on one named group - it cannot touch the account's own entry, any other group, or any other attribute.
  • Two independent gates (auto_remediate, dry_run) between "still a member" and "membership actually revoked".
  • A final status log every run, risk or not, so the execution history doubles as an access-review trail for that account and group.

Who it's for

  • IT and identity teams running an on-premises OpenLDAP, ApacheDS, or similar directory who want a scheduled check that an offboarding or access-review action actually took effect on a specific sensitive group.
  • Security teams doing periodic privileged-access reviews who want one Slack alert naming the exact account and group, instead of exporting the whole group and diffing it by hand.
  • Anyone extending this repo's own ldap-employee-offboarding-deprovision.yaml - that blueprint's own "How to extend" section suggests exactly this: "Add a scheduled io.kestra.plugin.ldap.Search trigger elsewhere in the catalog to audit for stale accounts that were never offboarded through this path."

Why orchestrate this with Kestra

Two LDAP searches run by hand answer one moment in time; they do not decide a cadence, classify the combined result into a three-state risk level, gate a membership change behind two independent confirmations, or notify anyone. Kestra supplies the schedule, the HEALTHY/AT_RISK/UNREACHABLE classification as a first-class branch, two independent authorization gates before any membership is revoked, and an execution history that shows exactly when an account's privileged access should have been removed and whether it was.

Prerequisites

  • An LDAP directory reachable from Kestra, with the account's entry under people_base_dn and the sensitive group using a schema with a multi-valued member attribute (for example groupOfNames).
  • A bind account with permission to search people_base_dn and privileged_group_dn, and, if auto_remediate will ever be "true", permission to modify privileged_group_dn.
  • A Slack incoming webhook for alerts.

Local testing: docker run -d --name ldap-gate -p 3890:389 --env LDAP_ORGANISATION="Example Inc" --env LDAP_DOMAIN="example.com" --env LDAP_ADMIN_PASSWORD="<your-local-password>" osixia/openldap:1.5.0 docker network connect YOUR_KESTRA_NETWORK ldap-gate (check existing networks first with docker network ls; only needed if the Kestra Worker runs in a separate Docker network than this container; set LDAP_HOSTNAME to ldap-gate from inside that network, or localhost with port: 3890 from the host - the Search/Modify tasks' port property is a plain input, not read from a secret, so adjust the task-level port: 389 in this flow to 3890 for host-side testing). Host port 3890 is used here specifically so it never collides with Kestra's own UI on 8080, and 389 stays free for an in-network connection.

Secrets

  • LDAP_HOSTNAME: hostname of the LDAP server.
  • LDAP_ADMIN_DN / LDAP_ADMIN_PASSWORD: bind credentials used by every io.kestra.plugin.ldap.Search/Modify task in this flow.
  • SLACK_WEBHOOK_URL: Slack incoming webhook used by alert_still_a_member, alert_account_unreachable, and the errors block.
  • In Kestra OSS (no Enterprise secrets backend), secrets are supplied as environment variables prefixed SECRET_, base64-encoded, and read back in flows with {{ secret('NAME') }} - for example SECRET_LDAP_ADMIN_PASSWORD=$(echo -n '<your-local-password>' | base64). This keeps credentials out of the flow YAML but, per Kestra's own documentation, offers no encryption at rest or access control beyond the host environment; use the Enterprise secrets backend for stronger guarantees.

Inputs

  • target_uid (STRING, default jdoe): uid of the account audited.
  • people_base_dn (STRING, default ou=people,dc=example,dc=com): OU searched for target_uid.
  • privileged_group_dn (STRING, default cn=admins,ou=groups,dc=example,dc=com): sensitive group audited for lingering membership.
  • auto_remediate (SELECT: "false", "true"; default "false"): must be "true" for remediation to even be considered.
  • dry_run (SELECT: "true", "false"; default "true"): must be explicitly "false", together with auto_remediate: "true", for the group membership to actually be revoked.

Outputs

  • outputs.check_account_exists.uri: LDIF file for the uid lookup, every run; read(...) on it is empty when the account does not exist.
  • outputs.check_group_membership.uri: LDIF file for the group-membership check, every run; read(...) on it is empty when the account is not a member.
  • outputs.remove_group_membership: present only when remediation actually ran.

Quick start

  1. Start OpenLDAP locally with the command above, create a jdoe entry under ou=people,dc=example,dc=com, and add it as a member of cn=admins,ou=groups,dc=example,dc=com.
  2. Add the LDAP_HOSTNAME, LDAP_ADMIN_DN, LDAP_ADMIN_PASSWORD, and SLACK_WEBHOOK_URL secrets.
  3. Run the flow manually with the defaults and confirm alert_still_a_member fires (AT_RISK: the account exists and is still a group member).
  4. Run once with auto_remediate: "true" and dry_run: "true" and confirm log_dry_run_remediation describes the removal without applying it.
  5. Run once with auto_remediate: "true" and dry_run: "false" and confirm remove_group_membership runs, then verify with an LDAP search on cn=admins that jdoe is no longer listed.
  6. Point target_uid at a uid that does not exist under people_base_dn and re-run to confirm alert_account_unreachable fires (UNREACHABLE) instead of either HEALTHY or AT_RISK.
  7. Enable periodic_membership_check once you trust the check; it always runs in the safe auto_remediate: "false" / dry_run: "true" mode regardless of what you leave the flow's own defaults set to.

How to extend

  • Loop over several target_uid/privileged_group_dn pairs with io.kestra.plugin.core.flow.Loop to run the same gate across an entire access-review list in one flow.
  • Chain this flow after ldap-employee-offboarding-deprovision.yaml with a Subflow task as a same-run verification that the offboarding's own group revocation actually took effect, instead of waiting for the next scheduled audit.
  • Add a second check_group_membership-style task per additional sensitive group, and fold every result into one combined AT_RISK condition, for an account that must be checked against several privileged groups at once.
  • Replace the hardcoded (member=...) filter with (|(member=...)(uniqueMember=...)) if your directory mixes groupOfNames and groupOfUniqueNames schemas across different groups.

Pitfalls

  • io.kestra.plugin.ldap.Search has no row/count output, only uri. Every presence/absence check in this flow goes through read(outputs.x.uri) == '' on the stored LDIF file, not a structured field - there is nothing like outputs.search.totalHits for this plugin, unlike some other search-oriented plugins in this repo.
  • The group search relies on default SUB scope matching only the group entry itself. A groupOfNames entry has no descendants, so searching privileged_group_dn with the default scope and a (member=...) filter can only ever return that one group entry or nothing - it does not recurse into nested groups. A nested-group structure needs a separate lookup per group in the chain.
  • This only revokes membership in the one group you name. An account flagged AT_RISK in cn=admins may still hold access through a completely different group this flow was never told to check - it is a single-group gate, not a full privileged-access inventory.
  • There is no structured "list group members" or "check membership" task in this plugin. Both checks go through the same generic io.kestra.plugin.ldap.Search task used by this repo's own ldap-employee-offboarding-deprovision.yaml, and remediation reuses that same blueprint's Ion-to-LDIF-to-Modify chain rather than inventing a new one.
  • auto_remediate and dry_run are independent gates, not a single boolean. Both must be "true"/"false" respectively for the group membership to actually be revoked; setting only one leaves the other still blocking.
  • Switch case keys "true"/"false" are quoted strings. auto_remediate renders as the literal string "true" or "false"; unquoted true:/false: YAML map keys would parse as booleans instead and would not match.
  • This blueprint is UNTESTED against a live LDAP directory. Search's uri-only output, the connection properties (hostname, port, userDn, password), and the Ion/LDIF remediation shape are all taken verbatim from the plugin's source and this repo's own existing LDAP blueprint - but no Docker container or Kestra engine was run to execute this flow end to end.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.