id: quarterly-access-review-campaign
namespace: company.team
description: |
Quarterly entitlement recertification campaign for standing access held in an LDAP
directory. The flow enumerates group entitlements and account activity, risk-flags
stale, dormant, and over-privileged accounts, routes every flagged account to its
manager over Slack with a decision row in Postgres, revokes the flagged group
membership, and archives a JSON evidence pack to Amazon S3.
triggers:
- id: quarterly_review_window
type: io.kestra.plugin.core.trigger.Schedule
description: Opens the certification window at 06:00 UTC on the first day of
January, April, July, and October. Shipped disabled so the campaign can be
reviewed before it ever runs unattended.
cron: "0 6 1 1,4,7,10 *"
disabled: true
inputs:
- id: campaign_name
type: STRING
description: Label attached to the review, the Slack notices, and the Postgres
decision rows.
defaults: Quarterly Access Certification
- id: base_dn
type: STRING
description: Root distinguished name of the directory, for example dc=example,dc=com.
defaults: dc=example,dc=com
- id: max_flagged
type: INT
description: Maximum number of flagged accounts carried into notifications,
revocation, and the evidence pack.
defaults: 25
tasks:
# --- 1. enumerate standing access ---
- id: enumerate_group_entitlements
type: io.kestra.plugin.ldap.Search
description: Read every group entry under ou=groups so each entitlement and its
member list is known before the assessment starts.
hostname: "{{ secret('LDAP_HOSTNAME') }}"
port: 389
userDn: "{{ secret('LDAP_ADMIN_DN') }}"
password: "{{ secret('LDAP_ADMIN_PASSWORD') }}"
baseDn: "ou=groups,{{ inputs.base_dn }}"
filter: "(objectClass=groupOfNames)"
attributes:
- cn
- description
- member
- managedBy
sizeLimit: 500
- id: enumerate_account_activity
type: io.kestra.plugin.ldap.Search
description: Read the people tree with the logon and status attributes the risk
rules need to spot stale and dormant accounts.
hostname: "{{ secret('LDAP_HOSTNAME') }}"
port: 389
userDn: "{{ secret('LDAP_ADMIN_DN') }}"
password: "{{ secret('LDAP_ADMIN_PASSWORD') }}"
baseDn: "ou=people,{{ inputs.base_dn }}"
filter: "(objectClass=inetOrgPerson)"
attributes:
- uid
- employeeType
- accountStatus
- lastLogonTimestamp
- pwdChangedTime
- manager
sizeLimit: 1000
# --- 2. risk assessment ---
- id: assess_entitlement_risk
type: io.kestra.plugin.scripts.python.Script
description: Score every account that holds standing access against the stale,
dormant, and over-privileged rules, then publish the flagged list for the
notification loop.
inputFiles:
entitlements.ion: "{{ outputs.enumerate_group_entitlements.uri }}"
accounts.ion: "{{ outputs.enumerate_account_activity.uri }}"
script: |
import json
import re
from datetime import datetime, timedelta, timezone
CAMPAIGN = "{{ inputs.campaign_name }}"
BASE_DN = "{{ inputs.base_dn }}"
MAX_FLAGGED = {{ inputs.max_flagged }}
STALE_DAYS = 90
DORMANT_DAYS = 180
MAX_PRIVILEGED_GROUPS = 3
PRIVILEGED_MARKERS = ("admin", "privileged", "sudo", "write", "dba", "operator")
INACTIVE_STATUSES = ("disabled", "inactive", "locked", "expired")
def load_records(path):
records = []
try:
with open(path, "r", encoding="utf-8") as handle:
lines = handle.read().splitlines()
except OSError:
return records
for line in lines:
line = line.strip()
if not line:
continue
try:
record = json.loads(line)
except ValueError:
continue
if isinstance(record, dict):
records.append(record)
return records
def as_list(value):
if value is None:
return []
if isinstance(value, list):
return [entry for entry in value if entry is not None]
return [value]
def parse_timestamp(value):
if value is None:
return None
text = str(value).strip()
if not text:
return None
if text.endswith("Z"):
text = text[:-1]
if text.isdigit() and len(text) >= 14:
stamp = text[:14]
text = (
stamp[0:4] + "-" + stamp[4:6] + "-" + stamp[6:8] + "T"
+ stamp[8:10] + ":" + stamp[10:12] + ":" + stamp[12:14]
)
try:
parsed = datetime.fromisoformat(text)
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed
def slugify(text):
slug = re.sub(r"[^a-z0-9]+", "-", str(text).lower()).strip("-")
return slug or "campaign"
entitlements = load_records("entitlements.ion")
accounts = load_records("accounts.ion")
groups_by_member = {}
privileged_groups = set()
for group in entitlements:
group_dn = str(group.get("dn") or "").strip()
if not group_dn:
continue
group_name = str(group.get("cn") or group_dn).lower()
if any(marker in group_name for marker in PRIVILEGED_MARKERS):
privileged_groups.add(group_dn)
for member in as_list(group.get("member")):
groups_by_member.setdefault(str(member), []).append(group_dn)
accounts_by_dn = {}
for account in accounts:
dn = str(account.get("dn") or "").strip()
if dn:
accounts_by_dn[dn] = account
now = datetime.now(timezone.utc)
stale_cutoff = now - timedelta(days=STALE_DAYS)
dormant_cutoff = now - timedelta(days=DORMANT_DAYS)
flagged = []
scanned = 0
for dn in sorted(groups_by_member):
account = accounts_by_dn.get(dn)
if account is None:
continue
scanned += 1
last_logon = None
for key in ("lastLogonTimestamp", "lastLogon", "lastLogin"):
last_logon = parse_timestamp(account.get(key))
if last_logon is not None:
break
flags = []
if last_logon is None or last_logon < stale_cutoff:
flags.append("stale_logon")
status = str(account.get("accountStatus") or "").strip().lower()
inactive = status in INACTIVE_STATUSES
if inactive or (last_logon is not None and last_logon < dormant_cutoff):
flags.append("dormant_account")
memberships = groups_by_member.get(dn, [])
privileged = sorted(
group_dn for group_dn in memberships if group_dn in privileged_groups
)
if len(privileged) > MAX_PRIVILEGED_GROUPS:
flags.append("over_privileged")
if not flags:
continue
risk = "high" if ("dormant_account" in flags or "over_privileged" in flags) else "medium"
uid = str(account.get("uid") or dn.split(",")[0].replace("uid=", ""))
flagged.append({
"uid": uid,
"dn": dn,
"manager": str(account.get("manager") or ""),
"employee_type": str(account.get("employeeType") or ""),
"account_status": status,
"risk": risk,
"flags": flags,
"flags_text": ",".join(flags),
"membership_count": len(memberships),
"privileged_group_count": len(privileged),
"groups": memberships,
"revoke_group_dns": privileged or memberships,
})
risk_rank = {"high": 0, "medium": 1, "low": 2}
flagged.sort(key=lambda item: (risk_rank.get(item["risk"], 3), item["uid"]))
flagged_total = len(flagged)
capped = flagged[:MAX_FLAGGED] if MAX_FLAGGED > 0 else []
truncated = flagged_total > len(capped)
verdict = "action_required" if capped else "clean"
report = {
"campaign": CAMPAIGN,
"base_dn": BASE_DN,
"verdict": verdict,
"generated_at": now.isoformat(timespec="seconds"),
"entitlement_count": len(entitlements),
"account_count": len(accounts),
"scanned_accounts": scanned,
"flagged_total": flagged_total,
"flagged_count": len(capped),
"truncated": truncated,
"flagged_accounts": capped,
}
payload = {
"outputs": {
"campaign": CAMPAIGN,
"campaign_slug": slugify(CAMPAIGN),
"verdict": verdict,
"scanned_accounts": scanned,
"account_count": len(accounts),
"entitlement_count": len(entitlements),
"flagged_total": flagged_total,
"flagged_count": len(capped),
"truncated": truncated,
"flagged_users": capped,
"report_json": json.dumps(report),
}
}
print("::" + json.dumps(payload) + "::")
# --- 3. manager notifications and decision rows ---
- id: notify_flagged_accounts
type: io.kestra.plugin.core.flow.Loop
description: Iterate over the flagged accounts so each one gets a manager
notification and a decision row.
values: "{{ outputs.assess_entitlement_risk.vars.flagged_users }}"
runIf: "{{ outputs.assess_entitlement_risk.vars.flagged_count > 0 }}"
tasks:
- id: request_manager_decision
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Ask the account manager to recertify or reject the flagged standing
access before the revocation step.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{
"text": "Access review {{ inputs.campaign_name }}: {{ item.value | jq('.uid') | first }} is flagged for {{ item.value | jq('.flags_text') | first }} (risk {{ item.value | jq('.risk') | first }}) across {{ item.value | jq('.membership_count') | first }} entitlement(s) with manager {{ item.value | jq('.manager') | first }}. Record the decision for execution {{ execution.id }}."
}
- id: record_decision_row
type: io.kestra.plugin.jdbc.postgresql.Queries
description: Append one decision row per flagged account so the campaign leaves
a queryable audit trail beside the evidence pack.
url: "{{ secret('POSTGRES_URL') }}"
username: "{{ secret('POSTGRES_USER') }}"
password: "{{ secret('POSTGRES_PASSWORD') }}"
fetchType: NONE
sql: |
INSERT INTO access_review_decisions (campaign_name, account_uid, manager_dn, risk, flags, membership_count, review_status, created_at)
VALUES ('{{ inputs.campaign_name }}', '{{ item.value | jq('.uid') | first }}', '{{ item.value | jq('.manager') | first }}', '{{ item.value | jq('.risk') | first }}', '{{ item.value | jq('.flags_text') | first }}', {{ item.value | jq('.membership_count') | first }}, 'pending_manager_review', NOW());
# --- 4. revoke flagged standing access ---
- id: revocation_gate
type: io.kestra.plugin.core.flow.If
description: Apply the revocation set only when the assessment flagged at least
one account, otherwise record that nothing needed revoking.
condition: "{{ outputs.assess_entitlement_risk.vars.flagged_count > 0 }}"
then:
- id: build_revocation_records
type: io.kestra.plugin.core.storage.Write
description: Render one Ion modify record per flagged account and entitlement
that deletes the account DN from that group member attribute.
extension: .ion
content: |
{% for user in outputs.assess_entitlement_risk.vars.flagged_users %}
{% for group_dn in user.revoke_group_dns %}
{dn:"{{ group_dn }}",changeType:"modify",modifications:[{operation:"DELETE",attribute:"member",values:["{{ user.dn }}"]}]}
{% endfor %}
{% endfor %}
- id: convert_revocations_to_ldif
type: io.kestra.plugin.ldap.IonToLdif
description: Convert the Ion modify records into the LDIF format the Modify task
expects.
inputs:
- "{{ outputs.build_revocation_records.uri }}"
- id: apply_revocations
type: io.kestra.plugin.ldap.Modify
description: Apply the modify operations, where a group that no longer exists is
logged and skipped without blocking the rest of the revocation set.
hostname: "{{ secret('LDAP_HOSTNAME') }}"
port: 389
userDn: "{{ secret('LDAP_ADMIN_DN') }}"
password: "{{ secret('LDAP_ADMIN_PASSWORD') }}"
inputs:
- "{{ outputs.convert_revocations_to_ldif.urisList[0] }}"
else:
- id: skip_revocation
type: io.kestra.plugin.core.log.Log
description: Nothing was flagged, so the run records a clean recertification and
touches no group membership.
message: "{{ outputs.assess_entitlement_risk.vars.campaign }} came back clean
with 0 flagged accounts, so no entitlement was revoked."
# --- 5. evidence pack ---
- id: render_evidence_pack
type: io.kestra.plugin.scripts.python.Script
description: Write the campaign report as a single JSON artifact that the S3
upload can archive.
inputFiles:
report_payload.json: "{{ outputs.assess_entitlement_risk.vars.report_json }}"
outputFiles:
- access_review_report.json
script: |
import json
with open("report_payload.json", "r", encoding="utf-8") as handle:
report = json.load(handle)
with open("access_review_report.json", "w", encoding="utf-8") as handle:
json.dump(report, handle, indent=2, sort_keys=True)
print(
"rendered evidence report for "
+ str(report.get("campaign"))
+ " with "
+ str(report.get("flagged_count"))
+ " flagged account(s)"
)
- id: upload_evidence_pack
type: io.kestra.plugin.aws.s3.Upload
description: Archive the JSON evidence report under a campaign prefix so
auditors can pull the certification record for the quarter.
from: "{{ outputs.render_evidence_pack.outputFiles['access_review_report.json']
}}"
key: "access-reviews/{{ outputs.assess_entitlement_risk.vars.campaign_slug
}}/access_review_report.json"
bucket: "{{ secret('S3_EVIDENCE_BUCKET') }}"
region: "{{ secret('AWS_DEFAULT_REGION') }}"
accessKeyId: "{{ secret('AWS_ACCESS_KEY_ID') }}"
secretKeyId: "{{ secret('AWS_SECRET_ACCESS_KEY') }}"
- id: campaign_summary
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Post the campaign verdict, the flagged count, and the archived
evidence location to the security channel.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{
"text": "Quarterly access review {{ outputs.assess_entitlement_risk.vars.campaign }} finished with verdict {{ outputs.assess_entitlement_risk.vars.verdict }}. Flagged {{ outputs.assess_entitlement_risk.vars.flagged_count }} of {{ outputs.assess_entitlement_risk.vars.scanned_accounts }} reviewed account(s), entitlements read {{ outputs.assess_entitlement_risk.vars.entitlement_count }}. Evidence pack at s3://{{ outputs.upload_evidence_pack.bucket }}/{{ outputs.upload_evidence_pack.key }}"
}
errors:
- id: slack_alert
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Alert the security channel when the campaign fails, since a
half-finished certification cannot be used as audit evidence.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{"text": "FAILED {{ flow.id }} run {{ execution.id }} - the {{ inputs.campaign_name }} access review stopped before the evidence pack was archived."}
outputs:
- id: campaign
type: STRING
value: "{{ outputs.assess_entitlement_risk.vars.campaign }}"
- id: verdict
type: STRING
value: "{{ outputs.assess_entitlement_risk.vars.verdict }}"
- id: flagged_count
type: INT
value: "{{ outputs.assess_entitlement_risk.vars.flagged_count }}"
- id: scanned_accounts
type: INT
value: "{{ outputs.assess_entitlement_risk.vars.scanned_accounts }}"
- id: evidence_bucket
type: STRING
value: "{{ outputs.upload_evidence_pack.bucket }}"
- id: evidence_key
type: STRING
value: "{{ outputs.upload_evidence_pack.key }}"