From e5a1bf4cb019933621eb059cc6cdd1f8af8df71d Mon Sep 17 00:00:00 2001
From: ibizaman <ibizaman@tiserbox.com>
Date: Wed, 13 Aug 2025 08:14:38 +0200
Subject: [PATCH 1/2] lldap-bootstrap: init 0.6.2

---
 pkgs/by-name/ll/lldap-bootstrap/package.nix | 57 +++++++++++++++++++++
 1 file changed, 57 insertions(+)
 create mode 100644 pkgs/by-name/ll/lldap-bootstrap/package.nix

diff --git a/pkgs/by-name/ll/lldap-bootstrap/package.nix b/pkgs/by-name/ll/lldap-bootstrap/package.nix
new file mode 100644
index 00000000000000..8b9a915b18f4d9
--- /dev/null
+++ b/pkgs/by-name/ll/lldap-bootstrap/package.nix
@@ -0,0 +1,57 @@
+{
+  curl,
+  fetchFromGitHub,
+  jq,
+  jo,
+  lib,
+  lldap,
+  lldap-bootstrap,
+  makeWrapper,
+  stdenv,
+}:
+let
+  version = "0.6.2";
+in
+stdenv.mkDerivation {
+  pname = "lldap-bootstrap";
+  inherit version;
+
+  src = fetchFromGitHub {
+    owner = "lldap";
+    repo = "lldap";
+    rev = "v${version}";
+    hash = "sha256-UBQWOrHika8X24tYdFfY8ETPh9zvI7/HV5j4aK8Uq+Y=";
+  };
+
+  dontBuild = true;
+
+  nativeBuildInputs = [ makeWrapper ];
+
+  installPhase = ''
+    mkdir -p $out/bin
+    cp ./scripts/bootstrap.sh $out/bin/lldap-bootstrap
+
+    wrapProgram $out/bin/lldap-bootstrap \
+      --set LLDAP_SET_PASSWORD_PATH ${lldap}/bin/lldap_set_password \
+      --prefix PATH : ${
+        lib.makeBinPath [
+          curl
+          jq
+          jo
+        ]
+      }
+  '';
+
+  meta = {
+    description = "Bootstrap script for LLDAP";
+    homepage = "https://github.com/lldap/lldap";
+    changelog = "https://github.com/lldap/lldap/blob/v${lldap-bootstrap.version}/CHANGELOG.md";
+    license = lib.licenses.gpl3Only;
+    platforms = lib.platforms.linux;
+    maintainers = with lib.maintainers; [
+      bendlas
+      ibizaman
+    ];
+    mainProgram = "lldap-bootstrap";
+  };
+}

From 850339f833113255afca5c6038e9fafa5b59bbb8 Mon Sep 17 00:00:00 2001
From: ibizaman <ibizaman@tiserbox.com>
Date: Wed, 13 Aug 2025 08:15:12 +0200
Subject: [PATCH 2/2] lldap: add ensure options

---
 nixos/modules/services/databases/lldap.nix | 372 ++++++++++++++++++++-
 nixos/tests/lldap.nix                      | 143 +++++++-
 2 files changed, 498 insertions(+), 17 deletions(-)

diff --git a/nixos/modules/services/databases/lldap.nix b/nixos/modules/services/databases/lldap.nix
index e21deef91f3366..03f321996a3c99 100644
--- a/nixos/modules/services/databases/lldap.nix
+++ b/nixos/modules/services/databases/lldap.nix
@@ -8,6 +8,84 @@
 let
   cfg = config.services.lldap;
   format = pkgs.formats.toml { };
+
+  inherit (lib) mkOption types;
+
+  ensureFormat = pkgs.formats.json { };
+  ensureGenerate =
+    let
+      filterNulls = lib.filterAttrsRecursive (n: v: v != null);
+
+      filteredSource =
+        source: if builtins.isList source then map filterNulls source else filterNulls source;
+    in
+    name: source: ensureFormat.generate name (filteredSource source);
+
+  ensureFieldsOptions = name: {
+    name = mkOption {
+      type = types.str;
+      description = "Name of the field.";
+      default = name;
+    };
+
+    attributeType = mkOption {
+      type = types.enum [
+        "STRING"
+        "INTEGER"
+        "JPEG"
+        "DATE_TIME"
+      ];
+      description = "Attribute type.";
+    };
+
+    isEditable = mkOption {
+      type = types.bool;
+      description = "Is field editable.";
+      default = true;
+    };
+
+    isList = mkOption {
+      type = types.bool;
+      description = "Is field a list.";
+      default = false;
+    };
+
+    isVisible = mkOption {
+      type = types.bool;
+      description = "Is field visible in UI.";
+      default = true;
+    };
+  };
+
+  allUserGroups = lib.flatten (lib.mapAttrsToList (n: u: u.groups) cfg.ensureUsers);
+  # The three hardcoded groups are always created when the service starts.
+  allGroups = lib.mapAttrsToList (n: g: g.name) cfg.ensureGroups ++ [
+    "lldap_admin"
+    "lldap_password_manager"
+    "lldap_strict_readonly"
+  ];
+  userGroupNotInEnsuredGroup = lib.sortOn lib.id (
+    lib.unique (lib.subtractLists allGroups allUserGroups)
+  );
+  someUsersBelongToNonEnsuredGroup = (lib.lists.length userGroupNotInEnsuredGroup) > 0;
+
+  generateEnsureConfigDir =
+    name: source:
+    let
+      genOne =
+        name: sourceOne:
+        pkgs.writeTextDir "configs/${name}.json" (
+          builtins.readFile (ensureGenerate "configs/${name}.json" sourceOne)
+        );
+    in
+    "${
+      pkgs.symlinkJoin {
+        inherit name;
+        paths = lib.mapAttrsToList genOne source;
+      }
+    }/configs";
+
+  quoteVariable = x: "\"${x}\"";
 in
 {
   options.services.lldap = with lib; {
@@ -15,6 +93,8 @@ in
 
     package = mkPackageOption pkgs "lldap" { };
 
+    bootstrap-package = mkPackageOption pkgs "lldap-bootstrap" { };
+
     environment = mkOption {
       type = with types; attrsOf str;
       default = { };
@@ -169,6 +249,198 @@ in
         If that is okay for you and you want to silence the warning, set this option to `true`.
       '';
     };
+
+    ensureUsers = mkOption {
+      description = ''
+        Create the users defined here on service startup.
+
+        If `enforceEnsure` option is `true`, the groups
+        users belong to must be present in the `ensureGroups` option.
+
+        Non-default options must be added to the `ensureGroupFields` option.
+      '';
+      default = { };
+      type = types.attrsOf (
+        types.submodule (
+          { name, ... }:
+          {
+            freeformType = ensureFormat.type;
+
+            options = {
+              id = mkOption {
+                type = types.str;
+                description = "Username.";
+                default = name;
+              };
+
+              email = mkOption {
+                type = types.str;
+                description = "Email.";
+              };
+
+              password_file = mkOption {
+                type = types.str;
+                description = "File containing the password.";
+              };
+
+              displayName = mkOption {
+                type = types.nullOr types.str;
+                default = null;
+                description = "Display name.";
+              };
+
+              firstName = mkOption {
+                type = types.nullOr types.str;
+                default = null;
+                description = "First name.";
+              };
+
+              lastName = mkOption {
+                type = types.nullOr types.str;
+                default = null;
+                description = "Last name.";
+              };
+
+              avatar_file = mkOption {
+                type = types.nullOr types.str;
+                default = null;
+                description = "Avatar file. Must be a valid path to jpeg file (ignored if avatar_url specified)";
+              };
+
+              avatar_url = mkOption {
+                type = types.nullOr types.str;
+                default = null;
+                description = "Avatar url. must be a valid URL to jpeg file (ignored if gravatar_avatar specified)";
+              };
+
+              gravatar_avatar = mkOption {
+                type = types.nullOr types.str;
+                default = null;
+                description = "Get avatar from Gravatar using the email.";
+              };
+
+              weser_avatar = mkOption {
+                type = types.nullOr types.str;
+                default = null;
+                description = "Convert avatar retrieved by gravatar or the URL.";
+              };
+
+              groups = mkOption {
+                type = types.listOf types.str;
+                default = [ ];
+                description = "Groups the user would be a member of (all the groups must be specified in group config files).";
+              };
+            };
+          }
+        )
+      );
+    };
+
+    ensureGroups = mkOption {
+      description = ''
+        Create the groups defined here on service startup.
+
+        Non-default options must be added to the `ensureGroupFields` option.
+      '';
+      default = { };
+      type = types.attrsOf (
+        types.submodule (
+          { name, ... }:
+          {
+            freeformType = ensureFormat.type;
+
+            options = {
+              name = mkOption {
+                type = types.str;
+                description = "Name of the group.";
+                default = name;
+              };
+            };
+          }
+        )
+      );
+    };
+
+    ensureUserFields = mkOption {
+      description = "Extra fields for users";
+      default = { };
+      type = types.attrsOf (
+        types.submodule (
+          { name, ... }:
+          {
+            options = ensureFieldsOptions name;
+          }
+        )
+      );
+    };
+
+    ensureGroupFields = mkOption {
+      description = "Extra fields for groups";
+      default = { };
+      type = types.attrsOf (
+        types.submodule (
+          { name, ... }:
+          {
+            options = ensureFieldsOptions name;
+          }
+        )
+      );
+    };
+
+    ensureAdminUsername = mkOption {
+      type = types.str;
+      default = "admin";
+      description = ''
+        Username of the default admin user with which to connect to the LLDAP service.
+
+        By default, it is `"admin"`.
+        Extra admin users can be added using the `services.lldap.ensureUsers` option and adding them to the correct groups.
+      '';
+    };
+
+    ensureAdminPassword = mkOption {
+      type = types.nullOr types.str;
+      defaultText = "config.services.lldap.settings.ldap_user_pass";
+      default = cfg.settings.ldap_user_pass or null;
+      description = ''
+        Password of an admin user with which to connect to the LLDAP service.
+
+        By default, it is the same as the password for the default admin user 'admin'.
+        If using a password from another user, it must be managed manually.
+
+        Unsecure. Use `services.lldap.ensureAdminPasswordFile` option instead.
+      '';
+    };
+
+    ensureAdminPasswordFile = mkOption {
+      type = types.nullOr types.str;
+      defaultText = "config.services.lldap.settings.ldap_user_pass_file";
+      default = cfg.settings.ldap_user_pass_file or null;
+      description = ''
+        Path to the file containing the password of an admin user with which to connect to the LLDAP service.
+
+        By default, it is the same as the password for the default admin user 'admin'.
+        If using a password from another user, it must be managed manually.
+      '';
+    };
+
+    enforceUsers = mkOption {
+      description = "Delete users not managed declaratively.";
+      type = types.bool;
+      default = false;
+    };
+
+    enforceUserMemberships = mkOption {
+      description = "Remove users from groups they do not belong to declaratively.";
+      type = types.bool;
+      default = false;
+    };
+
+    enforceGroups = mkOption {
+      description = "Delete groups not managed declaratively.";
+      type = types.bool;
+      default = false;
+    };
   };
 
   config = lib.mkIf cfg.enable {
@@ -185,25 +457,77 @@ in
           (cfg.settings.ldap_user_pass_file or null) == null || (cfg.settings.ldap_user_pass or null) == null;
         message = "lldap: Both `ldap_user_pass` and `ldap_user_pass_file` settings should not be set at the same time. Set one to `null`.";
       }
+      {
+        assertion =
+          cfg.ensureUsers != { }
+          || cfg.ensureGroups != { }
+          || cfg.ensureUserFields != { }
+          || cfg.ensureGroupFields != { }
+          || cfg.enforceUsers
+          || cfg.enforceUserMemberships
+          || cfg.enforceGroups
+          -> cfg.ensureAdminPassword != null || cfg.ensureAdminPasswordFile != null;
+        message = ''
+          lldap: Some ensure options are set but no admin user password is set.
+          Add a default password to the `ldap_user_pass` or `ldap_user_pass_file` setting and set `force_ldap_user_pass_reset` to `true` to manage the admin user declaratively
+          or create an admin user manually and set its password in `ensureAdminPasswordFile` option.
+        '';
+      }
+      {
+        assertion = cfg.enforceUserMemberships -> !someUsersBelongToNonEnsuredGroup;
+        message = ''
+          lldap: Some users belong to groups not present in the ensureGroups attr,
+          add the following groups or remove them from the groups a user belong to:
+            ${lib.concatMapStringsSep quoteVariable ", " userGroupNotInEnsuredGroup}
+        '';
+      }
+      (
+        let
+          getNames = source: lib.flatten (lib.mapAttrsToList (x: v: v.name) source);
+          allNames = getNames cfg.ensureUserFields ++ getNames cfg.ensureGroupFields;
+          validFieldName = name: lib.match "[a-zA-Z0-9-]+" name != null;
+        in
+        {
+          assertion = lib.all validFieldName allNames;
+          message = ''
+            lldap: The following custom user or group fields have invalid names. Valid characters are: a-z, A-Z, 0-9, and dash (-).
+            The offending fields are: ${
+              lib.concatMapStringsSep quoteVariable ", " (lib.filter (x: !(validFieldName x)) allNames)
+            }
+          '';
+        }
+      )
     ];
 
     warnings =
-      lib.optionals (cfg.settings.ldap_user_pass or null != null) [
+      (lib.optionals (cfg.ensureAdminPassword != null) [
+        ''
+          lldap: Unsecure option `ensureAdminPassword` is used. Prefer `ensureAdminPasswordFile` instead.
+        ''
+      ])
+      ++ (lib.optionals (cfg.settings.ldap_user_pass or null != null) [
         ''
           lldap: Unsecure `ldap_user_pass` setting is used. Prefer `ldap_user_pass_file` instead.
         ''
-      ]
-      ++
-        lib.optionals
-          (cfg.settings.force_ldap_user_pass_reset == false && cfg.silenceForceUserPassResetWarning == false)
-          [
-            ''
-              lldap: The `force_ldap_user_pass_reset` setting is set to `false` which means
-              the admin password can be changed through the UI and will drift from the one defined in your nix config.
-              It also means changing the setting `ldap_user_pass` or `ldap_user_pass_file` will have no effect on the admin password.
-              Either set `force_ldap_user_pass_reset` to `"always"` or silence this warning by setting the option `services.lldap.silenceForceUserPassResetWarning` to `true`.
-            ''
-          ];
+      ])
+      ++ (lib.optionals
+        (cfg.settings.force_ldap_user_pass_reset == false && cfg.silenceForceUserPassResetWarning == false)
+        [
+          ''
+            lldap: The `force_ldap_user_pass_reset` setting is set to `false` which means
+            the admin password can be changed through the UI and will drift from the one defined in your nix config.
+            It also means changing the setting `ldap_user_pass` or `ldap_user_pass_file` will have no effect on the admin password.
+            Either set `force_ldap_user_pass_reset` to `"always"` or silence this warning by setting the option `services.lldap.silenceForceUserPassResetWarning` to `true`.
+          ''
+        ]
+      )
+      ++ (lib.optionals (!cfg.enforceUserMemberships && someUsersBelongToNonEnsuredGroup) [
+        ''
+          Some users belong to groups not managed by the configuration here,
+          make sure the following groups exist or the service will not start properly:
+            ${lib.concatStringsSep ", " (map (x: "\"${x}\"") userGroupNotInEnsuredGroup)}
+        ''
+      ]);
 
     systemd.services.lldap = {
       description = "Lightweight LDAP server (lldap)";
@@ -226,6 +550,28 @@ in
         + ''
           ${lib.getExe cfg.package} run --config-file ${format.generate "lldap_config.toml" cfg.settings}
         '';
+      postStart = ''
+        export LLDAP_URL=http://127.0.0.1:${toString cfg.settings.http_port}
+        export LLDAP_ADMIN_USERNAME=${cfg.ensureAdminUsername}
+        export LLDAP_ADMIN_PASSWORD=${
+          if cfg.ensureAdminPassword != null then cfg.ensureAdminPassword else ""
+        }
+        export LLDAP_ADMIN_PASSWORD_FILE=${
+          if cfg.ensureAdminPasswordFile != null then cfg.ensureAdminPasswordFile else ""
+        }
+        export USER_CONFIGS_DIR=${generateEnsureConfigDir "users" cfg.ensureUsers}
+        export GROUP_CONFIGS_DIR=${generateEnsureConfigDir "groups" cfg.ensureGroups}
+        export USER_SCHEMAS_DIR=${
+          generateEnsureConfigDir "userFields" (lib.mapAttrs (n: v: [ v ]) cfg.ensureUserFields)
+        }
+        export GROUP_SCHEMAS_DIR=${
+          generateEnsureConfigDir "groupFields" (lib.mapAttrs (n: v: [ v ]) cfg.ensureGroupFields)
+        }
+        export DO_CLEANUP_USERS=${if cfg.enforceUsers then "true" else "false"}
+        export DO_CLEANUP_USER_MEMBERSHIPS=${if cfg.enforceUserMemberships then "true" else "false"}
+        export DO_CLEANUP_GROUPS=${if cfg.enforceGroups then "true" else "false"}
+        ${lib.getExe cfg.bootstrap-package}
+      '';
       serviceConfig = {
         StateDirectory = "lldap";
         StateDirectoryMode = "0750";
diff --git a/nixos/tests/lldap.nix b/nixos/tests/lldap.nix
index 8e38d4bdefa31d..47d32c7a2a7bbc 100644
--- a/nixos/tests/lldap.nix
+++ b/nixos/tests/lldap.nix
@@ -1,6 +1,9 @@
 { ... }:
 let
   adminPassword = "mySecretPassword";
+  alicePassword = "AlicePassword";
+  bobPassword = "BobPassword";
+  charliePassword = "CharliePassword";
 in
 {
   name = "lldap";
@@ -26,7 +29,7 @@ in
           {
             services.lldap.settings = {
               ldap_user_pass = lib.mkForce null;
-              ldap_user_pass_file = lib.mkForce (toString (pkgs.writeText "adminPasswordFile" adminPassword));
+              ldap_user_pass_file = toString (pkgs.writeText "adminPasswordFile" adminPassword);
               force_ldap_user_pass_reset = "always";
             };
           };
@@ -40,13 +43,110 @@ in
               force_ldap_user_pass_reset = false;
             };
           };
+
+        withAlice.configuration =
+          { ... }:
+          {
+            services.lldap = {
+              enforceUsers = true;
+              enforceUserMemberships = true;
+              enforceGroups = true;
+
+              # This password was set in the "differentAdminPassword" specialisation.
+              ensureAdminPasswordFile = toString (pkgs.writeText "adminPasswordFile" adminPassword);
+
+              ensureUsers = {
+                alice = {
+                  email = "alice@example.com";
+                  password_file = toString (pkgs.writeText "alicePasswordFile" alicePassword);
+                  groups = [ "mygroup" ];
+                };
+              };
+
+              ensureGroups = {
+                mygroup = { };
+              };
+            };
+          };
+
+        withBob.configuration =
+          { ... }:
+          {
+            services.lldap = {
+              enforceUsers = true;
+              enforceUserMemberships = true;
+              enforceGroups = true;
+
+              # This time we check that ensureAdminPasswordFile correctly defaults to `settings.ldap_user_pass_file`
+              settings = {
+                ldap_user_pass = lib.mkForce "password";
+                force_ldap_user_pass_reset = "always";
+              };
+
+              ensureUsers = {
+                bob = {
+                  email = "bob@example.com";
+                  password_file = toString (pkgs.writeText "bobPasswordFile" bobPassword);
+                  groups = [ "bobgroup" ];
+                  displayName = "Bob";
+                };
+              };
+
+              ensureGroups = {
+                bobgroup = { };
+              };
+            };
+          };
+
+        withAttributes.configuration =
+          { ... }:
+          {
+            services.lldap = {
+              enforceUsers = true;
+              enforceUserMemberships = true;
+              enforceGroups = true;
+
+              settings = {
+                ldap_user_pass = lib.mkForce adminPassword;
+                force_ldap_user_pass_reset = "always";
+              };
+
+              ensureUsers = {
+                charlie = {
+                  email = "charlie@example.com";
+                  password_file = toString (pkgs.writeText "charliePasswordFile" charliePassword);
+                  groups = [ "othergroup" ];
+                  displayName = "Charlie";
+                  myattribute = 2;
+                };
+              };
+
+              ensureGroups = {
+                othergroup = {
+                  mygroupattribute = "Managed by NixOS";
+                };
+              };
+
+              ensureUserFields = {
+                myattribute = {
+                  attributeType = "INTEGER";
+                };
+              };
+
+              ensureGroupFields = {
+                mygroupattribute = {
+                  attributeType = "STRING";
+                };
+              };
+            };
+          };
       };
     };
 
   testScript =
     { nodes, ... }:
     let
-      specializations = "${nodes.machine.system.build.toplevel}/specialisation";
+      specialisations = "${nodes.machine.system.build.toplevel}/specialisation";
     in
     ''
       machine.wait_for_unit("lldap.service")
@@ -56,6 +156,9 @@ in
       machine.succeed("curl --location --fail http://localhost:17170/")
 
       adminPassword="${adminPassword}"
+      alicePassword="${alicePassword}"
+      bobPassword="${bobPassword}"
+      charliePassword="${charliePassword}"
 
       def try_login(user, password, expect_success=True):
           cmd = f'ldapsearch -H ldap://localhost:3890 -D uid={user},ou=people,dc=example,dc=com -b "ou=people,dc=example,dc=com" -w {password}'
@@ -70,18 +173,50 @@ in
                   raise Exception("Expected failure, had success")
           return response
 
+      def parse_ldapsearch_output(output):
+          return {n:v for (n, v) in (x.split(': ', 2) for x in output.splitlines() if x != "")}
+
       with subtest("default admin password"):
           try_login("admin", "password",    expect_success=True)
           try_login("admin", adminPassword, expect_success=False)
 
       with subtest("different admin password"):
-          machine.succeed('${specializations}/differentAdminPassword/bin/switch-to-configuration test')
+          machine.succeed('${specialisations}/differentAdminPassword/bin/switch-to-configuration test')
           try_login("admin", "password",    expect_success=False)
           try_login("admin", adminPassword, expect_success=True)
 
       with subtest("change admin password has no effect"):
-          machine.succeed('${specializations}/differentAdminPassword/bin/switch-to-configuration test')
+          machine.succeed('${specialisations}/differentAdminPassword/bin/switch-to-configuration test')
           try_login("admin", "password",    expect_success=False)
           try_login("admin", adminPassword, expect_success=True)
+
+      with subtest("with alice"):
+          machine.succeed('${specialisations}/withAlice/bin/switch-to-configuration test')
+          try_login("alice", "password",    expect_success=False)
+          try_login("alice", alicePassword, expect_success=True)
+          try_login("bob",   "password",    expect_success=False)
+          try_login("bob",   bobPassword,   expect_success=False)
+
+      with subtest("with bob"):
+          machine.succeed('${specialisations}/withBob/bin/switch-to-configuration test')
+          try_login("alice", "password",    expect_success=False)
+          try_login("alice", alicePassword, expect_success=False)
+          try_login("bob",   "password",    expect_success=False)
+          try_login("bob",   bobPassword,   expect_success=True)
+
+      with subtest("with attributes"):
+          machine.succeed('${specialisations}/withAttributes/bin/switch-to-configuration test')
+
+          response = machine.succeed(f'ldapsearch -LLL -H ldap://localhost:3890 -D uid=admin,ou=people,dc=example,dc=com -b "dc=example,dc=com" -w {adminPassword} "(uid=charlie)"')
+          print(response)
+          charlie = parse_ldapsearch_output(response)
+          if charlie.get('myattribute') != "2":
+              raise Exception(f'Unexpected value for attribute "myattribute": {charlie.get('myattribute')}')
+
+          response = machine.succeed(f'ldapsearch -LLL -H ldap://localhost:3890 -D uid=admin,ou=people,dc=example,dc=com -b "dc=example,dc=com" -w {adminPassword} "(cn=othergroup)"')
+          print(response)
+          othergroup = parse_ldapsearch_output(response)
+          if othergroup.get('mygroupattribute') != "Managed by NixOS":
+              raise Exception(f'Unexpected value for attribute "mygroupattribute": {othergroup.get('mygroupattribute')}')
     '';
 }
