diff --git a/nixos/doc/manual/release-notes/rl-2205.section.md b/nixos/doc/manual/release-notes/rl-2205.section.md
index 90d22643701..24fbc537dca 100644
--- a/nixos/doc/manual/release-notes/rl-2205.section.md
+++ b/nixos/doc/manual/release-notes/rl-2205.section.md
@@ -135,6 +135,8 @@ In addition to numerous new and upgraded packages, this release has the followin
- [nifi](https://nifi.apache.org), an easy to use, powerful, and reliable system to process and distribute data. Available as [services.nifi](options.html#opt-services.nifi.enable).
+- [kanidm](https://kanidm.github.io/kanidm/stable/), an identity management server written in Rust.
+
## Backward Incompatibilities {#sec-release-22.05-incompatibilities}
diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix
index 9d9f2e9057c..0ccf97234ff 100644
--- a/nixos/modules/module-list.nix
+++ b/nixos/modules/module-list.nix
@@ -975,6 +975,7 @@
./services/security/hockeypuck.nix
./services/security/hologram-server.nix
./services/security/hologram-agent.nix
+ ./services/security/kanidm.nix
./services/security/munge.nix
./services/security/nginx-sso.nix
./services/security/oauth2_proxy.nix
diff --git a/nixos/modules/services/security/kanidm.nix b/nixos/modules/services/security/kanidm.nix
new file mode 100644
index 00000000000..a7c51b9a877
--- /dev/null
+++ b/nixos/modules/services/security/kanidm.nix
@@ -0,0 +1,345 @@
+{ config, lib, options, pkgs, ... }:
+let
+ cfg = config.services.kanidm;
+ settingsFormat = pkgs.formats.toml { };
+ # Remove null values, so we can document optional values that don't end up in the generated TOML file.
+ filterConfig = lib.converge (lib.filterAttrsRecursive (_: v: v != null));
+ serverConfigFile = settingsFormat.generate "server.toml" (filterConfig cfg.serverSettings);
+ clientConfigFile = settingsFormat.generate "kanidm-config.toml" (filterConfig cfg.clientSettings);
+ unixConfigFile = settingsFormat.generate "kanidm-unixd.toml" (filterConfig cfg.unixSettings);
+
+ defaultServiceConfig = {
+ BindReadOnlyPaths = [
+ "/nix/store"
+ "-/etc/resolv.conf"
+ "-/etc/nsswitch.conf"
+ "-/etc/hosts"
+ "-/etc/localtime"
+ ];
+ CapabilityBoundingSet = "";
+ # ProtectClock= adds DeviceAllow=char-rtc r
+ DeviceAllow = "";
+ # Implies ProtectSystem=strict, which re-mounts all paths
+ # DynamicUser = true;
+ LockPersonality = true;
+ MemoryDenyWriteExecute = true;
+ NoNewPrivileges = true;
+ PrivateDevices = true;
+ PrivateMounts = true;
+ PrivateNetwork = true;
+ PrivateTmp = true;
+ PrivateUsers = true;
+ ProcSubset = "pid";
+ ProtectClock = true;
+ ProtectHome = true;
+ ProtectHostname = true;
+ # Would re-mount paths ignored by temporary root
+ #ProtectSystem = "strict";
+ ProtectControlGroups = true;
+ ProtectKernelLogs = true;
+ ProtectKernelModules = true;
+ ProtectKernelTunables = true;
+ ProtectProc = "invisible";
+ RestrictAddressFamilies = [ ];
+ RestrictNamespaces = true;
+ RestrictRealtime = true;
+ RestrictSUIDSGID = true;
+ SystemCallArchitectures = "native";
+ SystemCallFilter = [ "@system-service" "~@privileged @resources @setuid @keyring" ];
+ # Does not work well with the temporary root
+ #UMask = "0066";
+ };
+
+in
+{
+ options.services.kanidm = {
+ enableClient = lib.mkEnableOption "the Kanidm client";
+ enableServer = lib.mkEnableOption "the Kanidm server";
+ enablePam = lib.mkEnableOption "the Kanidm PAM and NSS integration.";
+
+ serverSettings = lib.mkOption {
+ type = lib.types.submodule {
+ freeformType = settingsFormat.type;
+
+ options = {
+ bindaddress = lib.mkOption {
+ description = "Address/port combination the webserver binds to.";
+ example = "[::1]:8443";
+ type = lib.types.str;
+ };
+ # Should be optional but toml does not accept null
+ ldapbindaddress = lib.mkOption {
+ description = ''
+ Address and port the LDAP server is bound to. Setting this to null disables the LDAP interface.
+ '';
+ example = "[::1]:636";
+ default = null;
+ type = lib.types.nullOr lib.types.str;
+ };
+ origin = lib.mkOption {
+ description = "The origin of your Kanidm instance. Must have https as protocol.";
+ example = "https://idm.example.org";
+ type = lib.types.strMatching "^https://.*";
+ };
+ domain = lib.mkOption {
+ description = ''
+ The domain that Kanidm manages. Must be below or equal to the domain
+ specified in serverSettings.origin.
+ This can be left at null, only if your instance has the role ReadOnlyReplica.
+ While it is possible to change the domain later on, it requires extra steps!
+ Please consider the warnings and execute the steps described
+ in the documentation.
+ '';
+ example = "example.org";
+ default = null;
+ type = lib.types.nullOr lib.types.str;
+ };
+ db_path = lib.mkOption {
+ description = "Path to Kanidm database.";
+ default = "/var/lib/kanidm/kanidm.db";
+ readOnly = true;
+ type = lib.types.path;
+ };
+ log_level = lib.mkOption {
+ description = "Log level of the server.";
+ default = "default";
+ type = lib.types.enum [ "default" "verbose" "perfbasic" "perffull" ];
+ };
+ role = lib.mkOption {
+ description = "The role of this server. This affects the replication relationship and thereby available features.";
+ default = "WriteReplica";
+ type = lib.types.enum [ "WriteReplica" "WriteReplicaNoUI" "ReadOnlyReplica" ];
+ };
+ };
+ };
+ default = { };
+ description = ''
+ Settings for Kanidm, see
+ the documentation
+ and example configuration
+ for possible values.
+ '';
+ };
+
+ clientSettings = lib.mkOption {
+ type = lib.types.submodule {
+ freeformType = settingsFormat.type;
+
+ options.uri = lib.mkOption {
+ description = "Address of the Kanidm server.";
+ example = "http://127.0.0.1:8080";
+ type = lib.types.str;
+ };
+ };
+ description = ''
+ Configure Kanidm clients, needed for the PAM daemon. See
+ the documentation
+ and example configuration
+ for possible values.
+ '';
+ };
+
+ unixSettings = lib.mkOption {
+ type = lib.types.submodule {
+ freeformType = settingsFormat.type;
+
+ options.pam_allowed_login_groups = lib.mkOption {
+ description = "Kanidm groups that are allowed to login using PAM.";
+ example = "my_pam_group";
+ type = lib.types.listOf lib.types.str;
+ };
+ };
+ description = ''
+ Configure Kanidm unix daemon.
+ See the documentation
+ and example configuration
+ for possible values.
+ '';
+ };
+ };
+
+ config = lib.mkIf (cfg.enableClient || cfg.enableServer || cfg.enablePam) {
+ assertions =
+ [
+ {
+ assertion = !cfg.enableServer || ((cfg.serverSettings.tls_chain or null) == null) || (!lib.isStorePath cfg.serverSettings.tls_chain);
+ message = ''
+ points to
+ a file in the Nix store. You should use a quoted absolute path to
+ prevent this.
+ '';
+ }
+ {
+ assertion = !cfg.enableServer || ((cfg.serverSettings.tls_key or null) == null) || (!lib.isStorePath cfg.serverSettings.tls_key);
+ message = ''
+ points to
+ a file in the Nix store. You should use a quoted absolute path to
+ prevent this.
+ '';
+ }
+ {
+ assertion = !cfg.enableClient || options.services.kanidm.clientSettings.isDefined;
+ message = ''
+ needs to be configured
+ if the client is enabled.
+ '';
+ }
+ {
+ assertion = !cfg.enablePam || options.services.kanidm.clientSettings.isDefined;
+ message = ''
+ needs to be configured
+ for the PAM daemon to connect to the Kanidm server.
+ '';
+ }
+ {
+ assertion = !cfg.enableServer || (cfg.serverSettings.domain == null
+ -> cfg.serverSettings.role == "WriteReplica" || cfg.serverSettings.role == "WriteReplicaNoUI");
+ message = ''
+ can only be set if this instance
+ is not a ReadOnlyReplica. Otherwise the db would inherit it from
+ the instance it follows.
+ '';
+ }
+ ];
+
+ environment.systemPackages = lib.mkIf cfg.enableClient [ pkgs.kanidm ];
+
+ systemd.services.kanidm = lib.mkIf cfg.enableServer {
+ description = "kanidm identity management daemon";
+ wantedBy = [ "multi-user.target" ];
+ after = [ "network.target" ];
+ serviceConfig = defaultServiceConfig // {
+ StateDirectory = "kanidm";
+ StateDirectoryMode = "0700";
+ ExecStart = "${pkgs.kanidm}/bin/kanidmd server -c ${serverConfigFile}";
+ User = "kanidm";
+ Group = "kanidm";
+
+ AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ];
+ CapabilityBoundingSet = [ "CAP_NET_BIND_SERVICE" ];
+ # This would otherwise override the CAP_NET_BIND_SERVICE capability.
+ PrivateUsers = false;
+ # Port needs to be exposed to the host network
+ PrivateNetwork = false;
+ RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ];
+ TemporaryFileSystem = "/:ro";
+ };
+ environment.RUST_LOG = "info";
+ };
+
+ systemd.services.kanidm-unixd = lib.mkIf cfg.enablePam {
+ description = "Kanidm PAM daemon";
+ wantedBy = [ "multi-user.target" ];
+ after = [ "network.target" ];
+ restartTriggers = [ unixConfigFile clientConfigFile ];
+ serviceConfig = defaultServiceConfig // {
+ CacheDirectory = "kanidm-unixd";
+ CacheDirectoryMode = "0700";
+ RuntimeDirectory = "kanidm-unixd";
+ ExecStart = "${pkgs.kanidm}/bin/kanidm_unixd";
+ User = "kanidm-unixd";
+ Group = "kanidm-unixd";
+
+ BindReadOnlyPaths = [
+ "/nix/store"
+ "-/etc/resolv.conf"
+ "-/etc/nsswitch.conf"
+ "-/etc/hosts"
+ "-/etc/localtime"
+ "-/etc/kanidm"
+ "-/etc/static/kanidm"
+ ];
+ BindPaths = [
+ # To create the socket
+ "/run/kanidm-unixd:/var/run/kanidm-unixd"
+ ];
+ # Needs to connect to kanidmd
+ PrivateNetwork = false;
+ RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ];
+ TemporaryFileSystem = "/:ro";
+ };
+ environment.RUST_LOG = "info";
+ };
+
+ systemd.services.kanidm-unixd-tasks = lib.mkIf cfg.enablePam {
+ description = "Kanidm PAM home management daemon";
+ wantedBy = [ "multi-user.target" ];
+ after = [ "network.target" "kanidm-unixd.service" ];
+ partOf = [ "kanidm-unixd.service" ];
+ restartTriggers = [ unixConfigFile clientConfigFile ];
+ serviceConfig = {
+ ExecStart = "${pkgs.kanidm}/bin/kanidm_unixd_tasks";
+
+ BindReadOnlyPaths = [
+ "/nix/store"
+ "-/etc/resolv.conf"
+ "-/etc/nsswitch.conf"
+ "-/etc/hosts"
+ "-/etc/localtime"
+ "-/etc/kanidm"
+ "-/etc/static/kanidm"
+ ];
+ BindPaths = [
+ # To manage home directories
+ "/home"
+ # To connect to kanidm-unixd
+ "/run/kanidm-unixd:/var/run/kanidm-unixd"
+ ];
+ # CAP_DAC_OVERRIDE is needed to ignore ownership of unixd socket
+ CapabilityBoundingSet = [ "CAP_CHOWN" "CAP_FOWNER" "CAP_DAC_OVERRIDE" "CAP_DAC_READ_SEARCH" ];
+ IPAddressDeny = "any";
+ # Need access to users
+ PrivateUsers = false;
+ # Need access to home directories
+ ProtectHome = false;
+ RestrictAddressFamilies = [ "AF_UNIX" ];
+ TemporaryFileSystem = "/:ro";
+ };
+ environment.RUST_LOG = "info";
+ };
+
+ # These paths are hardcoded
+ environment.etc = lib.mkMerge [
+ (lib.mkIf options.services.kanidm.clientSettings.isDefined {
+ "kanidm/config".source = clientConfigFile;
+ })
+ (lib.mkIf cfg.enablePam {
+ "kanidm/unixd".source = unixConfigFile;
+ })
+ ];
+
+ system.nssModules = lib.mkIf cfg.enablePam [ pkgs.kanidm ];
+
+ system.nssDatabases.group = lib.optional cfg.enablePam "kanidm";
+ system.nssDatabases.passwd = lib.optional cfg.enablePam "kanidm";
+
+ users.groups = lib.mkMerge [
+ (lib.mkIf cfg.enableServer {
+ kanidm = { };
+ })
+ (lib.mkIf cfg.enablePam {
+ kanidm-unixd = { };
+ })
+ ];
+ users.users = lib.mkMerge [
+ (lib.mkIf cfg.enableServer {
+ kanidm = {
+ description = "Kanidm server";
+ isSystemUser = true;
+ group = "kanidm";
+ packages = with pkgs; [ kanidm ];
+ };
+ })
+ (lib.mkIf cfg.enablePam {
+ kanidm-unixd = {
+ description = "Kanidm PAM daemon";
+ isSystemUser = true;
+ group = "kanidm-unixd";
+ };
+ })
+ ];
+ };
+
+ meta.maintainers = with lib.maintainers; [ erictapen Flakebi ];
+ meta.buildDocsInSandbox = false;
+}
diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix
index dda1c41f969..0c085b64efa 100644
--- a/nixos/tests/all-tests.nix
+++ b/nixos/tests/all-tests.nix
@@ -253,6 +253,7 @@ in
k3s-single-node = handleTest ./k3s-single-node.nix {};
k3s-single-node-docker = handleTest ./k3s-single-node-docker.nix {};
kafka = handleTest ./kafka.nix {};
+ kanidm = handleTest ./kanidm.nix {};
kbd-setfont-decompress = handleTest ./kbd-setfont-decompress.nix {};
kbd-update-search-paths-patch = handleTest ./kbd-update-search-paths-patch.nix {};
kea = handleTest ./kea.nix {};
diff --git a/nixos/tests/kanidm.nix b/nixos/tests/kanidm.nix
new file mode 100644
index 00000000000..d34f680f522
--- /dev/null
+++ b/nixos/tests/kanidm.nix
@@ -0,0 +1,75 @@
+import ./make-test-python.nix ({ pkgs, ... }:
+ let
+ certs = import ./common/acme/server/snakeoil-certs.nix;
+ serverDomain = certs.domain;
+ in
+ {
+ name = "kanidm";
+ meta.maintainers = with pkgs.lib.maintainers; [ erictapen Flakebi ];
+
+ nodes.server = { config, pkgs, lib, ... }: {
+ services.kanidm = {
+ enableServer = true;
+ serverSettings = {
+ origin = "https://${serverDomain}";
+ domain = serverDomain;
+ bindaddress = "[::1]:8443";
+ ldapbindaddress = "[::1]:636";
+ };
+ };
+
+ services.nginx = {
+ enable = true;
+ recommendedProxySettings = true;
+ virtualHosts."${serverDomain}" = {
+ forceSSL = true;
+ sslCertificate = certs."${serverDomain}".cert;
+ sslCertificateKey = certs."${serverDomain}".key;
+ locations."/".proxyPass = "http://[::1]:8443";
+ };
+ };
+
+ security.pki.certificateFiles = [ certs.ca.cert ];
+
+ networking.hosts."::1" = [ serverDomain ];
+ networking.firewall.allowedTCPPorts = [ 80 443 ];
+
+ users.users.kanidm.shell = pkgs.bashInteractive;
+
+ environment.systemPackages = with pkgs; [ kanidm openldap ripgrep ];
+ };
+
+ nodes.client = { pkgs, nodes, ... }: {
+ services.kanidm = {
+ enableClient = true;
+ clientSettings = {
+ uri = "https://${serverDomain}";
+ };
+ };
+
+ networking.hosts."${nodes.server.config.networking.primaryIPAddress}" = [ serverDomain ];
+
+ security.pki.certificateFiles = [ certs.ca.cert ];
+ };
+
+ testScript = { nodes, ... }:
+ let
+ ldapBaseDN = builtins.concatStringsSep "," (map (s: "dc=" + s) (pkgs.lib.splitString "." serverDomain));
+
+ # We need access to the config file in the test script.
+ filteredConfig = pkgs.lib.converge
+ (pkgs.lib.filterAttrsRecursive (_: v: v != null))
+ nodes.server.config.services.kanidm.serverSettings;
+ serverConfigFile = (pkgs.formats.toml { }).generate "server.toml" filteredConfig;
+
+ in
+ ''
+ start_all()
+ server.wait_for_unit("kanidm.service")
+ server.wait_until_succeeds("curl -sf https://${serverDomain} | grep Kanidm")
+ server.wait_until_succeeds("ldapsearch -H ldap://[::1]:636 -b '${ldapBaseDN}' -x '(name=test)'")
+ client.wait_until_succeeds("kanidm login -D anonymous && kanidm self whoami | grep anonymous@${serverDomain}")
+ (rv, result) = server.execute("kanidmd recover_account -d quiet -c ${serverConfigFile} -n admin 2>&1 | rg -o '[A-Za-z0-9]{48}'")
+ assert rv == 0
+ '';
+ })