From 75b9437558a22eabff74339569f98b567f0a0d04 Mon Sep 17 00:00:00 2001 From: ibizaman Date: Wed, 16 Jul 2025 17:51:57 +0200 Subject: [PATCH 3/3] lldap-bootstrap: init unstable-2025-07-17, lldap: add ensure options --- nixos/modules/services/databases/lldap.nix | 282 +++++++++++++++++++- nixos/tests/lldap.nix | 116 +++++++- pkgs/by-name/ll/lldap-bootstrap/package.nix | 54 ++++ 3 files changed, 446 insertions(+), 6 deletions(-) create mode 100644 pkgs/by-name/ll/lldap-bootstrap/package.nix diff --git a/nixos/modules/services/databases/lldap.nix b/nixos/modules/services/databases/lldap.nix index 518b39ba7a86..b7c4c85f751f 100644 --- a/nixos/modules/services/databases/lldap.nix +++ b/nixos/modules/services/databases/lldap.nix @@ -2,13 +2,82 @@ config, lib, pkgs, - utils, ... }: let cfg = config.services.lldap; format = pkgs.formats.toml { }; + + inherit (lib) mkOption types; + + ensureFormat = pkgs.formats.json { }; + ensureGenerate = + name: source: ensureFormat.generate name (lib.filterAttrsRecursive (n: v: v != null) 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"; in { options.services.lldap = with lib; { @@ -16,6 +85,8 @@ in package = mkPackageOption pkgs "lldap" { }; + bootstrap-package = mkPackageOption pkgs "lldap-bootstrap" { }; + environment = mkOption { type = with types; attrsOf str; default = { }; @@ -146,6 +217,172 @@ in }; }; }; + + 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 an admin user with which to connect to the LLDAP service. + + By default, it is the default admin username `admin`. + If using another user, it must be managed manually. + ''; + }; + + ensureAdminPasswordFile = mkOption { + type = types.nullOr types.str; + defaultText = "config.services.lldap.adminPasswordFile"; + default = cfg.adminPasswordFile; + 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. + ''; + }; + + enforceEnsure = mkOption { + description = "Delete users, groups and fields not in their respective ensure options and remove users from groups they do not belong to."; + type = types.bool; + default = false; + }; }; config = lib.mkIf cfg.enable { @@ -158,8 +395,40 @@ in Please set the `resetAdminPassword` option to `true` or `"always"`. ''; } + { + assertion = + cfg.ensureUsers != { } + || cfg.ensureGroups != { } + || cfg.ensureUserFields != { } + || cfg.ensureGroupFields != { } + || cfg.enforceEnsure + -> cfg.ensureAdminPasswordFile != null; + message = '' + Some ensure options are set but no admin user password is set. + Add a default password to `adminPasswordFile` to manage the admin user declaratively + or create a user manually and set its password in `ensureAdminPasswordFile`. + ''; + } + { + assertion = cfg.enforceEnsure -> !someUsersBelongToNonEnsuredGroup; + message = '' + 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.concatStringsSep ", " (map (x: "\"${x}\"") userGroupNotInEnsuredGroup)} + ''; + } ]; + warnings = ( + lib.optionals (!cfg.enforceEnsure && (lib.debug.traceValSeq 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.environment = { LLDAP_JWT_SECRET_FILE = lib.mkIf (cfg.jwtSecretFile != null) cfg.jwtSecretFile; LLDAP_LDAP_USER_PASS_FILE = lib.mkIf (cfg.adminPasswordFile != null) cfg.adminPasswordFile; @@ -193,6 +462,17 @@ 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_FILE=${cfg.ensureAdminPasswordFile} + export USER_CONFIGS_DIR=${generateEnsureConfigDir "users" cfg.ensureUsers} + export GROUP_CONFIGS_DIR=${generateEnsureConfigDir "groups" cfg.ensureGroups} + export USER_SCHEMAS_DIR=${generateEnsureConfigDir "userFields" cfg.ensureUserFields} + export GROUP_SCHEMAS_DIR=${generateEnsureConfigDir "groupFields" cfg.ensureGroupFields} + export DO_CLEANUP=${if cfg.enforceEnsure 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 e88fa37ab83d..7d2e65699a0f 100644 --- a/nixos/tests/lldap.nix +++ b/nixos/tests/lldap.nix @@ -1,6 +1,8 @@ { ... }: let adminPassword = "mySecretPassword"; + alicePassword = "AlicePassword"; + bobPassword = "BobPassword"; in { name = "lldap"; @@ -19,21 +21,125 @@ in verbose = true; ldap_base_dn = "dc=example,dc=com"; }; + + ensureUsers = { + alice = { + email = "alice@example.com"; + password_file = toString (pkgs.writeText "alicePasswordFile" alicePassword); + groups = [ "mygroup" ]; + }; + }; + + ensureGroups = { + mygroup = { }; + }; }; environment.systemPackages = [ pkgs.openldap ]; + + specialisation = { + withAlice.configuration = + { ... }: + { + services.lldap = { + ensureUsers = { + alice = { + email = "alice@example.com"; + password_file = toString (pkgs.writeText "alicePasswordFile" alicePassword); + groups = [ "mygroup" ]; + }; + }; + + ensureGroups = { + mygroup = { }; + }; + }; + }; + + withBob.configuration = + { ... }: + { + services.lldap = { + ensureUsers = { + bob = { + email = "bob@example.com"; + password_file = toString (pkgs.writeText "bobPasswordFile" bobPassword); + groups = [ "othergroup" ]; + displayName = "Bob"; + myattribute = 2; + }; + }; + + ensureGroups = { + othergroup = { + mygroupattribute = "Managed by NixOS"; + }; + }; + + ensureUserFields = { + myattribute = { + attributeType = "INTEGER"; + }; + }; + + ensureGroupFields = { + mygroupattribute = { + attributeType = "STRING"; + }; + }; + }; + }; + }; }; - testScript = '' + testScript = + { nodes, ... }: + let + specializations = "${nodes.machine.system.build.toplevel}/specialisation"; + in + '' machine.wait_for_unit("lldap.service") machine.wait_for_open_port(3890) machine.wait_for_open_port(17170) machine.succeed("curl --location --fail http://localhost:17170/") + adminPassword="${adminPassword}" + alicePassword="${alicePassword}" + bobPassword="${bobPassword}" + + def try_login(user, password, expect_success=True): + code, response = machine.execute(f'ldapsearch -H ldap://localhost:3890 -D uid={user},ou=people,dc=example,dc=com -b "ou=people,dc=example,dc=com" -w {password}') + print(response) + if expect_success: + if code != 0: + raise Exception("Expected failure, had success") + else: + if code == 0: + raise Exception(f"Expected success, had failure {code}") + + with subtest("only default admin user"): + print(try_login("admin", "password", expect_success=False)) + print(try_login("admin", adminPassword, expect_success=True)) + print(try_login("alice", "password", expect_success=False)) + print(try_login("alice", alicePassword, expect_success=False)) + print(try_login("bob", "password", expect_success=False)) + print(try_login("bob", bobPassword, expect_success=False)) - response = machine.fail('ldapsearch -H ldap://localhost:3890 -D uid=admin,ou=people,dc=example,dc=com -b "ou=people,dc=example,dc=com" -w password') - print(response) + with subtest("with alice"): + machine.succeed('${specializations}/withAlice/bin/switch-to-configuration test') + print(try_login("admin", "password", expect_success=False)) + print(try_login("admin", adminPassword, expect_success=True)) + print(try_login("alice", "password", expect_success=False)) + print(try_login("alice", alicePassword, expect_success=True)) + print(try_login("bob", "password", expect_success=False)) + print(try_login("bob", bobPassword, expect_success=False)) - response = machine.succeed('ldapsearch -H ldap://localhost:3890 -D uid=admin,ou=people,dc=example,dc=com -b "ou=people,dc=example,dc=com" -w ${adminPassword}') - print(response) + with subtest("with attributes"): + machine.succeed('${specializations}/withBob/bin/switch-to-configuration test') + print(try_login("admin", "password", expect_success=False)) + print(try_login("admin", adminPassword, expect_success=True)) + print(try_login("alice", "password", expect_success=False)) + print(try_login("alice", alicePassword, expect_success=False)) + print(try_login("bob", "password", expect_success=False)) + print(try_login("bob", bobPassword, expect_success=True)) ''; } 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 000000000000..1459784bf8e5 --- /dev/null +++ b/pkgs/by-name/ll/lldap-bootstrap/package.nix @@ -0,0 +1,54 @@ +{ + curl, + fetchFromGitHub, + jq, + jo, + lib, + lldap, + lldap-bootstrap, + makeWrapper, + stdenv, +}: +stdenv.mkDerivation { + pname = "lldap-bootstrap"; + version = "unstable-2025-07-17"; + + src = fetchFromGitHub { + owner = "ibizaman"; + repo = "lldap"; + rev = "14b083c9cf6c13802b7477af3b85894f2b7d1c81"; + hash = "sha256-olQGLgjLE7la5fYiCgC4tpaxyhFT9ZvRWLtASoaqq9k="; + }; + + 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"; + }; +} -- 2.49.0