feat(authelia): use lldap as authentication backend

- Replace file-based auth with LDAP backend pointing to lldap

- Add LDAP bind password to secrets-authelia sealed secret

- Rewrite scripts/authelia-users.sh to manage LLDAP users via LDAP

- Remove users.yaml and authelia-users-sealedsecret.yaml
This commit is contained in:
cătălin 2026-08-18 12:39:24 +02:00
commit a280746f68
No known key found for this signature in database
5 changed files with 311 additions and 26 deletions

View file

@ -30,3 +30,4 @@ repos:
hooks:
- id: shellcheck
files: \.sh
exclude: scripts/authelia-users.sh

View file

@ -29,12 +29,6 @@ spec:
traefik.ingress.kubernetes.io/router.tls: "true"
secret:
existingSecret: secrets-authelia
additionalSecrets:
authelia-users:
path: users
items:
- key: users_database.yml
path: users_database.yml
persistence:
enabled: true
existingClaim: authelia-data
@ -53,10 +47,23 @@ spec:
- domain: auth.fuku
subdomain: ''
authentication_backend:
file:
ldap:
enabled: true
path: /secrets/users/users_database.yml
watch: false
implementation: lldap
address: ldap://lldap.apps-fuku.svc.cluster.local:3890
base_dn: dc=fuku,dc=local
additional_users_dn: ou=people
users_filter: (&({username_attribute}={input})(objectClass=person))
additional_groups_dn: ou=groups
groups_filter: (member={dn})
user: uid=admin,ou=people,dc=fuku,dc=local
attributes:
distinguished_name: dn
username: uid
display_name: cn
mail: mail
member_of: memberOf
group_name: cn
storage:
local:
enabled: true

View file

@ -0,0 +1,38 @@
# Authelia service extras
This directory contains supporting resources for the `authelia` ArgoCD app.
Authelia uses the existing [LLDAP](https://github.com/lldap/lldap) deployment
as its authentication backend.
## User management
Users are managed directly in LLDAP via the helper script at
`scripts/authelia-users.sh`.
### Requirements
- [uv](https://docs.astral.sh/uv/) (the script uses `#!/usr/bin/env -S uv run --script`)
- `kubectl` with access to the cluster
### Usage
```bash
# List existing users
scripts/authelia-users.sh list
# Add a user
scripts/authelia-users.sh add -u jdoe -n "John Doe" -p changeme \
-e jdoe@example.com -g users,admins
# Remove a user
scripts/authelia-users.sh remove -u jdoe
# Validate LLDAP connectivity
scripts/authelia-users.sh apply
```
The script reads the LLDAP admin password from the `lldap-secrets` Secret in
the `apps-fuku` namespace, creates users under `ou=people,dc=fuku,dc=local`,
sets their password via the LDAP extended password operation, and adds them to
the requested groups (which must already exist in LLDAP).

View file

@ -8,26 +8,13 @@ metadata:
namespace: apps-roboces
spec:
encryptedData:
identity_validation.reset_password.jwt.hmac.key: AgA/AUNGTlrmX3Zqv4jGo8bA5IeP+S9X+YviRx4GL/BMqQ/o+N9zes5AGTJ7/6WenEC2Q4uqAI+FvHv2nyrgxlfAXWPVnNn22Gxcg4RrxKLku6OjtzxMj37DCeaX/tX/f4wu6vWAyLWd4eaNFH7HD9o2i4gNPjGlU4elGiZBLdp0BzK3Xg5DAsyT6uuLybZc5SCtPgWjQWJTGIX5VnzdxkTULiJiI6rMWiY6gGfY5rFgdCgk2mBvcZJiOTXVnvalo2yyLvL4y8nqKGyQsxggsKxFIWz0iH4j96UDV93k+HfNJloBXgRgkHtf0l8USkyQF+Lk0p0qs5SIuUwfc+Q2s6dBPQwwIuXai42ZCsRINqTxlGWwFN9X/q8QbDDaosYgIsyUo9o9Nvr/yauqdkaoNsJsmM+/pjC3FWXXmfxRtuxYswKDGiV5RfUCz5nwcCuYSWzU0l+x4aohQ7TyfrnWzg519BpMqDcIXRx+j0kzmXznWMKrOPgDz3aHSU2hqilwX9Ty56vdD0Dp1jqTw9BgTgNmWqG28mVKYyHvEIgA3q1ipEiZhnrU71fy1mArAOE4ZEwK753vLIxjFBiXSCcum/gndQPNEa2E7e5ROzT/Z65mvcOr9NzW3/prCiKTe4PnhMKLB0Bb0dcJDt4n8ySEWkg4xGK2uwXd4B4p9cKEPv5NjFmCW1sEiNFX9YUBXH/tpV431weHQBhhb0QQv6cmlkPE2lRm5/9Rx8ycuCx1SmWNE8Xedi66zeyIdXiH6g==
session.encryption.key: AgCz2fGN8W6vQkK3+0buvzg1X60b5oi5EPzX6x+l2PGFbwsvGr2Xp4Yg2nQx82EovzUXSO9rTVDrGMgK4nvYoG9fIBmw9ZeYrdrJgxKPpU1zdL+nHLJX9x0mYGkFS0x/WDnhUX2w4/RBJBdIfOSDQCqE/eo79g/RyHfoLuUn+JEAnLVEy4JE9ODNGllqQGjni11/O7i/5eBsln2vSrOlyxR6RHr1I3oJ7EVoI+57MPO64kOsf5lW54EOn7GtVXWKiRgbhbAJC73ASagSGzE6eJwwlNlHQID1dGv0aS3fbQWfVluqrkLxmGKqEtflxrw3M/H+VNklRqrNNfoJ74lcIuLCTYFqttJfmZC3UufBVCkDfW4Hz2rE25u2KdmZpsEGemJVh1GFOWtByKuXnxOvGCKLtmacO83zG/4zdCv5wtKmD1n5FkL8FYV8c/C5oXZ7uozyNMczCyt2S/+M+3pWHdOJSLSRl1i9z0NnhbZr2yUF5p9O3cs/Ey6XC2qThr0tyrxRKbtGq9uos7ywovZpfy6vGGdf4tSyBK9lGK1MQqj4nko7SArRB8r6os3WbqHpNnLQnjUlSBTkdh94W8sbnPqs+Nzv1uRJT03R/Tfb0NG+Nudj03gF5jDSaT7MysKXMgsWjfQWt3lBmhLIZvNyvlTRnoGEuBtU1KlialPF9Ufn9j6L9lJR8px+uELqr1DccKDmJuj2nObfSUtLIaRMW4pmtq7LNVD/2S2yL32AGvNUmVUUoMrL7+SP8KL/ng==
storage.encryption.key: AgAO+MVcF4rrXTYGeKeRrShblkdYlr+RgPC3C2VABOCMgdncoxX04u0i5R7hUNSEWdATr33Rgw1zoFC6oWAjIA1vUAt1PXUfrRXV0/zXkn2Jt0SP39Gn9qMMT/8mBfnodc/kKWEvlQBPtrtbjldm8Nwkzlf4ho4XpDrQDuKmCwSZzq9JVtxc6B6o8iww5LGS7zNr0PPrkmAJHwGvqCZJ6oTO6Tz3RT78pe6qy9EdzBf2u4r5TQuMY5Q3Z+UDgkkHFOIdb5GfMq1S+pauUJwTWU45Y5iBgtnftu4qP4mEqlNXkmjypIX/QFCEh5mKAhOhNbY2cFj8PIcpuT9jaHKDEwYFMeYsDioR7VWLx0sSL19HjBphKvsngrVnS0tNfv+/Fmpay4ry9QmTDA0b8/bQsBcMpoiMHt83r6UtS+uGABxRd5lvBlMVNQqNiBhu0UsBBjl1kORDFhLDXBdoMZ00aYclL2rbzUZ23XRpptmuWmbFcJ4mTDTBjKndlzRb9DE7PQNETgHC2l4dW46yStV+aW3RgtNBfc10SuUQTgIwRm5PXsfzW9UfjfyyZhAgr+ilK63t7ezi+KXuXePJBdK/XSO8/RnKWvbZgn0L+kblrFJh2a9XrTOtweac5g1JlChFzDI8cVSpGh33ZNtHm1TLPLZnlWE2wTxDJQAnVMPvI3RmGSExYKZaswOrBHMZZp2rdPN4F5p0AupKyP8xXiRq8bebpJrDz1Ml6wCFEXijifndQH4RHi9ck2ajAhXGRw==
authentication.ldap.password.txt: AgBuzti7rqo+GQRg+PykD8NLFiLcchU0QvCHk/0IX5yBuBbPuNLK45eZDPCnryRRDb6ATg2BvF0qyXdKs4T0SNXqmXt3Q/vXpO9q6BoQZzBL53EHUB6n9pzMSQjoP5A9Eye+mwL4suXI1UFc9Y/bXjcWEHuoEjaXK7P/lYSWsNqEAWIb+jrCBT7qBdMVkFQTH0F0/VWxwA4dvW8RwNnpfHs/wZ7dQov3xjC01dbSCVzbwKQaBWu9GpI804gBmAzPDywhlaPnBv73L/Og9tsXex6D9C5noKrP8YzBoQQTuSpifiMsxcfmbFVknfHl0gwPSZkASf+3OXf6zXTQh3tHYSNHbC6VIpo03boBF7ZoKdXVEwG6t6gxnBprE/0NvbxCldd6JhYfTigGmNpKNHLknQ+McT2S5fNC54itvuMsNcV6EE+mV+prhvGaKW4cyhjnyC47rbO5SX8RDp4SiXMMaK4+nyTym7mgTDgSNnY0nqIuZ2Nx0m+EjC4+uLuLkHNckSJxU5xBkjWOyNre72SHMMcbZYtcXHyY2m0RPlemazZbHsfBj++P7N2ZrAN67Bp7l8GESKaJVVTqimmT5SIUN2rwspsGXRRk9Y7K74M/GarpPd8ihl4AU5qfXjJ8JEnUDJ4P2a9iLau7sMFCPPGebXfoJMLILxHf7wOqvJNwaRbonvBd6tQ9qCzMRh02IHbFE5bZBl1CayxTTZyRnW5fnA==
identity_validation.reset_password.jwt.hmac.key: AgCYbPPoz5GwgySIJvpd2PUecYgMUWTQz51rBUzJWL4rCBt5s1ZisD9W8YoOWs5RyybdTy3Eg/JbO/HNkmEDd1QOPJyQv1mPpwcbN13y2iEomUg4zaeEOTkucFVJbdxNUeqaninrnv0ib2X2goHooXzSPSehuaAvE1XgdoEFgyR7i0GD/5t1fCT/WU3TyAH9lh89Ld8Oy5Pz4VoE/AHzl/froh7/xRZl7OMZupz3jiqGgBlsmSktGiOLir4EIiOV29TQlFRfNfVXFE1ltYofNYas3OYbRQKoi+3h4vIwaSFMngm4kJ0hSFFVN6uJNFjiQs1hV79IeOozGVE32ykmYCtVfA2uvqbA+Ybpjmyp+mDcEduDUSiG7puudAbk6ehDT/0Hz0iqBkV/FgeXns/EjV8AN1o6kG0Vgwjud+34+iQUEHDYLbscXC/daOSz7gKtnXfl2jLwJfgleNR+Rlj8FnEY0NspeLfa+aKxJBTSHUJWF/5druyXsnlWU4drlbQAXuF3zejQc3C4NqLszurxM3uL0HULCcmuue+fXkvDiJGqADEaEm1xGgWayY2VXnemNOXKVSgKd7NTWffpq/9/xdV3lj4Us4kuWsJ2ueVn48epNQ+XV1hNfkQSh5O/ij5FoySFq0n0QnggbQnlYoTXsS8Fa6YeEkZMT3j/MPKqWCeqZbRoPHAa3eR849rNh3ri5XPc4b2o2s+yTjC+eL65FmtQAW59hLZzgxSHsDM2dckskZCZ5Xy9vwjU7RKjeQ==
session.encryption.key: AgCvhJwi8nMtta7O+2ciL6ipuoXRiOnPOQ5+pcZpE5GaXAeT/61C8PVrI11PecGecMZrNdz2dc2gqIkUEwGSDbhHqlBVWRY3TZJJ8jy885Ix/f6mt4PG6W237TtrgZjHfV5fMhJiHm+wdF4s6668PUI0Gurd8xog9yu2aQN5qTy0LmcZC1im6PzkFgn2CQbQUMdrGcgMqEb4YqtzAgmaatLRJPZiheC/85ALsVthly+2Pk6mZf0XtL46FtrGFzITKE4IcJPQwN6vGc7t5LRm2Q5R9lQlSvxGHcnr19a+VQw+0Ac4Vptt9HrsYbF17wmiHdNDqogF6l9EGAlfpRJjCFz0cGou1UHK7/iQNBN7ZfFyIL/Atv5WNg8gxs/kWC9+R1YkkxXePLchYyxhr3d6rwiqMIJ1j7YNywlwUUBP0UuV5RMYeXw5mwqvX5+xxNMlLLNYET5dx7ySWwHj3hvHNET651pPy/utuNzBs30WHXHdH3LcFxGvScwE0heq75J24PnwYBFCqtV8uUUg0zxdKbLxsUWIepnWBNG1asKUAKIyjLosc1fSwU80G7ML0osCwfqDCdGV34BR8b5OjgAzfABFea4UzmIgoLrlR68/TFVD6+wzanDOlnvqBBQi8UsPGxY/SSKlw3YwvsYQhw23jZ5JVNwldcLJX0aDA6aBIol1cIaNIF0YYHc6a+IFQpn+d+leTvo2lfDvf/0BUDyVEXYOwBAtfzdUYjUUMNu3/bra5mU5Qt+Ee6lH2owAg==
storage.encryption.key: AgCscLfx6lCZPV/lA8yUq+dRSC040CHIQnQezXHTKoPmm3CNnHnJPmBguhVQELhRVgVGB8H95WnDUIJPiwXDN/XpqJ325nUnUqRHN/LbizYDmc/pffjw558AY/tHh3vEDLpaa5nfE442ETR/JG09QESZnCXCSt7yibm1OBS+b6neWgJU19CnJDkhdmt9vBIX7x+MObL8EDzTBFmGHPSdgLMPuk0XrdFK42n1wtrp1kf3fKxHlNPm0bIWsZTkqXvIbAPEWDCxDNhQHNoHmfUmketf8frR34ifSwnOPLKzSVX4tfwqDDF/ENj9QYprAPObZN8RVW9L3CGV/z3B9J53bSD13MwWBdf/c44KF2M0jBI29uAIB3G1YkgiynT0zJN/553/RUhRYeAOJsSMfhlGZkmON2AOxbGKomkrbgSN49QGTRZU+DtmXOCmWbMzgjX9tN1tNsxM+QdV+AhpAzd9laVG7L/MS7GmeV35b9Ov0wptMb1R+1EWohtnihKt+NbUXqKC23H7IZYOLbFLhs4UfG+aRzjN/RTJ3JB0BH6xsWa6wDCxsZC6CRxsa64zsFQtbMrW7Kqoa1ioK91D4knfZG10m8+Bn5D3+zsS9/extck6s7ojSFxs36MVF6rGBnHTI4FWMUPxB3cy5fpcSeWK8clFx0pOtFj/WK7IsSkupMdg7Gwwji62sDicJ+ecpAgcvNj8IM+9CPB+yRPaHUEvzHa0VPlXacVVKbyqpDJ+XR3ZDlEcOuFxGFjTnJ8A3Q==
template:
metadata:
creationTimestamp:
name: secrets-authelia
namespace: apps-roboces
type: Opaque
---
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
name: authelia-users
namespace: apps-roboces
spec:
encryptedData:
users_database.yml: AgAdV3dL58huAr1hquhF4XOk+mkYZjW+D5qStDBzmFmEinzoSYCmothMNZ8P+QCxwgH0bKc3W7MbZfDVbbRyKkEj7Kr6SfXWgWo+N7gPEqfgTVi0yHlpJgrxzRXiQDcL9ruKSGj6ePA2ALb/p1ykGvhFoX90g+X6EEi3Ya0EMm+suuPG/cw/CYJZA1hOyi8xEwmTqEFO9BLdXO3MbUmt/kkBIijul2QXPiU1Hb0rNfBKTJUsdcz4E/jhoplWeV+5O8ZE3BGUNI1JaMJiGV5T/TM6t6gcllCVkmBUmhYjhqFMeMcNc12d+D0J3jDbSfNyf+vp3oGYldBUXmSgh4oWN0KRzo7HiYC84tj/T6+cFUistBb4OBcePbRdmn5Yi8Hzk0/x/P7nKtMyJzWMoWG7MCYTAtFLKdN/mASLsqahC/0iqN7ansLq2bddKeHrDwATmP4oeuXetzh1AWmhKRcY3RVBOts3RiGLAxEcYDAZFXj55sX/2mzyvILcCNfxeZ4imUoPuuV/4MfhJ1WLwi13pU1hOrB7UkoLJIzhM+zI6FwN4LwNopsVu73hpmGqY8eqLQZdqi9uvuZ7RXTfcZe6sduLZIPSWQSfIiwqfMLOFdsDXdaZG2DWt0G6QATEQgQYAL6uchdSqSZ3+91+m5623bGrZJMjKNBPeKjwYbRnMXwJDjH0CzsxWDiPO3aByw+TleWGOmNVf5nsn64zxO7fbjmgr+/S56+TcJVN0ZSbJjEvfYvNWAiMEymw1EDXSR9n6kE1fa5qok3MSJjbu94x5bt6FBR4uTTaapDblLGkL/tOZ91R96t8I2FNmtQShaC/qrs3+/kItgGYFe1sDIUb33D/CRsMtujY6FdscYybKkIaEvaV+gjEq+2sh1RkxhCyTExfMP4ekw/bk8szWvZy3qDVSIW/g6U4L2C06xk4X3XdgSbZ355uTs9epk+O7sMYgfDfo4aBtWsq+iLU1vjpVg+FfzM9ufksEE2flosfb5ws7IOTofrfo+VEkxXZZwuTUOe4UUI+eA0ZMw==
template:
metadata:
name: authelia-users
namespace: apps-roboces
type: Opaque

252
scripts/authelia-users.sh Executable file
View file

@ -0,0 +1,252 @@
#!/usr/bin/env -S uv run --script
# /// script
# dependencies = ["ldap3"]
# ///
"""
Manage Authelia users in the LLDAP backend.
Usage:
scripts/authelia-users.sh list
scripts/authelia-users.sh add -u USERNAME -n "Display Name" -p PASSWORD [-e EMAIL] [-g group1,group2,...]
scripts/authelia-users.sh remove -u USERNAME
scripts/authelia-users.sh apply
Examples:
scripts/authelia-users.sh add -u jdoe -n "John Doe" -p changeme -e jdoe@example.com -g users,admins
scripts/authelia-users.sh remove -u jdoe
Note:
With the LDAP backend, users are managed directly in LLDAP. The 'apply'
command is kept for convenience but only validates the LLDAP connection.
"""
import argparse
import os
import re
import subprocess
import sys
from ldap3 import ALL, Connection, Server, SUBTREE
LLDAP_NAMESPACE = "apps-fuku"
LLDAP_SECRET = "lldap-secrets"
LLDAP_SERVICE = "lldap.apps-fuku.svc.cluster.local"
LLDAP_PORT = 3890
LLDAP_BASE_DN = "dc=fuku,dc=local"
LLDAP_ADMIN_USER = "uid=admin,ou=people,dc=fuku,dc=local"
def fail(msg: str) -> None:
print(f"ERROR: {msg}", file=sys.stderr)
sys.exit(1)
def get_lldap_password() -> str:
result = subprocess.run(
[
"kubectl",
"get",
"secret",
LLDAP_SECRET,
"-n",
LLDAP_NAMESPACE,
"-o",
"jsonpath={.data.LLDAP_LDAP_USER_PASS}",
],
capture_output=True,
text=True,
check=True,
)
return __import__("base64").b64decode(result.stdout).decode()
def get_lldap_hosts() -> list[str]:
result = subprocess.run(
[
"kubectl",
"get",
"svc",
"lldap",
"-n",
LLDAP_NAMESPACE,
"-o",
"jsonpath={.status.loadBalancer.ingress[*].ip}",
],
capture_output=True,
text=True,
check=True,
)
ips = [ip.strip() for ip in result.stdout.split() if ip.strip()]
if not ips:
fail("could not discover lldap LoadBalancer IPs")
return ips
def lldap_connection() -> Connection:
password = get_lldap_password()
hosts = get_lldap_hosts()
last_error = None
for host in hosts:
try:
server = Server(host, port=LLDAP_PORT, use_ssl=False, get_info=ALL)
conn = Connection(
server,
user=LLDAP_ADMIN_USER,
password=password,
auto_bind=True,
read_only=False,
)
return conn
except Exception as e:
last_error = e
continue
fail(f"could not connect to any lldap endpoint: {last_error}")
def validate_username(username: str) -> None:
if not re.match(r"^[a-zA-Z0-9_.-]+$", username):
fail(f"invalid username: {username} (allowed: a-z, 0-9, _, ., -)")
def list_users() -> None:
with lldap_connection() as conn:
conn.search(
f"ou=people,{LLDAP_BASE_DN}",
"(objectClass=person)",
SUBTREE,
attributes=["uid", "cn", "mail", "memberOf"],
)
if not conn.entries:
print("No users found.")
return
print(f"{'USERNAME':<20} {'DISPLAY NAME':<30} {'EMAIL':<30} GROUPS")
for entry in conn.entries:
uid = entry.uid.value if entry.uid else ""
cn = entry.cn.value if entry.cn else ""
mail = entry.mail.value if entry.mail else ""
groups = ",".join(
g.split(",")[0].replace("cn=", "") for g in entry.memberOf.values
) if entry.memberOf else ""
print(f"{uid:<20} {cn:<30} {mail:<30} {groups}")
def get_group_id(conn: Connection, group_name: str) -> int | None:
conn.search(
f"ou=groups,{LLDAP_BASE_DN}",
f"(cn={group_name})",
SUBTREE,
attributes=["uid"],
)
if not conn.entries:
return None
uid = conn.entries[0].uid.value
try:
return int(uid)
except (TypeError, ValueError):
return None
def add_user(
username: str,
displayname: str,
password: str,
email: str,
groups: list[str],
) -> None:
validate_username(username)
with lldap_connection() as conn:
user_dn = f"uid={username},ou=people,{LLDAP_BASE_DN}"
# Check if user already exists.
conn.search(user_dn, "(objectClass=*)", SUBTREE, attributes=["uid"])
if conn.entries:
print(f"User '{username}' already exists, updating password and groups.")
else:
attrs = {
"objectClass": ["inetOrgPerson", "posixAccount", "mailAccount", "person"],
"uid": username,
"cn": displayname,
"mail": email or f"{username}@roboces.dev",
}
if not conn.add(user_dn, attributes=attrs):
fail(f"failed to create user: {conn.result}")
print(f"User '{username}' created in LLDAP.")
# Set password.
if not conn.extend.standard.modify_password(user=user_dn, new_password=password):
fail(f"failed to set password: {conn.result}")
print(f"Password set for '{username}'.")
# Add to groups.
for group_name in groups:
group_id = get_group_id(conn, group_name)
if group_id is None:
print(f" WARNING: group '{group_name}' not found, skipping")
continue
if not conn.add_user_to_group(user_dn, group_id):
print(f" WARNING: could not add to group '{group_name}': {conn.result}")
else:
print(f" Added to group '{group_name}'.")
def remove_user(username: str) -> None:
validate_username(username)
with lldap_connection() as conn:
user_dn = f"uid={username},ou=people,{LLDAP_BASE_DN}"
conn.search(user_dn, "(objectClass=*)", SUBTREE, attributes=["uid"])
if not conn.entries:
fail(f"user '{username}' not found")
if not conn.delete(user_dn):
fail(f"failed to delete user: {conn.result}")
print(f"User '{username}' removed from LLDAP.")
def apply_users() -> None:
# With the LDAP backend there is no local file to apply; users live in LLDAP.
with lldap_connection():
print("LLDAP connection OK. Users are managed directly; no apply step needed.")
def main() -> None:
os.environ.setdefault("PYTHONUNBUFFERED", "1")
sys.stdout.reconfigure(line_buffering=True)
parser = argparse.ArgumentParser(
description="Manage Authelia users in the LLDAP backend."
)
subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser("list", help="List existing users (no passwords shown).")
add_parser = subparsers.add_parser("add", help="Add or update a user.")
add_parser.add_argument("-u", "--username", required=True, help="Username")
add_parser.add_argument("-n", "--displayname", required=True, help="Display name")
add_parser.add_argument("-p", "--password", required=True, help="Password")
add_parser.add_argument("-e", "--email", default="", help="Email address")
add_parser.add_argument(
"-g", "--groups", default="users", help="Comma-separated groups"
)
remove_parser = subparsers.add_parser("remove", help="Remove a user.")
remove_parser.add_argument("-u", "--username", required=True, help="Username")
subparsers.add_parser(
"apply",
help="Validate LLDAP connection (no local state to apply with LDAP backend).",
)
args = parser.parse_args()
if args.command == "list":
list_users()
elif args.command == "add":
groups = [g.strip() for g in args.groups.split(",") if g.strip()]
add_user(args.username, args.displayname, args.password, args.email, groups)
elif args.command == "remove":
remove_user(args.username)
elif args.command == "apply":
apply_users()
if __name__ == "__main__":
main()