Skip to main content
  1. Articles/

LLDAP — Declarative Selfhosted Lightweight Authentication

·4020 words·19 mins·
Vegard S. Hagen
Author
Vegard S. Hagen
Pondering post-physicists
Table of Contents

Selfhosting multiple applications often means having to deal with disparate user accounts unless you can integrate them with a common third party. A tried and tested framework for this is the ubiquitous LDAP, or Lightweight Directory Access Protocol, server.

The LDAP server functions as a centralised identity and access management (IAM), meaning each application sees it as a single source of truth.

Motivation
#

LDAP is a complex protocol with its own terminology and filter syntax. A common LDAP implementation is Microsoft’s Active Directory, or their cloud based Entra ID offering.

For homelab use you probably only need the bare minimum of the LDAP features. I’ve found the features offered by LLDAP to more than meet my needs. Other alternatives that LLDAP suggests themselves are OpenLDAP, FreeIPA and Kanidm.

LLDAP should integrate with most applications that support LDAP. The LLDAP repository supplies ample example integrations — including Dex, Gitea, Proxmox, Home Assistant, and Authelia.

Using a common identity provider (IdP) alleviates the need to individually maintain users in each application, making it easier to add new users and change passwords everywhere at once.

A more modern approach would be to use an OAuth/OIDC client — like Keycloak, Zitadel, Authelia, Authentik, or Kanidm, but not all applications natively support OAuth/OIDC. For applications that doesn’t support OAuth/OIDC there’s the option of using a middleware like OAuth2 Proxy or Traefik Forward Auth, but that’s an article for a later time.

Most of the aforementioned OAuth clients can act as a simple LDAP server themselves, and all except Kanidm can fetch users from an external LDAP-server, but in this article we’re keeping it simple with only LDAP with LLDAP.

My motivation for choosing LLDAP is that it’s simple and easy to get started, as well as the possibility to declaratively configure groups and users using a bootstrap script. As a nice bonus, LLDAP is written in Safe Rust, which in theory should make it resilient to buffer overflow and use-after-free attacks.

Overview
#

Evantage-WS has already done a great job creating both a simple example Kubernetes deployment, and a Helm chart for LLDAP.

In this article we will use Kustomize to configure LLDAP and explore most configuration and integration options available for LLDAP.

After configuring and verifying a basic deployment of LLDAP, we will take a look at how we can bootstrap users and groups declaratively, before configuring SMTP for password resetting. Next we’ll enable Secure LDAP (LDAPS) with the help of Cert manager, and lastly we’ll integrate with an external database to enable high availability.

This article assumes you have your own domain at <DOMAIN>.<TLD> which will be used as an example throughout the article.

If you’re not one for details you can skip to the Summary-section for the final manifests or visit the repository for this article.

Basic Configuration
#

We’ll leverage Kustomize’s ability to reference the different manifests required to configure LLDAP in order to organise everything

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# basic/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

configMapGenerator:
  - name: lldap-config
    namespace: lldap
    literals:
      - TZ="Europe/Oslo"
      - GID="1001"
      - UID="1001"
      - LLDAP_LDAP_BASE_DN="dc=<DOMAIN>,dc=<TLD>"

resources:
  - ns.yaml
  - svc.yaml
  - admin-credentials.yaml
  - crypto.yaml
  - deployment.yaml

LLDAP can be configured using either a configuration file or environment variables. To save some lines of YAML we’ll take advantage of envFrom to map ConfigMap data to container environment variables.

We can utilise Kustomize’s built-in configMapGenerator to automatically generate a ConfigMap that holds some of the LLDAP configuration environment variables. Most important is the LLDAP_LDAP_BASE_DN-variable (line 12) which should be configured to your own domain.

Next, we declaratively define the Namespace for completeness’ sake

1
2
3
4
5
# basic/ns.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: lldap

and prepare a Service referencing named ports (line 14, 17) that we’ll define shortly

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# basic/svc.yaml
apiVersion: v1
kind: Service
metadata:
  name: lldap
  namespace: lldap
spec:
  type: ClusterIP
  selector:
    app: lldap
  ports:
    - name: http
      port: 80
      targetPort: http
    - name: ldap
      port: 389
      targetPort: ldap

We also need to define cryptographic values preferably stored in a Secret-resource to hold a secure random value designated LLDAP_JWT_SECRET — used to sign the JWTs issued by LLDAP, and a seed called LLDAP_KEY_SEED — used to hash passwords stored in the database. The LLDAP configuration suggests to run

LC_ALL=C tr -dc 'A-Za-z0-9!#%&'\''()*+,-./:;<=>?@[\]^_{|}~' </dev/urandom | head -c 32; echo ''

in order to generate a random enough value for the JWT secret and at least 12 random characters for the hash seed. Assuming you don’t encrypt your secret using e.g. Sealed Secrets, or run an implementation of Secrets Store CSI Driver, the Secret should look something like

1
2
3
4
5
6
7
8
9
# basic/crypto.yaml
apiVersion: v1
kind: Secret
metadata:
  name: crypto
  namespace: lldap
stringData:
  LLDAP_KEY_SEED: "<RANDOM_SEED>"
  LLDAP_JWT_SECRET: "<RANDOM_SECRET>"

Note that it’s possible — though computationally prohibitive, to brute force user passwords if you know the key seed and have access to a database dump.

Lastly, we also need to define an admin user for our LLDAP instance

1
2
3
4
5
6
7
8
9
# basic/admin-credentials.yaml
apiVersion: v1
kind: Secret
metadata:
  name: admin-credentials
  namespace: lldap
stringData:
  LLDAP_LDAP_USER_DN: "admin"
  LLDAP_LDAP_USER_PASS: "<RANDOM_PASSWORD>"

With the auxiliary config created, we’re ready to design a Deployment of LLDAP that uses all the ConfigMaps and Secrets we just defined (line 22, 24, 26)

Note that we’ve picked the rootless version of LLDAP (line 19) for some extra security. An even more hardened deployment can be found in the Summary-section.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# basic/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: lldap
  namespace: lldap
spec:
  selector:
    matchLabels:
      app: lldap
  template:
    metadata:
      namespace: lldap
      labels:
        app: lldap
    spec:
      containers:
        - name: lldap
          image: docker.io/lldap/lldap:v0.6.1-alpine-rootless
          envFrom:
            - configMapRef:
                name: lldap-config
            - secretRef:
                name: crypto
            - secretRef:
                name: admin-credentials
          ports:
            - name: ldap
              containerPort: 3890
            - name: http
              containerPort: 17170
          volumeMounts:
            - name: lldap-data
              mountPath: /data
      volumes:
        - name: lldap-data
          emptyDir: { }

We’ve also named port 3890 and 17170 which are, respectively, the default LDAP and web ports (line 28, 30). These can be changed with the LLDAP_LDAP_PORT and LLDAP_HTTP_PORT environment variables.

By defining everything declaratively — see the Bootstrapping-section, we don’t need to configure additional persistence, and can thus use an emptyDir-volume for storing the LLDAP-data (line 33).

If you on the other hand want to store the state you have to define a PersistentVolume with an associated PersistentVolumeClaim. For easier PersistentVolume provisioning you can take a look at Container Storage Interface (CSI) implementations like the Proxmox CSI Plugin.

Another option for persistence is using an external database, something we’ll explore in the aptly named External Database-section later.

Applying the manifests by running

kubectl apply -k .

in the folder containing the kustomization.yaml-file should then deploy LLDAP.

Testing
#

Assuming the deployment went OK, we can now test our LDAP server.

In order to do this we first need to install some tools. Depending on you OS/package manager, these tools are bundled in either the openldap (brew), ldap-utils (apt), or openldap-clients (dnf, yum, apk).

With the tools installed we need to create a route to our LDAP-server which can be done by port-forwarding the service we just created by running1

kubectl port-forward service/lldap 3890:389 -n lldap

If everything is working as expected you should now be able to invoke

ldapsearch -H ldap://localhost:3890 \
  -D "uid=<USER>,ou=people,dc=<DOMAIN>,dc=<TLD>" \
  -w "<PASSWORD>" \
  -b "dc=<DOMAIN>,dc=<TLD>" "(objectClass=*)"

to list all objects stored in LLDAP.

Some LDAP-operations are not supported by LLDAP, e.g.

ldapwhoami -H ldap://localhost:3890 \
  -D "uid=<USER>,ou=people,dc=<DOMAIN>,dc=<TLD>" \
  -w "<PASSWORD>" 

which will result in the following message

ldap_parse_result: Server is unwilling to perform (53)
        additional info: Unsupported extended operation: 1.3.6.1.4.1.4203.1.11.3
Result: Server is unwilling to perform (53)
Additional info: Unsupported extended operation: 1.3.6.1.4.1.4203.1.11.3

I assume this means that the functionality isn’t implemented in LLDAP.

Bootstrapping (Optional)
#

The default configuration for LLDAP only allows declaration of an admin user, but they graciously supply a bootstrap-option that lets us configure as many users, groups, and custom attributes (schemas) we want2

LDAP objects to be bootstrapped are defined as a JSON-object, e.g. a user can be defined as

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
{
  "id": "username",
  "email": "[email protected]",
  "password": "changeme",
  "displayName": "Display Name",
  "firstName": "First",
  "lastName": "Last",
  "avatar_file": "/path/to/avatar.jpg",
  "avatar_url": "https://i.imgur.com/nbCxk3z.jpg",
  "gravatar_avatar": "false",
  "weserv_avatar": "false",
  "groups": [
    "group-1",
    "group-2"
  ]
}

where only the id and email fields are mandatory.

It’s possible to define multiple users in a single file, though not as a JSON-list, but as consecutive JSON-objects, e.g.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
  "id": "spike",
  "email": "[email protected]",
  "firstName": "Spike",
  "lastName": "Spiegel",
  "password": "fearless123"
}
{
  "id": "faye",
  "email": "[email protected]",
  "firstName": "Faye",
  "lastName": "Valentine"
}
{
  "id": "ed",
  "email": "[email protected]",
  "firstName": "Françoise",
  "lastName": "Lütfen",
  "displayName": "Edward Wong Hau Pepelu Tivrusky IV",
  "groups": [
    "argocd:admin"
  ]
}

Note that it’s possible to omit the password, in which case you need to configure password reset — which we cover in the next section, in order to set an initial password to be able to log in.

Groups are defined in a similar fashion as users

1
2
3
4
5
6
{
  "name": "argocd:admin"
}
{
  "name": "argocd:read_all"
}

Check the official documentation for how to configure custom schemas.

We can easily use Kustomize’s configMapGenerator to create ConfigMaps of the configuration files (line 9-11) to mount them into a Pod

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# bootstrap/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

configMapGenerator:
  - name: bootstrap-config
    namespace: lldap
    files:
      - users/user.json
      - users/more-users.json
      - groups/groups.json
  - name: bootstrap-env
    namespace: lldap
    literals:
      - LLDAP_URL="http://lldap:80"
      - DO_CLEANUP="true"

resources:
  - bootstrap.yaml

The bootstrap script also needs the http-url of LLDAP (line 15) which will be the service name assuming you run the bootstrapping Job in the same Namespace.

Setting the DO_CLEANUP variable to true (line 16) will “delete groups and users not specified in config files, also remove users from groups that they do not belong to”.

If you’re using Argo CD you can add the annotations on line 8 and 9 in order to relaunch the job on every sync. Read the Argo CD documentation for more information about resource hooks.

We can reuse the same image as the main deployment (line 15) which ship with the bootstrap.sh-script, we just have to change the start-command as on line 16. The bootstrap environment variables are injected from the bootstrap-env-ConfigMap (line 19), as well as mapping the admin credentials (line 21 and 26) from the lldap-credentials-Secret (line 24 and 29).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
# bootstrap/bootstrap.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: lldap-bootstrap
  namespace: lldap
  annotations:
    argocd.argoproj.io/hook: PostSync
    argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
spec:
  template:
    spec:
      containers:
        - name: lldap-bootstrap
          image: ghcr.io/lldap/lldap:v0.6.1-alpine-rootless
          command: [ /app/bootstrap.sh ]
          envFrom:
            - configMapRef:
                name: bootstrap-env
          env:
            - name: LLDAP_ADMIN_USERNAME
              valueFrom:
                secretKeyRef:
                  name: lldap-credentials
                  key: LLDAP_LDAP_USER_DN
            - name: LLDAP_ADMIN_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: lldap-credentials
                  key: LLDAP_LDAP_USER_PASS
          volumeMounts:
            - name: tmp
              mountPath: /tmp
            - name: users
              mountPath: /bootstrap/user-configs
              readOnly: true
            - name: groups
              mountPath: /bootstrap/group-configs
              readOnly: true
      volumes:
        - name: tmp
          emptyDir: { }
        - name: users
          projected:
            sources:
              - configMap:
                  name: bootstrap-config
                  items:
                    - key: user.json
                      path: user.json
                    - key: more-users.json
                      path: more-users.json
        - name: groups
          projected:
            sources:
              - configMap:
                  name: bootstrap-config
                  items:
                    - key: groups.json
                      path: groups.json

Next we create projected volumes (line 43 and 53) from the bootstrap-config-ConfigMap (line 47 and 57) containing the JSON-files we previously wrote, and mount them at the correct locations (line 34 and 37).

SMTP — Password Reset (Optional)
#

If you plan on hosting multiple personal accounts in your LLDAP instance it’s a good idea to let them reset their passwords themselves. In order to enable this we need some kind of SMTP-integration for LLDAP to be able to send password reset e-mails.

It’s possible to self-host a simple solution like docker-postfix, or something more complex like mailcow, Mox, or Dovecot, but setting up one of those are all articles in themselves.

I haphazardly picked SendGrid which I found easy to integrate with LLDAP. Take a look at this Hacker News thread for more options.

It should be fairly straight forward to create a free account with SendGrid and verify it with your domain under Settings > Sender Authentication using DNS entries.

You can create a new sender e-mail, e.g. no-reply@<DOMAIN>.<TLD> under Marketing > Senders and find a guide on how to integrate it with LLDAP under Email API > Integration Guide shown in the picture below

SendGrid SMTP integration
SendGrid integration wizard. Choose SMTP Relay. (full size)

Choose the SMTP Relay option and take note of the API key you create.

Also make sure to disable tracking under Settings > Tracking to avoid overwriting links and adding images to track opened e-mails.

SendGrid tracking settings
SendGrid tracking settings. Disable link overwriting and opened e-mail tracking (full size)

With your API-key (line 13) and e-mail address (line 14) in hand, create the following Secret

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# lldap/smtp-settings.yaml
apiVersion: v1
kind: Secret
metadata:
  name: smtp-settings
  namespace: lldap
stringData:
  LLDAP_SMTP_OPTIONS__ENABLE_PASSWORD_RESET: "true"
  LLDAP_SMTP_OPTIONS__SERVER: "smtp.sendgrid.net"
  LLDAP_SMTP_OPTIONS__PORT: "587"
  LLDAP_SMTP_OPTIONS__SMTP_ENCRYPTION: "STARTTLS"
  LLDAP_SMTP_OPTIONS__USER: "apikey"
  LLDAP_SMTP_OPTIONS__PASSWORD: "<API_KEY>"
  LLDAP_SMTP_OPTIONS__FROM: "LLDAP <no-reply@<DOMAIN>.<TLD>>"

and inject the environment variables in the LLDAP Deployment by adding

envFrom:
  - secretRef:
      name: smtp-settings

to the LLDAP-container.

We also need to configure the LLDAP base URL to be used in the password reset e-mail which is done with the LLDAP_HTTP_URL environment variable which can be done using Kustomize’s configMapGenerator

configMapGenerator:
  - name: lldap-config
    namespace: lldap
    literals:
      - LLDAP_HTTP_URL="https://lldap.<DOMAIN>.<TLD>"

LDAPS (Optional)
#

LDAPS is LDAP over TLS/SSL, and is highly recommended if you plan to expose your LDAP-server to the internet — though exposing LDAP to the internet is not advisable, or want an extra layer of security for inter-container communication.

Similar to HTTPS we need an X.509 certificate to enable the secure part. The easiest way to obtain such a certificate is by using Cert manager. I’ve already explained how to get started with Cert manager in my Traefik wildcard certificates article, so I’ll allow myself to be brief here.

Assuming you’ve configured a ClusterIssuer imaginatively called cluster-issuer (line 13), you can create the following Certificate-resource

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# lldap/cert.yaml
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: cert
  namespace: lldap
spec:
  dnsNames:
    - lldap.<DOMAIN>.<TLD>
  issuerRef:
    group: cert-manager.io
    kind: ClusterIssuer
    name: cluster-issuer
  secretName: cert
  usages:
    - digital signature
    - key encipherment

to issue a certificate for you chosen domain main (line 9) as a TLS Secret called cert (line 14).

We can then create a volume in our LLDAP Deployment with this secret

volumes:
  - name: cert
    secret:
      secretName: cert
      items:
        - key: tls.key
          path: tls.key
        - key: tls.crt
          path: tls.crt

and mount the volume in the LLDAP-container

volumeMounts:
  - name: cert
    mountPath: /cert

Next we can configure LLDAP to enable LDAPS and use the certificate

configMapGenerator:
  - name: lldap-config
    namespace: lldap
    literals:
      - LLDAP_LDAPS_OPTIONS__ENABLED="true"
      - LLDAP_LDAPS_OPTIONS__CERT_FILE="/cert/tls.crt"
      - LLDAP_LDAPS_OPTIONS__KEY_FILE="/cert/tls.key"

For TLS-communication to work inside the cluster with public certificate, i.e. not self-signed certificates for the cluster.local domain — which we in that case have to distribute, we can create DNS-entries for our LLDAP-service.

We start by assigning a static clusterIP (line 9) to the LLDAP-service. To minimise the possibility of a Service Cluster IP conflict we can pick an IP in the lower band of the Service IP range, e.g. 10.96.0.15

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# lldap/svc.yaml
apiVersion: v1
kind: Service
metadata:
  name: lldap
  namespace: lldap
spec:
  type: ClusterIP
  clusterIP: 10.96.0.15
  selector:
    app: lldap
  ports:
    - name: http
      port: 80
      targetPort: http
    - name: ldap
      port: 389
      targetPort: ldap
    - name: ldaps
      port: 636
      targetPort: ldaps

We also need to route to the LDAPS-port (line 19-21) by adding

ports:
  - name: ldaps
    containerPort: 6360

to the LLDAP container.

You can now test that everything works by port-forwarding the ldaps-port

kubectl port-forward service/lldap 6360:636 -n lldap

and try to get the certificate information by running

openssl s_client -connect localhost:6360

If this works you can add an entry in your /etc/hosts-file

# /etc/hosts
127.0.0.1    lldap.<DOMAIN>.<TLD>

to route lldap.<DOMAIN>.<TLD> to our port-forward in order for the certificate to be valid. If the following search returns OK everything should be configured properly

ldapsearch -H ldaps://lldap.<DOMAIN>.<TLD>:6360 \
  -D "uid=<USER>,ou=people,dc=<DOMAIN>,dc=<TLD>" \
  -w "<PASSWORD>" \
  -b "dc=stonegarden,dc=dev" "(objectClass=*)"

Clean up by stopping the port-forward and remove the /etc/hosts entry.

We can now create a rule in CoreDNS to forward all internal requests to lldap.<DOMAIN>.<TLD> to 10.96.0.15 by editing the Corefile-config by running

CoreDNS is an integral part of Kubernetes, an invalid config can potentially break your cluster until resolved.
kubectl edit configmap coredns -n kube-system

and adding the following hosts block in the Corefile-config

data:
  Corefile: |-
    .:53 {
        ...
        hosts { 
            10.96.0.15 lldap.<DOMAIN>.<TLD>
            fallthrough 
        }
        ...
    }    

Restart the CoreDNS pods to apply the configuration changes

kubectl rollout restart deployment coredns -n kube-system

If you can’t — or don’t want to, edit the CoreDNS config you can instead add a host alias to the Pods that should talk ldaps with LLDAP, e.g.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: <DEPLOYMENT_NAME>
spec:
  template:
    spec:
      hostAliases:
        - ip: 10.96.0.15
          hostnames:
            - lldap.<DOMAIN>.<TLD>

External Database (Optional)
#

LLDAP ships with a built-in SQLite database to store its state. For most homelab uses this should be more than enough, but if you want high availability you need to use an external database in order to be able to spin up multiple instances of LLDAP.

LLDAP supports both MySQL and PostgreSQL. I’ve previously written about PostgreSQL on Kubernetes where I decided to go with CloudNative PG (CNPG) to spin up PostgreSQL clusters inside Kubernetes clusters. Other alternatives are Zalando Postgres Operator, Crunchy Data, KubebBocks, Percona Everest, Stolon, and StackGres.

Assuming you’ve configured a default StorageClass and CNPG, you can create a database cluster for LLDAP with the following resource

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# lldap/cnpg-db.yaml
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: lldap-postgres
  namespace: lldap
spec:
  instances: 3
  bootstrap:
    initdb:
      database: lldap
      owner: lldap
  storage:
    size: 2G

With the above manifest, CNPG creates a secret called lldap-postgres-app with a connection uri on the form postgresql://<USER>:<PASSWORD>@<URI>:<PORT>/<DATABASE> which we can map to the LLDAP_DATABASE_URL environment variable in the LLDAP container

env:
  - name: LLDAP_DATABASE_URL
    valueFrom:
      secretKeyRef:
        name: lldap-postgres-app
        key: uri

If you already have a database you want to connect with you can instead create a Secret with the correct database uri.

LLDAP provides a migration guide if you start with SQLite and decide to change to an external database at a later stage.

Summary
#

All resources can be found in this GitLab repo, and are written for use with Argo CD with Kustomize + Helm, but should be easily adaptable for other approaches using e.g. Flux CD.

# lldap/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

configMapGenerator:
  - name: lldap-config
    namespace: lldap
    literals:
      - TZ="Europe/Oslo"
      - GID="1001"
      - UID="1001"
      - LLDAP_LDAP_BASE_DN="dc=<DOMAIN>,dc=<TLD>"
      - LLDAP_HTTP_URL="https://lldap.<DOMAIN>.<TLD>"
      - LLDAP_LDAPS_OPTIONS__ENABLED="true"
      - LLDAP_LDAPS_OPTIONS__CERT_FILE="/cert/tls.crt"
      - LLDAP_LDAPS_OPTIONS__KEY_FILE="/cert/tls.key"

  - name: bootstrap-config
    namespace: lldap
    files:
      - users/user.json
      - users/more-users.json
      - groups/groups.json

  - name: custom-schemas
    namespace: lldap
    files:
      - group-schemas/group-application.json
      - user-schemas/user-details.json

  - name: bootstrap-env
    namespace: lldap
    literals:
      - LLDAP_URL="http://lldap:80"
      - DO_CLEANUP="true"

resources:
  - ns.yaml
  - svc.yaml
  - crypto.yaml
  - admin-credentials.yaml
  - smtp-settings.yaml
  - cert.yaml
  - cnpg-db.yaml
  - deployment.yaml
  - bootstrap.yaml
# lldap/ns.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: lldap
# lldap/svc.yaml
apiVersion: v1
kind: Service
metadata:
  name: lldap
  namespace: lldap
spec:
  type: ClusterIP
  clusterIP: 10.96.0.15
  selector:
    app: lldap
  ports:
    - name: http
      port: 80
      targetPort: http
    - name: ldap
      port: 389
      targetPort: ldap
    - name: ldaps
      port: 636
      targetPort: ldaps
# lldap/crypto.yaml
apiVersion: v1
kind: Secret
metadata:
  name: crypto
  namespace: lldap
stringData:
  LLDAP_KEY_SEED: "<RANDOM_SEED>"
  LLDAP_JWT_SECRET: "<RANDOM_SECRET>"
# lldap/admin-credentials.yaml
apiVersion: v1
kind: Secret
metadata:
  name: admin-credentials
  namespace: lldap
stringData:
  LLDAP_LDAP_USER_DN: "admin"
  LLDAP_LDAP_USER_PASS: "<RANDOM_PASSWORD>"
# lldap/smtp-settings.yaml
apiVersion: v1
kind: Secret
metadata:
  name: smtp-settings
  namespace: lldap
stringData:
  LLDAP_SMTP_OPTIONS__ENABLE_PASSWORD_RESET: "true"
  LLDAP_SMTP_OPTIONS__SERVER: "smtp.sendgrid.net"
  LLDAP_SMTP_OPTIONS__PORT: "587"
  LLDAP_SMTP_OPTIONS__SMTP_ENCRYPTION: "STARTTLS"
  LLDAP_SMTP_OPTIONS__USER: "apikey"
  LLDAP_SMTP_OPTIONS__PASSWORD: "<API_KEY>"
  LLDAP_SMTP_OPTIONS__FROM: "LLDAP <no-reply@<DOMAIN>.<TLD>>"
# lldap/cert.yaml
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: cert
  namespace: lldap
spec:
  dnsNames:
    - lldap.<DOMAIN>.<TLD>
  issuerRef:
    group: cert-manager.io
    kind: ClusterIssuer
    name: cluster-issuer
  secretName: cert
  usages:
    - digital signature
    - key encipherment
# lldap/cnpg-db.yaml
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: lldap-postgres
  namespace: lldap
spec:
  instances: 3
  bootstrap:
    initdb:
      database: lldap
      owner: lldap
  storage:
    size: 2G
# lldap/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: lldap
  namespace: lldap
spec:
  replicas: 3
  selector:
    matchLabels:
      app: lldap
  template:
    metadata:
      namespace: lldap
      labels:
        app: lldap
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 1001
        runAsGroup: 1001
        fsGroup: 1001
        fsGroupChangePolicy: OnRootMismatch
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: lldap
          image: docker.io/lldap/lldap:v0.6.1-alpine-rootless
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: [ ALL ]
          envFrom:
            - configMapRef:
                name: lldap-config
            - secretRef:
                name: crypto
            - secretRef:
                name: admin-credentials
            - secretRef:
                name: smtp-config
          env:
            - name: LLDAP_DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: lldap-postgres-app
                  key: uri
          ports:
            - name: ldap
              containerPort: 3890
            - name: ldaps
              containerPort: 6360
            - name: web
              containerPort: 17170
          volumeMounts:
            - name: lldap-data
              mountPath: /data
            - name: cert
              mountPath: /cert
      volumes:
        - name: cert
          secret:
            secretName: cert
            items:
              - key: tls.key
                path: tls.key
              - key: tls.crt
                path: tls.crt
        - name: lldap-data
          emptyDir: { }
# lldap/bootstrap.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: lldap-bootstrap
  namespace: lldap
  annotations:
    argocd.argoproj.io/hook: PostSync
    argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
spec:
  template:
    spec:
      restartPolicy: OnFailure
      securityContext:
        runAsNonRoot: true
        runAsUser: 1001
        runAsGroup: 1001
        fsGroup: 1001
        fsGroupChangePolicy: OnRootMismatch
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: lldap-bootstrap
          image: ghcr.io/lldap/lldap:v0.6.1-alpine-rootless
          command: [ /app/bootstrap.sh ]
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: [ ALL ]
          envFrom:
            - configMapRef:
                name: bootstrap-env
          env:
            - name: LLDAP_ADMIN_USERNAME
              valueFrom:
                secretKeyRef:
                  name: lldap-credentials
                  key: LLDAP_LDAP_USER_DN
            - name: LLDAP_ADMIN_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: lldap-credentials
                  key: LLDAP_LDAP_USER_PASS
          volumeMounts:
            - name: tmp
              mountPath: /tmp
            - name: user-schemas
              mountPath: /bootstrap/user-schemas
              readOnly: true
            - name: group-schemas
              mountPath: /bootstrap/group-schemas
              readOnly: true
            - name: groups
              mountPath: /bootstrap/group-configs
              readOnly: true
            - name: users
              mountPath: /bootstrap/user-configs
              readOnly: true
      volumes:
        - name: tmp
          emptyDir: { }
        - name: group-schemas
          projected:
            sources:
              - configMap:
                  name: custom-schemas
                  items:
                    - key: group-application.json
                      path: group-application.json
        - name: user-schemas
          projected:
            sources:
              - configMap:
                  name: custom-schemas
                  items:
                    - key: user-details.json
                      path: user-details.json
        - name: groups
          projected:
            sources:
              - configMap:
                  name: bootstrap-config
                  items:
                    - key: groups.json
                      path: groups.json
        - name: users
          projected:
            sources:
              - configMap:
                  name: bootstrap-config
                  items:
                    - key: user.json
                      path: user.json
                    - key: more-users.json
                      path: more-users.json
[
  {
    "name": "application",
    "attributeType": "STRING",
    "isEditable": false,
    "isList": false,
    "isVisible": true
  }
]
[
  {
    "name": "favourite number",
    "attributeType": "INTEGER",
    "isEditable": true,
    "isList": false,
    "isVisible": true
  },
  {
    "name": "nickname",
    "attributeType": "STRING",
    "isEditable": false,
    "isList": false,
    "isVisible": true
  },
  {
    "name": "notes",
    "attributeType": "STRING",
    "isEditable": false,
    "isList": true,
    "isVisible": true
  }
]
{
  "name": "argocd:admin"
}
{
  "name": "argocd:read_all"
}
{
  "id": "username",
  "email": "[email protected]",
  "password": "changeme",
  "displayName": "Display Name",
  "firstName": "First",
  "lastName": "Last",
  "avatar_file": "/path/to/avatar.jpg",
  "avatar_url": "https://i.imgur.com/nbCxk3z.jpg",
  "gravatar_avatar": "false",
  "weserv_avatar": "false",
  "groups": [
    "group-1",
    "group-2"
  ]
}
{
  "id": "spike",
  "email": "[email protected]",
  "firstName": "Spike",
  "lastName": "Spiegel",
  "password": "fearless123"
}
{
  "id": "faye",
  "email": "[email protected]",
  "firstName": "Faye",
  "lastName": "Valentine"
}
{
  "id": "ed",
  "email": "[email protected]",
  "firstName": "Françoise",
  "lastName": "Lütfen",
  "displayName": "Edward Wong Hau Pepelu Tivrusky IV",
  "groups": [
    "argocd:admin"
  ]
}

  1. Note that the service is routing port 3890 form the pod to port 389, and then we’re port-forwarding port 389 from the service back to port 3890 on the client. ↩︎

  2. At the time of writing it seems that it’s not supported to bootstrap a value for a custom attribute, only the attribute itself. ↩︎