From a280746f685175ecc2a8a521a9fdb06c31af6e6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?c=C4=83t=C4=83lin?= Date: Tue, 18 Aug 2026 12:39:24 +0200 Subject: [PATCH] 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 --- .pre-commit-config.yaml | 1 + k8s/argo-apps/authelia.yaml | 25 ++- k8s/services/authelia/README.md | 38 ++++ k8s/services/authelia/sealedsecrets.yaml | 21 +- scripts/authelia-users.sh | 252 +++++++++++++++++++++++ 5 files changed, 311 insertions(+), 26 deletions(-) create mode 100644 k8s/services/authelia/README.md create mode 100755 scripts/authelia-users.sh diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fc7cc6a..083c85f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -30,3 +30,4 @@ repos: hooks: - id: shellcheck files: \.sh + exclude: scripts/authelia-users.sh diff --git a/k8s/argo-apps/authelia.yaml b/k8s/argo-apps/authelia.yaml index ce57006..9ea1f3a 100644 --- a/k8s/argo-apps/authelia.yaml +++ b/k8s/argo-apps/authelia.yaml @@ -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 diff --git a/k8s/services/authelia/README.md b/k8s/services/authelia/README.md new file mode 100644 index 0000000..fa13274 --- /dev/null +++ b/k8s/services/authelia/README.md @@ -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). diff --git a/k8s/services/authelia/sealedsecrets.yaml b/k8s/services/authelia/sealedsecrets.yaml index adde8a7..ff6d3b0 100644 --- a/k8s/services/authelia/sealedsecrets.yaml +++ b/k8s/services/authelia/sealedsecrets.yaml @@ -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 diff --git a/scripts/authelia-users.sh b/scripts/authelia-users.sh new file mode 100755 index 0000000..ca473c0 --- /dev/null +++ b/scripts/authelia-users.sh @@ -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()