LDAP service backed by ACTIVATE. The repo is a single deployable that bundles two components: an OpenLDAP server PW operates and a syncer that keeps the directory in sync with ACTIVATE. Downstream consumers (SUNK nsscache, sssd, getent) bind to the LDAP service and never need to know that ACTIVATE exists.
This is the LDAP analog of pw-slurm-group-sync. The two are complementary; pw-slurm-group-sync writes Slurm accounts via sacctmgr, this writes POSIX identity and SSH public keys via LDAP.
+------------------+ +-----------------+ +--------------+
| ACTIVATE control | REST | syncer | ldap3 | OpenLDAP |
| plane |<-------| (CronJob) |------->| StatefulSet |
+------------------+ +-----------------+ +--------------+
^ |
| pw ssh-public-keys (subprocess) | LDAPS
+------------------------------------------------------|
v
+--------------+
| SUNK login |
| pod (nsscache|
| / sssd) |
+--------------+
The syncer is stateless; each run is a full reconciliation. State lives only in LDAP, under ou=pw-managed,<base>. Anything outside that subtree is invisible to the syncer, so the directory can host other records (service accounts, manually-managed entries) without risk.
pw-activate-ldap/
src/pw_ldap_sync/ syncer source
tests/ diff unit tests
k8s/
ldap-server/ OpenLDAP StatefulSet, Service, schema, bootstrap
syncer/ CronJob, ConfigMap, Secret
Dockerfile builds the syncer image
pyproject.toml
env.example local-dev env
The bring-up has three phases. The full sequence:
# 1. LDAP server, see k8s/ldap-server/README.md
kubectl apply -f k8s/ldap-server/00-namespace.yaml
# generate real openldap-admin and openldap-tls secrets, then:
kubectl apply -f k8s/ldap-server/20-schema-configmap.yaml
kubectl apply -f k8s/ldap-server/30-service.yaml
kubectl apply -f k8s/ldap-server/40-statefulset.yaml
kubectl -n pw-ldap rollout status statefulset/openldap
# 2. Syncer, see k8s/syncer/README.md
# replace placeholders in k8s/syncer/configmap.example.yaml and secret.example.yaml first
kubectl apply -f k8s/syncer/configmap.example.yaml
kubectl apply -f k8s/syncer/secret.example.yaml
kubectl apply -f k8s/syncer/cronjob.yaml
# 3. Verify end-to-end
kubectl -n pw-ldap create job initial-sync \
--from=cronjob/pw-activate-ldap-sync
kubectl -n pw-ldap logs job/initial-sync -f
kubectl -n pw-ldap run check --rm -it --image=bitnami/openldap:2.6 -- \
ldapsearch -x -H ldaps://ldap.pw-ldap.svc:636 \
-D "cn=admin,dc=pw-managed,dc=local" -w "$ADMIN_PASSWORD" \
-b "ou=people,ou=pw-managed,dc=pw-managed,dc=local" "(objectClass=posixAccount)" \
uid uidNumber sshPublicKeyThe LDAP service that downstream consumers bind to is ldaps://ldap.pw-ldap.svc:636 for in-cluster access. To expose it across clusters or for external consumers, front the Service with a LoadBalancer, an Ingress with TLS passthrough, or expose through a ServiceMesh gateway.
uv sync
cp env.example .env
# fill in ACTIVATE_API_KEY, ACTIVATE_ORG_*, LDAP_*, point LDAP_URI at a dev directory
uv run pw-activate-ldap-sync --dry-run
uv run pw-activate-ldap-sync
uv run pytestThe diff layer (src/pw_ldap_sync/sync/diff.py) has no I/O and is the primary unit-test target.
By default the syncer projects every group in the ACTIVATE org. To restrict to a subset, set ACTIVATE_SYNC_GROUPS to a comma-separated list of group names. Users who are not members of any selected group are not synced and any LDAP records the syncer previously created for them will be deleted on the next run, so think of this as a hard scope, not a hint.
ACTIVATE_SYNC_GROUPS=hpc-users,research-team,gpu-pilotFilter values are matched by ACTIVATE group name (not slug, not ID). Names in the filter that do not exist in ACTIVATE are logged as warnings but do not fail the run.
The default deployment has the syncer bind as cn=admin,dc=pw-managed,dc=local, which is convenient for getting started but grants more than the syncer needs. For production, create a dedicated service account and scope its write access to the management subtree:
dn: cn=svc-syncer,ou=services,dc=pw-managed,dc=local
objectClass: organizationalRole
objectClass: simpleSecurityObject
cn: svc-syncer
userPassword: {SSHA}<hash>
description: pw-activate-ldap-sync bind account
# olcAccess directives granting the account write to ou=pw-managed only
Then update LDAP_BIND_DN in the syncer ConfigMap and rotate the syncer Secret to the new password.
The downstream config that closes the loop. SUNK's directory-services.ldap chart values look like (paraphrased; check the SUNK chart values for the exact schema):
directoryServices:
ldap:
uri: "ldaps://ldap.pw-ldap.svc:636"
base: "ou=pw-managed,dc=pw-managed,dc=local"
binddn: "cn=ro-bind,ou=services,dc=pw-managed,dc=local"
bindpwFile: "/etc/openldap/secret"
tls:
cacertFile: "/etc/openldap/ca.crt"
nss:
passwd:
base: "ou=people,ou=pw-managed,dc=pw-managed,dc=local"
group:
base: "ou=groups,ou=pw-managed,dc=pw-managed,dc=local"
sshPublicKey:
attribute: "sshPublicKey"A read-only bind account scoped to read ou=pw-managed is the recommended pattern; it never needs write access and is what nsscache binds with.