From b50f45e17e86d51797f01371991d78a781409148 Mon Sep 17 00:00:00 2001 From: ibizaman Date: Thu, 17 Jul 2025 09:41:04 +0200 Subject: [PATCH] add declarative lldap using secrets contract --- docs/redirects.json | 106 +++- flake.nix | 5 + modules/blocks/lldap.nix | 198 ++++++- modules/blocks/lldap/docs/default.md | 90 ++- ...ldap-lldap-0.6.1-unstable-2025-07-16.patch | 163 ++++++ ...add-options-to-set-important-secrets.patch | 133 +++++ ...init-unstable-2025-07-16-lldap-add-e.patch | 549 ++++++++++++++++++ test/blocks/lldap.nix | 25 + 8 files changed, 1260 insertions(+), 9 deletions(-) create mode 100644 patches/0001-lldap-lldap-0.6.1-unstable-2025-07-16.patch create mode 100644 patches/0002-lldap-add-options-to-set-important-secrets.patch create mode 100644 patches/0003-lldap-bootstrap-init-unstable-2025-07-16-lldap-add-e.patch diff --git a/docs/redirects.json b/docs/redirects.json index b0fa342..b5985c1 100644 --- a/docs/redirects.json +++ b/docs/redirects.json @@ -311,8 +311,11 @@ "blocks-lldap-global-setup": [ "blocks-lldap.html#blocks-lldap-global-setup" ], - "blocks-lldap-manage-user-group": [ - "blocks-lldap.html#blocks-lldap-manage-user-group" + "blocks-lldap-manage-groups": [ + "blocks-lldap.html#blocks-lldap-manage-groups" + ], + "blocks-lldap-manage-users": [ + "blocks-lldap.html#blocks-lldap-manage-users" ], "blocks-lldap-options": [ "blocks-lldap.html#blocks-lldap-options" @@ -362,6 +365,105 @@ "blocks-lldap-options-shb.lldap.enable": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.enable" ], + "blocks-lldap-options-shb.lldap.ensureGroupFields": [ + "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureGroupFields" + ], + "blocks-lldap-options-shb.lldap.ensureGroupFields._name_.attributeType": [ + "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureGroupFields._name_.attributeType" + ], + "blocks-lldap-options-shb.lldap.ensureGroupFields._name_.isEditable": [ + "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureGroupFields._name_.isEditable" + ], + "blocks-lldap-options-shb.lldap.ensureGroupFields._name_.isList": [ + "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureGroupFields._name_.isList" + ], + "blocks-lldap-options-shb.lldap.ensureGroupFields._name_.isVisible": [ + "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureGroupFields._name_.isVisible" + ], + "blocks-lldap-options-shb.lldap.ensureGroupFields._name_.name": [ + "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureGroupFields._name_.name" + ], + "blocks-lldap-options-shb.lldap.ensureGroups": [ + "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureGroups" + ], + "blocks-lldap-options-shb.lldap.ensureGroups._name_.name": [ + "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureGroups._name_.name" + ], + "blocks-lldap-options-shb.lldap.ensureUserFields": [ + "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUserFields" + ], + "blocks-lldap-options-shb.lldap.ensureUserFields._name_.attributeType": [ + "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUserFields._name_.attributeType" + ], + "blocks-lldap-options-shb.lldap.ensureUserFields._name_.isEditable": [ + "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUserFields._name_.isEditable" + ], + "blocks-lldap-options-shb.lldap.ensureUserFields._name_.isList": [ + "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUserFields._name_.isList" + ], + "blocks-lldap-options-shb.lldap.ensureUserFields._name_.isVisible": [ + "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUserFields._name_.isVisible" + ], + "blocks-lldap-options-shb.lldap.ensureUserFields._name_.name": [ + "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUserFields._name_.name" + ], + "blocks-lldap-options-shb.lldap.ensureUsers": [ + "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers" + ], + "blocks-lldap-options-shb.lldap.ensureUsers._name_.avatar_file": [ + "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.avatar_file" + ], + "blocks-lldap-options-shb.lldap.ensureUsers._name_.avatar_url": [ + "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.avatar_url" + ], + "blocks-lldap-options-shb.lldap.ensureUsers._name_.displayName": [ + "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.displayName" + ], + "blocks-lldap-options-shb.lldap.ensureUsers._name_.email": [ + "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.email" + ], + "blocks-lldap-options-shb.lldap.ensureUsers._name_.firstName": [ + "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.firstName" + ], + "blocks-lldap-options-shb.lldap.ensureUsers._name_.gravatar_avatar": [ + "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.gravatar_avatar" + ], + "blocks-lldap-options-shb.lldap.ensureUsers._name_.groups": [ + "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.groups" + ], + "blocks-lldap-options-shb.lldap.ensureUsers._name_.id": [ + "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.id" + ], + "blocks-lldap-options-shb.lldap.ensureUsers._name_.lastName": [ + "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.lastName" + ], + "blocks-lldap-options-shb.lldap.ensureUsers._name_.password": [ + "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.password" + ], + "blocks-lldap-options-shb.lldap.ensureUsers._name_.password.request": [ + "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.password.request" + ], + "blocks-lldap-options-shb.lldap.ensureUsers._name_.password.request.group": [ + "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.password.request.group" + ], + "blocks-lldap-options-shb.lldap.ensureUsers._name_.password.request.mode": [ + "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.password.request.mode" + ], + "blocks-lldap-options-shb.lldap.ensureUsers._name_.password.request.owner": [ + "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.password.request.owner" + ], + "blocks-lldap-options-shb.lldap.ensureUsers._name_.password.request.restartUnits": [ + "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.password.request.restartUnits" + ], + "blocks-lldap-options-shb.lldap.ensureUsers._name_.password.result": [ + "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.password.result" + ], + "blocks-lldap-options-shb.lldap.ensureUsers._name_.password.result.path": [ + "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.password.result.path" + ], + "blocks-lldap-options-shb.lldap.ensureUsers._name_.weser_avatar": [ + "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.weser_avatar" + ], "blocks-lldap-options-shb.lldap.jwtSecret": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.jwtSecret" ], diff --git a/flake.nix b/flake.nix index c8fabcd..5f0e527 100644 --- a/flake.nix +++ b/flake.nix @@ -15,6 +15,11 @@ let originPkgs = nixpkgs.legacyPackages.${system}; shbPatches = originPkgs.lib.optionals (system == "x86_64-linux") [ + # Get rid of lldap patches when https://github.com/NixOS/nixpkgs/pull/425923 is merged. + ./patches/0001-lldap-lldap-0.6.1-unstable-2025-07-16.patch + ./patches/0002-lldap-add-options-to-set-important-secrets.patch + ./patches/0003-lldap-bootstrap-init-unstable-2025-07-16-lldap-add-e.patch + # Leaving commented out as an example. # (originPkgs.fetchpatch { # url = "https://github.com/NixOS/nixpkgs/pull/317107.patch"; diff --git a/modules/blocks/lldap.nix b/modules/blocks/lldap.nix index b82dea7..57ed5fd 100644 --- a/modules/blocks/lldap.nix +++ b/modules/blocks/lldap.nix @@ -6,6 +6,46 @@ let contracts = pkgs.callPackage ../contracts {}; fqdn = "${cfg.subdomain}.${cfg.domain}"; + + inherit (lib) mkOption types; + + ensureFormat = pkgs.formats.json { }; + + 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; + }; + }; in { options.shb.lldap = { @@ -118,10 +158,155 @@ 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 = mkOption { + description = "Password."; + type = lib.types.submodule { + options = contracts.secret.mkRequester { + mode = "0440"; + owner = "lldap"; + group = "lldap"; + restartUnits = [ "lldap.service" ]; + }; + }; + }; + + 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; + } + ) + ); + }; }; config = lib.mkIf cfg.enable { + services.nginx = { enable = true; @@ -151,10 +336,12 @@ in services.lldap = { enable = true; - environment = { - LLDAP_JWT_SECRET_FILE = toString cfg.jwtSecret.result.path; - LLDAP_LDAP_USER_PASS_FILE = toString cfg.ldapUserPassword.result.path; + adminPasswordFile = toString cfg.ldapUserPassword.result.path; + jwtSecretFile = toString cfg.jwtSecret.result.path; + resetAdminPassword = "always"; + enforceEnsure = true; + environment = { RUST_LOG = lib.mkIf cfg.debug "debug"; }; @@ -170,6 +357,11 @@ in verbose = cfg.debug; }; + + inherit (cfg) ensureGroups ensureUserFields ensureGroupFields; + ensureUsers = lib.mapAttrs (n: v: (lib.removeAttrs v [ "password" ]) // { + "password_file" = v.password.result.path; + }) cfg.ensureUsers; }; }; } diff --git a/modules/blocks/lldap/docs/default.md b/modules/blocks/lldap/docs/default.md index ee67c07..3eb5ab6 100644 --- a/modules/blocks/lldap/docs/default.md +++ b/modules/blocks/lldap/docs/default.md @@ -53,14 +53,94 @@ by adding the following line: shb.lldap.restrictAccessIPRange = "192.168.50.0/24"; ``` -## Manage User and Group {#blocks-lldap-manage-user-group} +## Manage Groups {#blocks-lldap-manage-groups} -Currently, managing users and groups must be done manually. -Work is in progress to make this declarative. +The following snippet will create group named "family" if it does not exist yet. +Also, all other groups will be deleted and only the "family" group will remain. + +Note that the `lldap_admin`, `lldap_password_manager` and `lldap_strict_readonly` groups, which are internal to LLDAP, will always exist. + +```nix +{ + shb.lldap.ensureGroups = { + family = {}; + }; +} +``` + +Changing the configuration to the following will add a new group "friends": + +```nix +{ + shb.lldap.ensureGroups = { + family = {}; + friends = {}; + }; +} +``` + +Switching back the configuration to the previous one will delete the group "friends": + +```nix +{ + shb.lldap.ensureGroups = { + family = {}; + }; +} +``` + +Custom fields can be added to groups as long as they are added to the `ensureGroupFields` field: + +```nix +shb.lldap = { + ensureGroupFields = { + mygroupattribute = { + attributeType = "STRING"; + }; + }; + + ensureGroups = { + family = { + mygroupattribute = "Managed by NixOS"; + }; + }; +}; +``` + +## Manage Users {#blocks-lldap-manage-users} + +The following snippet creates a user and makes it a member of the "family" group. + +```nix +{ + shb.lldap.ensureUsers = { + dad = { + email = "dad@example.com"; + displayName = "Dad"; + firstName = "First Name"; + lastName = "Last Name"; + groups = [ "family" ]; + password.result = config.shb.sops.secret."dad".result; + }; + }; + + shb.sops.secret."dad".request = + shb.ldap.ensureUsers.dad.password.request; +} +``` + +The password field assumes usage of the [sops block][] to provide secrets +although any blocks providing the [secrets contract][] works too. + +[sops block]: blocks-sops.html +[secrets contract]: contracts-secrets.html + +The user is still editable through the UI. +That being said, any change will be overwritten next time the configuration is applied. ## Troubleshooting {#blocks-lldap-troubleshooting} -To increase logging verbosity, add: +To increase logging verbosity and see the trace of the GraphQL queries, add: ```nix shb.lldap.debug = true; @@ -69,6 +149,8 @@ shb.lldap.debug = true; Note that verbosity is truly verbose here so you will want to revert this at some point. +To see the logs, then run `journalctl -u lldap.service`. + ## Tests {#blocks-lldap-tests} Specific integration tests are defined in [`/test/blocks/lldap.nix`](@REPO@/test/blocks/lldap.nix). diff --git a/patches/0001-lldap-lldap-0.6.1-unstable-2025-07-16.patch b/patches/0001-lldap-lldap-0.6.1-unstable-2025-07-16.patch new file mode 100644 index 0000000..f6fcbca --- /dev/null +++ b/patches/0001-lldap-lldap-0.6.1-unstable-2025-07-16.patch @@ -0,0 +1,163 @@ +From 5bc3588204e71a77d7e0bfccc4cb70e6ccfbb54b Mon Sep 17 00:00:00 2001 +From: ibizaman +Date: Tue, 15 Jul 2025 18:42:42 +0200 +Subject: [PATCH 1/3] lldap: lldap 0.6.1 -> unstable-2025-07-16 + +--- + .../0001-parameterize-frontend-location.patch | 64 ------------------- + pkgs/by-name/ll/lldap/package.nix | 30 +++++---- + 2 files changed, 16 insertions(+), 78 deletions(-) + delete mode 100644 pkgs/by-name/ll/lldap/0001-parameterize-frontend-location.patch + +diff --git a/pkgs/by-name/ll/lldap/0001-parameterize-frontend-location.patch b/pkgs/by-name/ll/lldap/0001-parameterize-frontend-location.patch +deleted file mode 100644 +index c33f5a7afa10..000000000000 +--- a/pkgs/by-name/ll/lldap/0001-parameterize-frontend-location.patch ++++ /dev/null +@@ -1,64 +0,0 @@ +-From a09babb0cd9dd532ad2de920a2a35aa03d740dc6 Mon Sep 17 00:00:00 2001 +-From: Herwig Hochleitner +-Date: Thu, 8 Aug 2024 00:29:14 +0200 +-Subject: [PATCH] parameterize frontend location +- +---- +- server/src/infra/tcp_server.rs | 14 +++++++------- +- 1 file changed, 7 insertions(+), 7 deletions(-) +- +-diff --git a/server/src/infra/tcp_server.rs b/server/src/infra/tcp_server.rs +-index fa5f11f..16e64c5 100644 +---- a/server/src/infra/tcp_server.rs +-+++ b/server/src/infra/tcp_server.rs +-@@ -25,7 +25,7 @@ use std::sync::RwLock; +- use tracing::info; +- +- async fn index(data: web::Data>) -> actix_web::Result { +-- let mut file = std::fs::read_to_string(r"./app/index.html")?; +-+ let mut file = std::fs::read_to_string(r"@frontend@/index.html")?; +- +- if data.server_url.path() != "/" { +- file = file.replace( +-@@ -80,7 +80,7 @@ pub(crate) fn error_to_http_response(error: TcpError) -> HttpResponse { +- async fn main_js_handler( +- data: web::Data>, +- ) -> actix_web::Result { +-- let mut file = std::fs::read_to_string(r"./app/static/main.js")?; +-+ let mut file = std::fs::read_to_string(r"@frontend@/static/main.js")?; +- +- if data.server_url.path() != "/" { +- file = file.replace("/pkg/", format!("{}/pkg/", data.server_url.path()).as_str()); +-@@ -92,12 +92,12 @@ async fn main_js_handler( +- } +- +- async fn wasm_handler() -> actix_web::Result { +-- Ok(actix_files::NamedFile::open_async("./app/pkg/lldap_app_bg.wasm").await?) +-+ Ok(actix_files::NamedFile::open_async("@frontend@/pkg/lldap_app_bg.wasm").await?) +- } +- +- async fn wasm_handler_compressed() -> actix_web::Result { +- Ok( +-- actix_files::NamedFile::open_async("./app/pkg/lldap_app_bg.wasm.gz") +-+ actix_files::NamedFile::open_async("@frontend@/pkg/lldap_app_bg.wasm.gz") +- .await? +- .customize() +- .insert_header(header::ContentEncoding::Gzip) +-@@ -143,11 +143,11 @@ fn http_config( +- .service(web::resource("/pkg/lldap_app_bg.wasm").route(web::route().to(wasm_handler))) +- .service(web::resource("/static/main.js").route(web::route().to(main_js_handler::))) +- // Serve the /pkg path with the compiled WASM app. +-- .service(Files::new("/pkg", "./app/pkg")) +-+ .service(Files::new("/pkg", "@frontend@/pkg")) +- // Serve static files +-- .service(Files::new("/static", "./app/static")) +-+ .service(Files::new("/static", "@frontend@/static")) +- // Serve static fonts +-- .service(Files::new("/static/fonts", "./app/static/fonts")) +-+ .service(Files::new("/static/fonts", "@frontend@/static/fonts")) +- // Default to serve index.html for unknown routes, to support routing. +- .default_service(web::route().guard(guard::Get()).to(index::)); +- } +--- +-2.45.2 +- +diff --git a/pkgs/by-name/ll/lldap/package.nix b/pkgs/by-name/ll/lldap/package.nix +index 6931256080b2..41e4a332018e 100644 +--- a/pkgs/by-name/ll/lldap/package.nix ++++ b/pkgs/by-name/ll/lldap/package.nix +@@ -3,29 +3,30 @@ + fetchFromGitHub, + lib, + lldap, ++ makeWrapper, + nixosTests, + rustPlatform, + rustc, +- wasm-bindgen-cli_0_2_95, ++ wasm-bindgen-cli_0_2_100, + wasm-pack, + which, + }: + + let + +- commonDerivationAttrs = rec { ++ commonDerivationAttrs = { + pname = "lldap"; +- version = "0.6.1"; ++ version = "unstable-2025-07-16"; + + src = fetchFromGitHub { + owner = "lldap"; + repo = "lldap"; +- rev = "v${version}"; +- hash = "sha256-iQ+Vv9kx/pWHoa/WZChBK+FD2r1avzWWz57bnnzRjUg="; ++ rev = "78337bce722c3573d9fc6eafe345a3dbce4b9119"; ++ hash = "sha256-/djLboAQwK/KQ0u9vzoOdDHwh/BQSvMa8lQkABn10Cw="; + }; + + useFetchCargoVendor = true; +- cargoHash = "sha256-qXYgr9uRswuo9hwVROUX9KUKpkzR0VEcXImbdyOgxsY="; ++ cargoHash = "sha256-/dyrtX2FUHSGkJ6AkCM81iPqI03IWA0tecR4KHSx8gA="; + + }; + +@@ -36,7 +37,7 @@ let + + nativeBuildInputs = [ + wasm-pack +- wasm-bindgen-cli_0_2_95 ++ wasm-bindgen-cli_0_2_100 + binaryen + which + rustc +@@ -69,12 +70,10 @@ rustPlatform.buildRustPackage ( + "lldap_set_password" + ]; + +- patches = [ +- ./0001-parameterize-frontend-location.patch +- ]; +- +- postPatch = '' +- substituteInPlace server/src/infra/tcp_server.rs --subst-var-by frontend '${frontend}' ++ nativeBuildInputs = [ makeWrapper ]; ++ postInstall = '' ++ wrapProgram $out/bin/lldap \ ++ --set LLDAP_ASSETS_PATH ${frontend} + ''; + + passthru = { +@@ -90,7 +89,10 @@ rustPlatform.buildRustPackage ( + changelog = "https://github.com/lldap/lldap/blob/v${lldap.version}/CHANGELOG.md"; + license = licenses.gpl3Only; + platforms = platforms.linux; +- maintainers = with maintainers; [ bendlas ]; ++ maintainers = with maintainers; [ ++ bendlas ++ ibizaman ++ ]; + mainProgram = "lldap"; + }; + } +-- +2.49.0 + diff --git a/patches/0002-lldap-add-options-to-set-important-secrets.patch b/patches/0002-lldap-add-options-to-set-important-secrets.patch new file mode 100644 index 0000000..7c1d48d --- /dev/null +++ b/patches/0002-lldap-add-options-to-set-important-secrets.patch @@ -0,0 +1,133 @@ +From f898e786bfb07399abf4e8173ee525c57eb41984 Mon Sep 17 00:00:00 2001 +From: ibizaman +Date: Wed, 16 Jul 2025 03:04:44 +0200 +Subject: [PATCH 2/3] lldap: add options to set important secrets + +--- + nixos/modules/services/databases/lldap.nix | 64 ++++++++++++++++++++++ + nixos/tests/lldap.nix | 16 +++++- + 2 files changed, 77 insertions(+), 3 deletions(-) + +diff --git a/nixos/modules/services/databases/lldap.nix b/nixos/modules/services/databases/lldap.nix +index a9fbe8f7e11a..518b39ba7a86 100644 +--- a/nixos/modules/services/databases/lldap.nix ++++ b/nixos/modules/services/databases/lldap.nix +@@ -37,6 +37,47 @@ in + ''; + }; + ++ adminPasswordFile = mkOption { ++ type = types.nullOr types.str; ++ default = null; ++ description = '' ++ Path to a file containing the default admin password. ++ ''; ++ }; ++ ++ resetAdminPassword = mkOption { ++ type = types.nullOr ( ++ types.oneOf [ ++ types.bool ++ (types.enum [ "always" ]) ++ ] ++ ); ++ default = false; ++ description = '' ++ Force reset of the admin password. ++ ++ Break glass in case of emergency: if you lost the admin password, you ++ can set this to true to force a reset of the admin password to the value ++ of `adminPasswordFile`. ++ ++ Alternatively, you can set it to `"always"` to reset every time the server starts ++ which makes for a more declarative configuration. ++ ++ The difference between `true` and `"always"` is the former is intended for a one time fix ++ while the latter is intended for a declarative workflow. In practice, the result ++ is the same: the password gets reset. The only practical difference is the former ++ outputs a warning message while the latter outputs an info message. ++ ''; ++ }; ++ ++ jwtSecretFile = mkOption { ++ type = types.nullOr types.str; ++ default = null; ++ description = '' ++ Path to a file containing the default admin password. ++ ''; ++ }; ++ + settings = mkOption { + description = '' + Free-form settings written directly to the `lldap_config.toml` file. +@@ -108,6 +149,29 @@ in + }; + + config = lib.mkIf cfg.enable { ++ assertions = [ ++ { ++ assertion = cfg.adminPasswordFile == null || cfg.resetAdminPassword != false; ++ message = '' ++ The default admin password is set declaratively with `adminPasswordFile` option but the `resetAdminPassword` is set to `false`. ++ This means the admin password can be changed through the UI and will drift from the one defined in your nix config. ++ Please set the `resetAdminPassword` option to `true` or `"always"`. ++ ''; ++ } ++ ]; ++ ++ 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; ++ LLDAP_FORCE_LDAP_USER_PASS_RESET = ++ if builtins.isString cfg.resetAdminPassword then ++ cfg.resetAdminPassword ++ else if cfg.resetAdminPassword then ++ "true" ++ else ++ "false"; ++ }; ++ + systemd.services.lldap = { + description = "Lightweight LDAP server (lldap)"; + wants = [ "network-online.target" ]; +diff --git a/nixos/tests/lldap.nix b/nixos/tests/lldap.nix +index c2e48525a5f3..e88fa37ab83d 100644 +--- a/nixos/tests/lldap.nix ++++ b/nixos/tests/lldap.nix +@@ -1,4 +1,7 @@ + { ... }: ++let ++ adminPassword = "mySecretPassword"; ++in + { + name = "lldap"; + +@@ -7,6 +10,11 @@ + { + services.lldap = { + enable = true; ++ ++ adminPasswordFile = toString (pkgs.writeText "adminPasswordFile" adminPassword); ++ resetAdminPassword = "always"; ++ enforceEnsure = true; ++ + settings = { + verbose = true; + ldap_base_dn = "dc=example,dc=com"; +@@ -22,8 +30,10 @@ + + machine.succeed("curl --location --fail http://localhost:17170/") + +- print( +- machine.succeed('ldapsearch -H ldap://localhost:3890 -D uid=admin,ou=people,dc=example,dc=com -b "ou=people,dc=example,dc=com" -w password') +- ) ++ 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) ++ ++ 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) + ''; + } +-- +2.49.0 + diff --git a/patches/0003-lldap-bootstrap-init-unstable-2025-07-16-lldap-add-e.patch b/patches/0003-lldap-bootstrap-init-unstable-2025-07-16-lldap-add-e.patch new file mode 100644 index 0000000..ede2b49 --- /dev/null +++ b/patches/0003-lldap-bootstrap-init-unstable-2025-07-16-lldap-add-e.patch @@ -0,0 +1,549 @@ +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 + diff --git a/test/blocks/lldap.nix b/test/blocks/lldap.nix index 55d29cf..e988f82 100644 --- a/test/blocks/lldap.nix +++ b/test/blocks/lldap.nix @@ -3,6 +3,7 @@ let pkgs' = pkgs; password = "securepassword"; + charliePassword = "CharliePassword"; in { auth = pkgs.testers.runNixOSTest { @@ -29,6 +30,17 @@ in ldapUserPassword.result = config.shb.hardcodedsecret.ldapUserPassword.result; jwtSecret.result = config.shb.hardcodedsecret.jwtSecret.result; debug = true; + + ensureUsers = { + "charlie" = { + email = "charlie@example.com"; + password.result = config.shb.hardcodedsecret."charlie".result; + }; + }; + + ensureGroups = { + "family" = {}; + }; }; shb.hardcodedsecret.ldapUserPassword = { request = config.shb.lldap.ldapUserPassword.request; @@ -38,6 +50,10 @@ in request = config.shb.lldap.jwtSecret.request; settings.content = "jwtSecret"; }; + shb.hardcodedsecret."charlie" = { + request = config.shb.lldap.ensureUsers."charlie".password.request; + settings.content = charliePassword; + }; networking.firewall.allowedTCPPorts = [ 80 ]; # nginx port }; @@ -89,6 +105,15 @@ in assert data['user']['displayName'] == "Administrator" assert data['user']['groups'][0]['displayName'] == "lldap_admin" + + with subtest("succeed charlie"): + client.succeed( + "curl -f -s -X POST " + + """ -H "Content-type: application/json" """ + + """ -H "Host: ldap.example.com" """ + + " http://server/auth/simple/login " + + """ -d '{"username": "charlie", "password": "${charliePassword}"}' """ + ) ''; }; }