549 lines
17 KiB
Diff
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
|
|
|