From e5a1bf4cb019933621eb059cc6cdd1f8af8df71d Mon Sep 17 00:00:00 2001 From: ibizaman 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 6666c710b77e53ea274af4c4dddcb9251b0ccf18 Mon Sep 17 00:00:00 2001 From: ibizaman 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 fe956c943281..6097f8d06216 100644 --- a/nixos/modules/services/databases/lldap.nix +++ b/nixos/modules/services/databases/lldap.nix @@ -12,6 +12,84 @@ let dbUser = "lldap"; localPostgresql = cfg.database.createLocally && cfg.database.type == "postgresql"; localMysql = cfg.database.createLocally && cfg.database.type == "mariadb"; + + 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; { @@ -19,6 +97,8 @@ in package = mkPackageOption pkgs "lldap" { }; + bootstrap-package = mkPackageOption pkgs "lldap-bootstrap" { }; + environment = mkOption { type = with types; attrsOf str; default = { }; @@ -203,6 +283,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 { @@ -219,25 +491,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)} + '' + ]); services.lldap.settings.database_url = lib.mkIf cfg.database.createLocally ( lib.mkDefault ( @@ -279,6 +603,28 @@ in + '' exec ${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 8e38d4bdefa3..47d32c7a2a7b 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')}') ''; } --