selfhostblocks/patches/0003-lldap-bootstrap-init-unstable-2025-07-16-lldap-add-e.patch

549 lines
17 KiB
Diff

From 75b9437558a22eabff74339569f98b567f0a0d04 Mon Sep 17 00:00:00 2001
From: ibizaman <ibizaman@tiserbox.com>
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