From ff93f3bf42f4e2ed6e3cd90bb7983e4754e25bae Mon Sep 17 00:00:00 2001
From: Valera V Harseko <vharseko@3a-systems.ru>
Date: Mon, 22 Jun 2026 10:55:39 +0000
Subject: [PATCH] Refine LDAP benchmark: READD, SSHA-256 hashing, grouped charts

---
 .github/benchmark/summary.sh    |   55 +++++++++++++++++----------
 .github/benchmark/benchmark.jmx |    6 +-
 .github/workflows/benchmark.yml |   57 +++++++++++++++++++++++-----
 3 files changed, 84 insertions(+), 34 deletions(-)

diff --git a/.github/benchmark/benchmark.jmx b/.github/benchmark/benchmark.jmx
index 745cffc..78f3d04 100644
--- a/.github/benchmark/benchmark.jmx
+++ b/.github/benchmark/benchmark.jmx
@@ -17,7 +17,7 @@
 <jmeterTestPlan version="1.2" properties="5.0" jmeter="5.6.3">
   <hashTree>
     <TestPlan guiclass="TestPlanGui" testclass="TestPlan" testname="OpenDJ vs OpenLDAP - LDAP benchmark" enabled="true">
-      <stringProp name="TestPlan.comments">Parametrized LDAP benchmark. Admin bind is cached once per thread (Once Only Controller, labelled ADMIN_CONNECT, excluded from metrics). Data ops (ADD/SEARCH/COMPARE/MODIFY/DELETE/ADD WITHOUT DELETE) reuse the cached admin connection. The measured user authentication is a single bind/unbind (test=sbind, own connection) after MODIFY has set the userPassword.</stringProp>
+      <stringProp name="TestPlan.comments">Parametrized LDAP benchmark. Admin bind is cached once per thread (Once Only Controller, labelled ADMIN_CONNECT, excluded from metrics). Data ops (ADD/SEARCH/COMPARE/MODIFY/DELETE/READD) reuse the cached admin connection. The measured user authentication is a single bind/unbind (test=sbind, own connection) after MODIFY has set the userPassword.</stringProp>
       <boolProp name="TestPlan.functional_mode">false</boolProp>
       <boolProp name="TestPlan.tearDown_on_shutdown">true</boolProp>
       <boolProp name="TestPlan.serialize_threadgroups">false</boolProp>
@@ -268,8 +268,8 @@
         </LDAPExtSampler>
         <hashTree/>
 
-        <!-- ADD WITHOUT DELETE (admin connection): accumulation; unique RDN via __UUID. -->
-        <LDAPExtSampler guiclass="LdapExtTestSamplerGui" testclass="LDAPExtSampler" testname="ADD WITHOUT DELETE" enabled="true">
+        <!-- READD (admin connection): re-add without delete (accumulation); unique RDN via __UUID. -->
+        <LDAPExtSampler guiclass="LdapExtTestSamplerGui" testclass="LDAPExtSampler" testname="READD" enabled="true">
           <stringProp name="servername"></stringProp>
           <stringProp name="port"></stringProp>
           <stringProp name="rootdn"></stringProp>
diff --git a/.github/benchmark/summary.sh b/.github/benchmark/summary.sh
index ecac86c..193fb33 100644
--- a/.github/benchmark/summary.sh
+++ b/.github/benchmark/summary.sh
@@ -32,7 +32,7 @@
 DJ_IMG="${6:-}"
 
 # Operations to compare, in workflow order. ADMIN_CONNECT and Total are excluded.
-OPS=("ADD" "SEARCH" "COMPARE" "MODIFY" "BIND" "DELETE" "ADD WITHOUT DELETE")
+OPS=("ADD" "SEARCH" "COMPARE" "MODIFY" "BIND" "DELETE" "READD")
 
 # m <file> <label> <field>  -> numeric value (0 if absent), rounded to 1 decimal.
 m() { jq -r --arg l "$2" --arg f "$3" '((.[$l][$f]) // 0) | (.*10 | round / 10)' "$1"; }
@@ -85,51 +85,63 @@
 echo ""
 
 # ---------------------------------------------------------------- Chart helpers
-# Build a Mermaid list "x, y, z" of values for all OPS from <file> <field>, via <m|mi>.
-series() { # <fn> <file> <field>
-  local fn="$1" file="$2" field="$3" out="" v
-  for op in "${OPS[@]}"; do
-    v=$("$fn" "$file" "$op" "$field")
-    out+="${out:+, }${v}"
-  done
+# Mermaid xychart-beta has no grouped bars/legend, so to get two adjacent (non-overlapping)
+# columns per operation we repeat each op on the x-axis and zero-pad the two series: OpenLDAP
+# bars land on the left tick of each pair, OpenDJ on the right tick.
+#
+# x-axis: each op twice -> "ADD", "ADD", "SEARCH", "SEARCH", ...
+xaxis_pairs() {
+  local out="" op
+  for op in "${OPS[@]}"; do out+="${out:+, }\"${op}\", \"${op}\""; done
   printf '%s' "$out"
 }
-# x-axis with every label quoted (labels contain spaces).
-xaxis() {
-  local out="" op
-  for op in "${OPS[@]}"; do out+="${out:+, }\"${op}\""; done
+# OpenLDAP series: value then 0 per op (bar on the left tick of each pair).
+series_ol() { # <fn> <file> <field>
+  local fn="$1" file="$2" field="$3" out="" v
+  for op in "${OPS[@]}"; do v=$("$fn" "$file" "$op" "$field"); out+="${out:+, }${v}, 0"; done
+  printf '%s' "$out"
+}
+# OpenDJ series: 0 then value per op (bar on the right tick of each pair).
+series_dj() { # <fn> <file> <field>
+  local fn="$1" file="$2" field="$3" out="" v
+  for op in "${OPS[@]}"; do v=$("$fn" "$file" "$op" "$field"); out+="${out:+, }0, ${v}"; done
   printf '%s' "$out"
 }
 
-XAXIS="$(xaxis)"
+XAXIS="$(xaxis_pairs)"
+# Fix the two series colors (series 1 = OpenLDAP blue, series 2 = OpenDJ orange).
+PALETTE='%%{init: {"themeVariables": {"xyChart": {"plotColorPalette": "#4e79a7, #f28e2b"}}}}%%'
+CAPTION="_Each operation has two columns: ๐ŸŸฆ OpenLDAP (left) ยท ๐ŸŸง OpenDJ (right)._"
 
 # ---------------------------------------------------------------- Throughput chart
 echo "### Comparative chart โ€” throughput per operation (ops/s, higher is better)"
 echo ""
-echo "_First bar series = OpenLDAP, second bar series = OpenDJ._"
+echo "$CAPTION"
 echo ""
 echo '```mermaid'
+echo "$PALETTE"
 echo "xychart-beta"
 echo "    title \"Throughput per operation (ops/s) โ€” OpenLDAP vs OpenDJ\""
 echo "    x-axis [${XAXIS}]"
 echo "    y-axis \"ops/s\""
-echo "    bar [$(series m "$OL_JSON" throughput)]"
-echo "    bar [$(series m "$DJ_JSON" throughput)]"
+echo "    bar [$(series_ol m "$OL_JSON" throughput)]"
+echo "    bar [$(series_dj m "$DJ_JSON" throughput)]"
 echo '```'
 echo ""
 
 # ---------------------------------------------------------------- Latency chart
 echo "### Comparative chart โ€” mean latency per operation (ms, lower is better)"
 echo ""
-echo "_First bar series = OpenLDAP, second bar series = OpenDJ._"
+echo "$CAPTION"
 echo ""
 echo '```mermaid'
+echo "$PALETTE"
 echo "xychart-beta"
 echo "    title \"Mean latency per operation (ms) โ€” OpenLDAP vs OpenDJ\""
 echo "    x-axis [${XAXIS}]"
 echo "    y-axis \"ms\""
-echo "    bar [$(series m "$OL_JSON" meanResTime)]"
-echo "    bar [$(series m "$DJ_JSON" meanResTime)]"
+echo "    bar [$(series_ol m "$OL_JSON" meanResTime)]"
+echo "    bar [$(series_dj m "$DJ_JSON" meanResTime)]"
 echo '```'
 echo ""
 
@@ -139,6 +151,7 @@
 echo "- \`BIND\` is the measured **user authentication** (\`test=sbind\`, single bind/unbind on its"
 echo "  own connection) as \`cn=user_<n>,ou=People\` with the password set by \`MODIFY\`. The admin"
 echo "  connection bind (\`ADMIN_CONNECT\`) is cached once per thread and excluded from these results."
-echo "- Default password storage scheme differs by server (OpenDJ PBKDF2/Salted-SHA vs OpenLDAP"
-echo "  SSHA); this is a legitimate part of authentication cost."
+echo "- MODIFY sends the password in cleartext; each server hashes it on write with the **same"
+echo "  scheme (SSHA-256)** โ€” OpenLDAP via the pw-sha2 module + ppolicy hash-cleartext, OpenDJ via"
+echo "  its Salted SHA-256 default scheme โ€” so BIND authentication is compared on equal footing."
 echo "- Full interactive JMeter HTML dashboards are attached as the \`jmeter-reports\` artifact."
diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml
index c8b7838..5dff071 100644
--- a/.github/workflows/benchmark.yml
+++ b/.github/workflows/benchmark.yml
@@ -78,7 +78,7 @@
       - uses: actions/checkout@v6
 
       - name: Cache JMeter
-        uses: actions/cache@v4
+        uses: actions/cache@v5
         with:
           path: ~/jmeter
           key: jmeter-${{ env.JMETER }}
@@ -103,15 +103,32 @@
             -e LDAP_TLS=false \
             "$OPENLDAP_IMAGE"
 
-      - name: Wait for OpenLDAP + seed ou=People
+      - name: Configure OpenLDAP (SSHA-256 hash-on-write, seed)
         run: |
-          for i in $(seq 1 90); do
-            if ldapsearch -x -H ldap://localhost:2389 -D "cn=admin,$BASEDN" -w password \
-                 -b "$BASEDN" -s base dn >/dev/null 2>&1; then
-              echo "OpenLDAP is up"; break
-            fi
-            sleep 2
-          done
+          wait_ldap() {
+            for i in $(seq 1 90); do
+              ldapsearch -x -H ldap://localhost:2389 -D "cn=admin,$BASEDN" -w password \
+                -b "$BASEDN" -s base dn >/dev/null 2>&1 && { echo "OpenLDAP is up"; return 0; }
+              sleep 2
+            done
+          }
+          le() { docker exec -i openldap "$@" -Y EXTERNAL -H ldapi:/// >/dev/null 2>&1 || true; }
+          wait_ldap
+          # Load pw-sha2 (provides {SSHA256}) and ppolicy (overlay) modules; osixia ships both
+          # in /usr/lib/ldap. Create the module list entry or append the values, then restart so
+          # the modules are active in the running slapd.
+          printf 'dn: cn=module{0},cn=config\nobjectClass: olcModuleList\nolcModuleLoad: pw-sha2\nolcModuleLoad: ppolicy\n' | le ldapadd
+          printf 'dn: cn=module{0},cn=config\nchangetype: modify\nadd: olcModuleLoad\nolcModuleLoad: pw-sha2\n' | le ldapmodify
+          printf 'dn: cn=module{0},cn=config\nchangetype: modify\nadd: olcModuleLoad\nolcModuleLoad: ppolicy\n' | le ldapmodify
+          docker restart openldap
+          wait_ldap
+          # Make slapd hash cleartext userPassword with {SSHA256} on a plain modify: set the global
+          # password-hash and enable the ppolicy overlay's hash_cleartext on the mdb database.
+          printf 'dn: olcDatabase={-1}frontend,cn=config\nchangetype: modify\nreplace: olcPasswordHash\nolcPasswordHash: {SSHA256}\n' | le ldapmodify
+          DBDN="$(docker exec openldap ldapsearch -Y EXTERNAL -H ldapi:/// -LLL -b cn=config '(olcDatabase=*mdb)' dn 2>/dev/null | sed -n 's/^dn: //p' | head -1)"
+          [ -n "$DBDN" ] || DBDN="olcDatabase={1}mdb,cn=config"
+          printf 'dn: olcOverlay=ppolicy,%s\nobjectClass: olcOverlayConfig\nobjectClass: olcPPolicyConfig\nolcOverlay: ppolicy\nolcPPolicyHashCleartext: TRUE\n' "$DBDN" | le ldapadd
+          # Seed ou=People (after the restart so it survives any re-init).
           ldapadd -x -H ldap://localhost:2389 -D "cn=admin,$BASEDN" -w password \
             -f .github/benchmark/people.ldif || true
 
@@ -141,6 +158,11 @@
             -Jjmeter.reportgenerator.sample_filter="$SAMPLE_FILTER" \
             -l openldap.jtl -e -o openldap
 
+      - name: Stop OpenLDAP
+        run: |
+          docker logs openldap 2>&1 | tail -n 100 || true
+          docker rm -f openldap || true
+
       - name: Start OpenDJ
         run: |
           docker run -d --name opendj -p 1389:1389 \
@@ -161,6 +183,16 @@
           ldapadd -x -H ldap://localhost:1389 -D "cn=Directory Manager" -w password \
             -f .github/benchmark/people.ldif || true
 
+      - name: Configure OpenDJ password policy (SSHA-256 hash-on-write)
+        run: |
+          # Hash cleartext userPassword with Salted SHA-256 on write, matching OpenLDAP.
+          docker exec opendj /opt/opendj/bin/dsconfig set-password-policy-prop \
+            --policy-name "Default Password Policy" \
+            --set default-password-storage-scheme:"Salted SHA-256" \
+            --hostname localhost --port 4444 \
+            --bindDN "cn=Directory Manager" --bindPassword password \
+            --trustAll --no-prompt
+
       - name: Capture OpenDJ version
         run: |
           # `|| true` guards the bind so a transient ldapsearch failure can't trip pipefail;
@@ -187,6 +219,11 @@
             -Jjmeter.reportgenerator.sample_filter="$SAMPLE_FILTER" \
             -l opendj.jtl -e -o opendj
 
+      - name: Stop OpenDJ
+        run: |
+          docker logs opendj 2>&1 | tail -n 100 || true
+          docker rm -f opendj || true
+
       # ---------------------------------------------------------------- Report
       - name: Build job summary
         run: |
@@ -197,7 +234,7 @@
 
       - name: Upload JMeter reports
         if: always()
-        uses: actions/upload-artifact@v4
+        uses: actions/upload-artifact@v7
         with:
           name: jmeter-reports
           path: |

--
Gitblit v1.10.0