1{
2 config,
3 lib,
4 options,
5 pkgs,
6 ...
7}:
8let
9 inherit (lib)
10 any
11 attrNames
12 attrValues
13 concatLines
14 concatLists
15 converge
16 filter
17 filterAttrs
18 filterAttrsRecursive
19 flip
20 foldl'
21 getExe
22 hasInfix
23 hasPrefix
24 isStorePath
25 last
26 mapAttrsToList
27 mkEnableOption
28 mkForce
29 mkIf
30 mkMerge
31 mkOption
32 mkPackageOption
33 optional
34 optionals
35 optionalString
36 splitString
37 subtractLists
38 types
39 unique
40 ;
41
42 cfg = config.services.kanidm;
43 settingsFormat = pkgs.formats.toml { };
44 # Remove null values, so we can document optional values that don't end up in the generated TOML file.
45 filterConfig = converge (filterAttrsRecursive (_: v: v != null));
46 serverConfigFile = settingsFormat.generate "server.toml" (filterConfig cfg.serverSettings);
47 clientConfigFile = settingsFormat.generate "kanidm-config.toml" (filterConfig cfg.clientSettings);
48 unixConfigFile = settingsFormat.generate "kanidm-unixd.toml" (filterConfig cfg.unixSettings);
49 provisionSecretFiles = filter (x: x != null) (
50 [
51 cfg.provision.idmAdminPasswordFile
52 cfg.provision.adminPasswordFile
53 ]
54 ++ optional (cfg.provision.extraJsonFile != null) cfg.provision.extraJsonFile
55 ++ mapAttrsToList (_: x: x.basicSecretFile) cfg.provision.systems.oauth2
56 );
57 secretPaths = [
58 cfg.serverSettings.tls_chain
59 cfg.serverSettings.tls_key
60 ]
61 ++ optionals cfg.provision.enable provisionSecretFiles;
62
63 # Merge bind mount paths and remove paths where a prefix is already mounted.
64 # This makes sure that if e.g. the tls_chain is in the nix store and /nix/store is already in the mount
65 # paths, no new bind mount is added. Adding subpaths caused problems on ofborg.
66 hasPrefixInList =
67 list: newPath: any (path: hasPrefix (builtins.toString path) (builtins.toString newPath)) list;
68 mergePaths = foldl' (
69 merged: newPath:
70 let
71 # If the new path is a prefix to some existing path, we need to filter it out
72 filteredPaths = filter (p: !hasPrefix (builtins.toString newPath) (builtins.toString p)) merged;
73 # If a prefix of the new path is already in the list, do not add it
74 filteredNew = optional (!hasPrefixInList filteredPaths newPath) newPath;
75 in
76 filteredPaths ++ filteredNew
77 ) [ ];
78
79 defaultServiceConfig = {
80 # Setting the type to notify enables additional healthchecks, ensuring units
81 # after and requiring kanidm-* wait for it to complete startup
82 Type = "notify";
83 BindReadOnlyPaths = [
84 "/nix/store"
85 # For healthcheck notifications
86 "/run/systemd/notify"
87 "-/etc/resolv.conf"
88 "-/etc/nsswitch.conf"
89 "-/etc/hosts"
90 "-/etc/localtime"
91 ];
92 CapabilityBoundingSet = [ ];
93 # ProtectClock= adds DeviceAllow=char-rtc r
94 DeviceAllow = "";
95 # Implies ProtectSystem=strict, which re-mounts all paths
96 # DynamicUser = true;
97 LockPersonality = true;
98 MemoryDenyWriteExecute = true;
99 NoNewPrivileges = true;
100 PrivateDevices = true;
101 PrivateMounts = true;
102 PrivateNetwork = true;
103 PrivateTmp = true;
104 PrivateUsers = true;
105 ProcSubset = "pid";
106 ProtectClock = true;
107 ProtectHome = true;
108 ProtectHostname = true;
109 # Would re-mount paths ignored by temporary root
110 #ProtectSystem = "strict";
111 ProtectControlGroups = true;
112 ProtectKernelLogs = true;
113 ProtectKernelModules = true;
114 ProtectKernelTunables = true;
115 ProtectProc = "invisible";
116 RestrictAddressFamilies = [ ];
117 RestrictNamespaces = true;
118 RestrictRealtime = true;
119 RestrictSUIDSGID = true;
120 SystemCallArchitectures = "native";
121 SystemCallFilter = [
122 "@system-service"
123 "~@privileged @resources @setuid @keyring"
124 ];
125 # Does not work well with the temporary root
126 #UMask = "0066";
127 };
128
129 mkPresentOption =
130 what:
131 mkOption {
132 description = "Whether to ensure that this ${what} is present or absent.";
133 type = types.bool;
134 default = true;
135 };
136
137 filterPresent = filterAttrs (_: v: v.present);
138
139 provisionStateJson = pkgs.writeText "provision-state.json" (
140 builtins.toJSON { inherit (cfg.provision) groups persons systems; }
141 );
142
143 # Only recover the admin account if a password should explicitly be provisioned
144 # for the account. Otherwise it is not needed for provisioning.
145 maybeRecoverAdmin = optionalString (cfg.provision.adminPasswordFile != null) ''
146 KANIDM_ADMIN_PASSWORD=$(< ${cfg.provision.adminPasswordFile})
147 # We always reset the admin account password if a desired password was specified.
148 if ! KANIDM_RECOVER_ACCOUNT_PASSWORD=$KANIDM_ADMIN_PASSWORD ${cfg.package}/bin/kanidmd recover-account -c ${serverConfigFile} admin --from-environment >/dev/null; then
149 echo "Failed to recover admin account" >&2
150 exit 1
151 fi
152 '';
153
154 # Recover the idm_admin account. If a password should explicitly be provisioned
155 # for the account we set it, otherwise we generate a new one because it is required
156 # for provisioning.
157 recoverIdmAdmin =
158 if cfg.provision.idmAdminPasswordFile != null then
159 ''
160 KANIDM_IDM_ADMIN_PASSWORD=$(< ${cfg.provision.idmAdminPasswordFile})
161 # We always reset the idm_admin account password if a desired password was specified.
162 if ! KANIDM_RECOVER_ACCOUNT_PASSWORD=$KANIDM_IDM_ADMIN_PASSWORD ${cfg.package}/bin/kanidmd recover-account -c ${serverConfigFile} idm_admin --from-environment >/dev/null; then
163 echo "Failed to recover idm_admin account" >&2
164 exit 1
165 fi
166 ''
167 else
168 ''
169 # Recover idm_admin account
170 if ! recover_out=$(${cfg.package}/bin/kanidmd recover-account -c ${serverConfigFile} idm_admin -o json); then
171 echo "$recover_out" >&2
172 echo "kanidm provision: Failed to recover admin account" >&2
173 exit 1
174 fi
175 if ! KANIDM_IDM_ADMIN_PASSWORD=$(grep '{"password' <<< "$recover_out" | ${getExe pkgs.jq} -r .password); then
176 echo "$recover_out" >&2
177 echo "kanidm provision: Failed to parse password for idm_admin account" >&2
178 exit 1
179 fi
180 '';
181
182 finalJson =
183 if cfg.provision.extraJsonFile != null then
184 ''
185 <(${lib.getExe pkgs.yq-go} '. *+ load("${cfg.provision.extraJsonFile}") | (.. | select(type == "!!seq")) |= unique' ${provisionStateJson})
186 ''
187 else
188 provisionStateJson;
189
190 postStartScript = pkgs.writeShellScript "post-start" ''
191 set -euo pipefail
192
193 # Wait for the kanidm server to come online
194 count=0
195 while ! ${getExe pkgs.curl} -L --silent --max-time 1 --connect-timeout 1 --fail \
196 ${optionalString cfg.provision.acceptInvalidCerts "--insecure"} \
197 ${cfg.provision.instanceUrl} >/dev/null
198 do
199 sleep 1
200 if [[ "$count" -eq 30 ]]; then
201 echo "Tried for at least 30 seconds, giving up..."
202 exit 1
203 fi
204 count=$((++count))
205 done
206
207 ${recoverIdmAdmin}
208 ${maybeRecoverAdmin}
209
210 KANIDM_PROVISION_IDM_ADMIN_TOKEN=$KANIDM_IDM_ADMIN_PASSWORD \
211 ${getExe pkgs.kanidm-provision} \
212 ${optionalString (!cfg.provision.autoRemove) "--no-auto-remove"} \
213 ${optionalString cfg.provision.acceptInvalidCerts "--accept-invalid-certs"} \
214 --url "${cfg.provision.instanceUrl}" \
215 --state ${finalJson}
216 '';
217
218 serverPort =
219 let
220 address = cfg.serverSettings.bindaddress;
221 in
222 # ipv6:
223 if hasInfix "]:" address then
224 last (splitString "]:" address)
225 else
226 # ipv4:
227 if hasInfix "." address then
228 last (splitString ":" address)
229 # default is 8443
230 else
231 throw "Address not parseable as IPv4 nor IPv6.";
232in
233{
234 options.services.kanidm = {
235 enableClient = mkEnableOption "the Kanidm client";
236 enableServer = mkEnableOption "the Kanidm server";
237 enablePam = mkEnableOption "the Kanidm PAM and NSS integration";
238
239 package = mkPackageOption pkgs "kanidm" {
240 example = "kanidm_1_4";
241 extraDescription = "If not set will receive a specific version based on stateVersion. Set to `pkgs.kanidm` to always receive the latest version, with the understanding that this could introduce breaking changes.";
242 };
243
244 serverSettings = mkOption {
245 type = types.submodule {
246 freeformType = settingsFormat.type;
247
248 options = {
249 bindaddress = mkOption {
250 description = "Address/port combination the webserver binds to.";
251 example = "[::1]:8443";
252 default = "127.0.0.1:8443";
253 type = types.str;
254 };
255 # Should be optional but toml does not accept null
256 ldapbindaddress = mkOption {
257 description = ''
258 Address and port the LDAP server is bound to. Setting this to `null` disables the LDAP interface.
259 '';
260 example = "[::1]:636";
261 default = null;
262 type = types.nullOr types.str;
263 };
264 origin = mkOption {
265 description = "The origin of your Kanidm instance. Must have https as protocol.";
266 example = "https://idm.example.org";
267 type = types.strMatching "^https://.*";
268 };
269 domain = mkOption {
270 description = ''
271 The `domain` that Kanidm manages. Must be below or equal to the domain
272 specified in `serverSettings.origin`.
273 This can be left at `null`, only if your instance has the role `ReadOnlyReplica`.
274 While it is possible to change the domain later on, it requires extra steps!
275 Please consider the warnings and execute the steps described
276 [in the documentation](https://kanidm.github.io/kanidm/stable/administrivia.html#rename-the-domain).
277 '';
278 example = "example.org";
279 default = null;
280 type = types.nullOr types.str;
281 };
282 db_path = mkOption {
283 description = "Path to Kanidm database.";
284 default = "/var/lib/kanidm/kanidm.db";
285 readOnly = true;
286 type = types.path;
287 };
288 tls_chain = mkOption {
289 description = "TLS chain in pem format.";
290 type = types.path;
291 };
292 tls_key = mkOption {
293 description = "TLS key in pem format.";
294 type = types.path;
295 };
296 log_level = mkOption {
297 description = "Log level of the server.";
298 default = "info";
299 type = types.enum [
300 "info"
301 "debug"
302 "trace"
303 ];
304 };
305 role = mkOption {
306 description = "The role of this server. This affects the replication relationship and thereby available features.";
307 default = "WriteReplica";
308 type = types.enum [
309 "WriteReplica"
310 "WriteReplicaNoUI"
311 "ReadOnlyReplica"
312 ];
313 };
314 online_backup = {
315 path = mkOption {
316 description = "Path to the output directory for backups.";
317 type = types.path;
318 default = "/var/lib/kanidm/backups";
319 };
320 schedule = mkOption {
321 description = "The schedule for backups in cron format.";
322 type = types.str;
323 default = "00 22 * * *";
324 };
325 versions = mkOption {
326 description = ''
327 Number of backups to keep.
328
329 The default is set to `0`, in order to disable backups by default.
330 '';
331 type = types.ints.unsigned;
332 default = 0;
333 example = 7;
334 };
335 };
336 };
337 };
338 default = { };
339 description = ''
340 Settings for Kanidm, see
341 [the documentation](https://kanidm.github.io/kanidm/stable/server_configuration.html)
342 and [example configuration](https://github.com/kanidm/kanidm/blob/master/examples/server.toml)
343 for possible values.
344 '';
345 };
346
347 clientSettings = mkOption {
348 type = types.submodule {
349 freeformType = settingsFormat.type;
350
351 options.uri = mkOption {
352 description = "Address of the Kanidm server.";
353 example = "http://127.0.0.1:8080";
354 type = types.str;
355 };
356 };
357 description = ''
358 Configure Kanidm clients, needed for the PAM daemon. See
359 [the documentation](https://kanidm.github.io/kanidm/stable/client_tools.html#kanidm-configuration)
360 and [example configuration](https://github.com/kanidm/kanidm/blob/master/examples/config)
361 for possible values.
362 '';
363 };
364
365 unixSettings = mkOption {
366 type = types.submodule {
367 freeformType = settingsFormat.type;
368
369 options = {
370 pam_allowed_login_groups = mkOption {
371 description = "Kanidm groups that are allowed to login using PAM.";
372 example = "my_pam_group";
373 type = types.listOf types.str;
374 };
375 hsm_pin_path = mkOption {
376 description = "Path to a HSM pin.";
377 default = "/var/cache/kanidm-unixd/hsm-pin";
378 type = types.path;
379 };
380 };
381 };
382 description = ''
383 Configure Kanidm unix daemon.
384 See [the documentation](https://kanidm.github.io/kanidm/stable/integrations/pam_and_nsswitch.html#the-unix-daemon)
385 and [example configuration](https://github.com/kanidm/kanidm/blob/master/examples/unixd)
386 for possible values.
387 '';
388 };
389
390 provision = {
391 enable = mkEnableOption "provisioning of groups, users and oauth2 resource servers";
392
393 instanceUrl = mkOption {
394 description = "The instance url to which the provisioning tool should connect.";
395 default = "https://localhost:${serverPort}";
396 defaultText = ''"https://localhost:<port from serverSettings.bindaddress>"'';
397 type = types.str;
398 };
399
400 acceptInvalidCerts = mkOption {
401 description = ''
402 Whether to allow invalid certificates when provisioning the target instance.
403 By default this is only allowed when the instanceUrl is localhost. This is
404 dangerous when used with an external URL.
405 '';
406 type = types.bool;
407 default = hasPrefix "https://localhost:" cfg.provision.instanceUrl;
408 defaultText = ''hasPrefix "https://localhost:" cfg.provision.instanceUrl'';
409 };
410
411 adminPasswordFile = mkOption {
412 description = "Path to a file containing the admin password for kanidm. Do NOT use a file from the nix store here!";
413 example = "/run/secrets/kanidm-admin-password";
414 default = null;
415 type = types.nullOr types.path;
416 };
417
418 idmAdminPasswordFile = mkOption {
419 description = ''
420 Path to a file containing the idm admin password for kanidm. Do NOT use a file from the nix store here!
421 If this is not given but provisioning is enabled, the idm_admin password will be reset on each restart.
422 '';
423 example = "/run/secrets/kanidm-idm-admin-password";
424 default = null;
425 type = types.nullOr types.path;
426 };
427
428 autoRemove = mkOption {
429 description = ''
430 Determines whether deleting an entity in this provisioning config should automatically
431 cause them to be removed from kanidm, too. This works because the provisioning tool tracks
432 all entities it has ever created. If this is set to false, you need to explicitly specify
433 `present = false` to delete an entity.
434 '';
435 type = types.bool;
436 default = true;
437 };
438
439 extraJsonFile = mkOption {
440 description = ''
441 A JSON file for provisioning persons, groups & systems.
442 Options set in this file take precedence over values set using the other options.
443 The files get deeply merged, and deduplicated.
444 The accepted JSON schema can be found at <https://github.com/oddlama/kanidm-provision#json-schema>.
445 '';
446 type = types.nullOr types.path;
447 default = null;
448 };
449
450 groups = mkOption {
451 description = "Provisioning of kanidm groups";
452 default = { };
453 type = types.attrsOf (
454 types.submodule (groupSubmod: {
455 options = {
456 present = mkPresentOption "group";
457
458 members = mkOption {
459 description = "List of kanidm entities (persons, groups, ...) which are part of this group.";
460 type = types.listOf types.str;
461 apply = unique;
462 default = [ ];
463 };
464
465 overwriteMembers = mkOption {
466 description = ''
467 Whether the member list should be overwritten each time (true) or appended
468 (false). Append mode allows interactive group management in addition to the
469 declared members. Also, future member removals cannot be reflected
470 automatically in append mode.
471 '';
472 type = types.bool;
473 default = true;
474 };
475 };
476 config.members = concatLists (
477 flip mapAttrsToList cfg.provision.persons (
478 person: personCfg:
479 optional (
480 personCfg.present && builtins.elem groupSubmod.config._module.args.name personCfg.groups
481 ) person
482 )
483 );
484 })
485 );
486 };
487
488 persons = mkOption {
489 description = "Provisioning of kanidm persons";
490 default = { };
491 type = types.attrsOf (
492 types.submodule {
493 options = {
494 present = mkPresentOption "person";
495
496 displayName = mkOption {
497 description = "Display name";
498 type = types.str;
499 example = "My User";
500 };
501
502 legalName = mkOption {
503 description = "Full legal name";
504 type = types.nullOr types.str;
505 example = "Jane Doe";
506 default = null;
507 };
508
509 mailAddresses = mkOption {
510 description = "Mail addresses. First given address is considered the primary address.";
511 type = types.listOf types.str;
512 example = [ "jane.doe@example.com" ];
513 default = [ ];
514 };
515
516 groups = mkOption {
517 description = "List of groups this person should belong to.";
518 type = types.listOf types.str;
519 apply = unique;
520 default = [ ];
521 };
522 };
523 }
524 );
525 };
526
527 systems.oauth2 = mkOption {
528 description = "Provisioning of oauth2 resource servers";
529 default = { };
530 type = types.attrsOf (
531 types.submodule {
532 options = {
533 present = mkPresentOption "oauth2 resource server";
534
535 public = mkOption {
536 description = "Whether this is a public client (enforces PKCE, doesn't use a basic secret)";
537 type = types.bool;
538 default = false;
539 };
540
541 displayName = mkOption {
542 description = "Display name";
543 type = types.str;
544 example = "Some Service";
545 };
546
547 originUrl = mkOption {
548 description = "The redirect URL of the service. These need to exactly match the OAuth2 redirect target";
549 type =
550 let
551 originStrType = types.strMatching ".*://?.*$";
552 in
553 types.either originStrType (types.nonEmptyListOf originStrType);
554 example = "https://someservice.example.com/auth/login";
555 };
556
557 originLanding = mkOption {
558 description = "When redirecting from the Kanidm Apps Listing page, some linked applications may need to land on a specific page to trigger oauth2/oidc interactions.";
559 type = types.str;
560 example = "https://someservice.example.com/home";
561 };
562
563 basicSecretFile = mkOption {
564 description = ''
565 The basic secret to use for this service. If null, the random secret generated
566 by kanidm will not be touched. Do NOT use a path from the nix store here!
567 '';
568 type = types.nullOr types.path;
569 example = "/run/secrets/some-oauth2-basic-secret";
570 default = null;
571 };
572
573 imageFile = mkOption {
574 description = ''
575 Application image to display in the WebUI.
576 Kanidm supports "image/jpeg", "image/png", "image/gif", "image/svg+xml", and "image/webp".
577 The image will be uploaded each time kanidm-provision is run.
578 '';
579 type = types.nullOr types.path;
580 default = null;
581 };
582
583 enableLocalhostRedirects = mkOption {
584 description = "Allow localhost redirects. Only for public clients.";
585 type = types.bool;
586 default = false;
587 };
588
589 enableLegacyCrypto = mkOption {
590 description = "Enable legacy crypto on this client. Allows JWT signing algorthms like RS256.";
591 type = types.bool;
592 default = false;
593 };
594
595 allowInsecureClientDisablePkce = mkOption {
596 description = ''
597 Disable PKCE on this oauth2 resource server to work around insecure clients
598 that may not support it. You should request the client to enable PKCE!
599 Only for non-public clients.
600 '';
601 type = types.bool;
602 default = false;
603 };
604
605 preferShortUsername = mkOption {
606 description = "Use 'name' instead of 'spn' in the preferred_username claim";
607 type = types.bool;
608 default = false;
609 };
610
611 scopeMaps = mkOption {
612 description = ''
613 Maps kanidm groups to returned oauth scopes.
614 See [Scope Relations](https://kanidm.github.io/kanidm/stable/integrations/oauth2.html#scope-relationships) for more information.
615 '';
616 type = types.attrsOf (types.listOf types.str);
617 default = { };
618 };
619
620 supplementaryScopeMaps = mkOption {
621 description = ''
622 Maps kanidm groups to additionally returned oauth scopes.
623 See [Scope Relations](https://kanidm.github.io/kanidm/stable/integrations/oauth2.html#scope-relationships) for more information.
624 '';
625 type = types.attrsOf (types.listOf types.str);
626 default = { };
627 };
628
629 removeOrphanedClaimMaps = mkOption {
630 description = "Whether claim maps not specified here but present in kanidm should be removed from kanidm.";
631 type = types.bool;
632 default = true;
633 };
634
635 claimMaps = mkOption {
636 description = ''
637 Adds additional claims (and values) based on which kanidm groups an authenticating party belongs to.
638 See [Claim Maps](https://kanidm.github.io/kanidm/master/integrations/oauth2.html#custom-claim-maps) for more information.
639 '';
640 default = { };
641 type = types.attrsOf (
642 types.submodule {
643 options = {
644 joinType = mkOption {
645 description = ''
646 Determines how multiple values are joined to create the claim value.
647 See [Claim Maps](https://kanidm.github.io/kanidm/master/integrations/oauth2.html#custom-claim-maps) for more information.
648 '';
649 type = types.enum [
650 "array"
651 "csv"
652 "ssv"
653 ];
654 default = "array";
655 };
656
657 valuesByGroup = mkOption {
658 description = "Maps kanidm groups to values for the claim.";
659 default = { };
660 type = types.attrsOf (types.listOf types.str);
661 };
662 };
663 }
664 );
665 };
666 };
667 }
668 );
669 };
670 };
671 };
672
673 config = mkIf (cfg.enableClient || cfg.enableServer || cfg.enablePam) {
674 warnings = lib.optionals (cfg.package.eolMessage != "") [ cfg.package.eolMessage ];
675
676 assertions =
677 let
678 entityList =
679 type: attrs: flip mapAttrsToList (filterPresent attrs) (name: _: { inherit type name; });
680 entities =
681 entityList "group" cfg.provision.groups
682 ++ entityList "person" cfg.provision.persons
683 ++ entityList "oauth2" cfg.provision.systems.oauth2;
684
685 # Accumulate entities by name. Track corresponding entity types for later duplicate check.
686 entitiesByName = foldl' (
687 acc: { type, name }: acc // { ${name} = (acc.${name} or [ ]) ++ [ type ]; }
688 ) { } entities;
689
690 assertGroupsKnown =
691 opt: groups:
692 let
693 knownGroups = attrNames (filterPresent cfg.provision.groups);
694 unknownGroups = subtractLists knownGroups groups;
695 in
696 {
697 assertion = (cfg.enableServer && cfg.provision.enable) -> unknownGroups == [ ];
698 message = "${opt} refers to unknown groups: ${toString unknownGroups}";
699 };
700
701 assertEntitiesKnown =
702 opt: entities:
703 let
704 unknownEntities = subtractLists (attrNames entitiesByName) entities;
705 in
706 {
707 assertion = (cfg.enableServer && cfg.provision.enable) -> unknownEntities == [ ];
708 message = "${opt} refers to unknown entities: ${toString unknownEntities}";
709 };
710 in
711 [
712 {
713 assertion =
714 !cfg.enableServer
715 || ((cfg.serverSettings.tls_chain or null) == null)
716 || (!isStorePath cfg.serverSettings.tls_chain);
717 message = ''
718 <option>services.kanidm.serverSettings.tls_chain</option> points to
719 a file in the Nix store. You should use a quoted absolute path to
720 prevent this.
721 '';
722 }
723 {
724 assertion =
725 !cfg.enableServer
726 || ((cfg.serverSettings.tls_key or null) == null)
727 || (!isStorePath cfg.serverSettings.tls_key);
728 message = ''
729 <option>services.kanidm.serverSettings.tls_key</option> points to
730 a file in the Nix store. You should use a quoted absolute path to
731 prevent this.
732 '';
733 }
734 {
735 assertion = !cfg.enableClient || options.services.kanidm.clientSettings.isDefined;
736 message = ''
737 <option>services.kanidm.clientSettings</option> needs to be configured
738 if the client is enabled.
739 '';
740 }
741 {
742 assertion = !cfg.enablePam || options.services.kanidm.clientSettings.isDefined;
743 message = ''
744 <option>services.kanidm.clientSettings</option> needs to be configured
745 for the PAM daemon to connect to the Kanidm server.
746 '';
747 }
748 {
749 assertion =
750 !cfg.enableServer
751 || (
752 cfg.serverSettings.domain == null
753 -> cfg.serverSettings.role == "WriteReplica" || cfg.serverSettings.role == "WriteReplicaNoUI"
754 );
755 message = ''
756 <option>services.kanidm.serverSettings.domain</option> can only be set if this instance
757 is not a ReadOnlyReplica. Otherwise the db would inherit it from
758 the instance it follows.
759 '';
760 }
761 {
762 assertion = cfg.provision.enable -> cfg.enableServer;
763 message = "<option>services.kanidm.provision</option> requires <option>services.kanidm.enableServer</option> to be true";
764 }
765 # If any secret is provisioned, the kanidm package must have some required patches applied to it
766 {
767 assertion =
768 (
769 cfg.provision.enable
770 && (
771 cfg.provision.adminPasswordFile != null
772 || cfg.provision.idmAdminPasswordFile != null
773 || any (x: x.basicSecretFile != null) (attrValues (filterPresent cfg.provision.systems.oauth2))
774 )
775 )
776 -> cfg.package.enableSecretProvisioning;
777 message = ''
778 Specifying an admin account password or oauth2 basicSecretFile requires kanidm to be built with the secret provisioning patches.
779 You may want to set `services.kanidm.package = pkgs.kanidm.withSecretProvisioning;`.
780 '';
781 }
782 # Entity names must be globally unique:
783 (
784 let
785 # Filter all names that occurred in more than one entity type.
786 duplicateNames = filterAttrs (_: v: builtins.length v > 1) entitiesByName;
787 in
788 {
789 assertion = cfg.provision.enable -> duplicateNames == { };
790 message = ''
791 services.kanidm.provision requires all entity names (group, person, oauth2, ...) to be unique!
792 ${concatLines (
793 mapAttrsToList (name: xs: " - '${name}' used as: ${toString xs}") duplicateNames
794 )}'';
795 }
796 )
797 ]
798 ++ (optionals (cfg.provision.extraJsonFile == null) (
799 flip mapAttrsToList (filterPresent cfg.provision.persons) (
800 person: personCfg:
801 assertGroupsKnown "services.kanidm.provision.persons.${person}.groups" personCfg.groups
802 )
803 ))
804 ++ (optionals (cfg.provision.extraJsonFile == null) (
805 flip mapAttrsToList (filterPresent cfg.provision.groups) (
806 group: groupCfg:
807 assertEntitiesKnown "services.kanidm.provision.groups.${group}.members" groupCfg.members
808 )
809 ))
810 ++ concatLists (
811 flip mapAttrsToList (filterPresent cfg.provision.systems.oauth2) (
812 oauth2: oauth2Cfg:
813 (optional (cfg.provision.extraJsonFile == null) (
814 assertGroupsKnown "services.kanidm.provision.systems.oauth2.${oauth2}.scopeMaps" (
815 attrNames oauth2Cfg.scopeMaps
816 )
817 ))
818 ++ (optional (cfg.provision.extraJsonFile == null) (
819 assertGroupsKnown "services.kanidm.provision.systems.oauth2.${oauth2}.supplementaryScopeMaps" (
820 attrNames oauth2Cfg.supplementaryScopeMaps
821 )
822 ))
823 ++ concatLists (
824 flip mapAttrsToList oauth2Cfg.claimMaps (
825 claim: claimCfg: [
826 (mkIf (cfg.provision.extraJsonFile == null) (
827 assertGroupsKnown "services.kanidm.provision.systems.oauth2.${oauth2}.claimMaps.${claim}.valuesByGroup" (
828 attrNames claimCfg.valuesByGroup
829 )
830 ))
831 # At least one group must map to a value in each claim map
832 (mkIf (cfg.provision.extraJsonFile == null) {
833 assertion =
834 (cfg.provision.enable && cfg.enableServer)
835 -> any (xs: xs != [ ]) (attrValues claimCfg.valuesByGroup);
836 message = "services.kanidm.provision.systems.oauth2.${oauth2}.claimMaps.${claim} does not specify any values for any group";
837 })
838 # Public clients cannot define a basic secret
839 {
840 assertion =
841 (cfg.provision.enable && cfg.enableServer && oauth2Cfg.public) -> oauth2Cfg.basicSecretFile == null;
842 message = "services.kanidm.provision.systems.oauth2.${oauth2} is a public client and thus cannot specify a basic secret";
843 }
844 # Public clients cannot disable PKCE
845 {
846 assertion =
847 (cfg.provision.enable && cfg.enableServer && oauth2Cfg.public)
848 -> !oauth2Cfg.allowInsecureClientDisablePkce;
849 message = "services.kanidm.provision.systems.oauth2.${oauth2} is a public client and thus cannot disable PKCE";
850 }
851 # Non-public clients cannot enable localhost redirects
852 {
853 assertion =
854 (cfg.provision.enable && cfg.enableServer && !oauth2Cfg.public)
855 -> !oauth2Cfg.enableLocalhostRedirects;
856 message = "services.kanidm.provision.systems.oauth2.${oauth2} is a non-public client and thus cannot enable localhost redirects";
857 }
858 ]
859 )
860 )
861 )
862 );
863
864 services.kanidm.package =
865 let
866 pkg =
867 if lib.versionAtLeast config.system.stateVersion "24.11" then
868 pkgs.kanidm_1_4
869 else
870 lib.warn "No default kanidm package found for stateVersion = '${config.system.stateVersion}'. Using unpinned version. Consider setting `services.kanidm.package = pkgs.kanidm_1_x` to avoid upgrades introducing breaking changes." pkgs.kanidm;
871 in
872 lib.mkDefault pkg;
873
874 environment.systemPackages = mkIf cfg.enableClient [ cfg.package ];
875
876 systemd.tmpfiles.settings."10-kanidm" = {
877 ${cfg.serverSettings.online_backup.path}.d = {
878 mode = "0700";
879 user = "kanidm";
880 group = "kanidm";
881 };
882 };
883
884 systemd.services.kanidm = mkIf cfg.enableServer {
885 description = "kanidm identity management daemon";
886 wantedBy = [ "multi-user.target" ];
887 after = [ "network.target" ];
888 serviceConfig = mkMerge [
889 # Merge paths and ignore existing prefixes needs to sidestep mkMerge
890 (
891 defaultServiceConfig
892 // {
893 BindReadOnlyPaths = mergePaths (
894 defaultServiceConfig.BindReadOnlyPaths
895 ++ secretPaths
896 ++ (lib.optionals (cfg.provision.enable && !cfg.provision.acceptInvalidCerts) [
897 "-/etc/ssl"
898 "-/etc/static/ssl"
899 ])
900 );
901 }
902 )
903 {
904 StateDirectory = "kanidm";
905 StateDirectoryMode = "0700";
906 RuntimeDirectory = "kanidmd";
907 ExecStart = "${cfg.package}/bin/kanidmd server -c ${serverConfigFile}";
908 ExecStartPost = mkIf cfg.provision.enable postStartScript;
909 User = "kanidm";
910 Group = "kanidm";
911
912 BindPaths = [
913 # To store backups
914 cfg.serverSettings.online_backup.path
915 ]
916 ++ optional (
917 cfg.enablePam && cfg.unixSettings ? home_mount_prefix
918 ) cfg.unixSettings.home_mount_prefix;
919
920 AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ];
921 CapabilityBoundingSet = [ "CAP_NET_BIND_SERVICE" ];
922 # This would otherwise override the CAP_NET_BIND_SERVICE capability.
923 PrivateUsers = mkForce false;
924 # Port needs to be exposed to the host network
925 PrivateNetwork = mkForce false;
926 RestrictAddressFamilies = [
927 "AF_INET"
928 "AF_INET6"
929 "AF_UNIX"
930 ];
931 TemporaryFileSystem = "/:ro";
932 }
933 ];
934 };
935
936 systemd.services.kanidm-unixd = mkIf cfg.enablePam {
937 description = "Kanidm PAM daemon";
938 wantedBy = [ "multi-user.target" ];
939 after = [ "network.target" ];
940 restartTriggers = [
941 unixConfigFile
942 clientConfigFile
943 ];
944 serviceConfig = mkMerge [
945 defaultServiceConfig
946 {
947 CacheDirectory = "kanidm-unixd";
948 CacheDirectoryMode = "0700";
949 RuntimeDirectory = "kanidm-unixd";
950 ExecStart = "${cfg.package}/bin/kanidm_unixd";
951 User = "kanidm-unixd";
952 Group = "kanidm-unixd";
953
954 BindReadOnlyPaths = [
955 "-/etc/kanidm"
956 "-/etc/static/kanidm"
957 "-/etc/ssl"
958 "-/etc/static/ssl"
959 "-/etc/passwd"
960 "-/etc/group"
961 ];
962 BindPaths = [
963 # To create the socket
964 "/run/kanidm-unixd:/var/run/kanidm-unixd"
965 ];
966 # Needs to connect to kanidmd
967 PrivateNetwork = mkForce false;
968 RestrictAddressFamilies = [
969 "AF_INET"
970 "AF_INET6"
971 "AF_UNIX"
972 ];
973 TemporaryFileSystem = "/:ro";
974 }
975 ];
976 environment.RUST_LOG = "info";
977 };
978
979 systemd.services.kanidm-unixd-tasks = mkIf cfg.enablePam {
980 description = "Kanidm PAM home management daemon";
981 wantedBy = [ "multi-user.target" ];
982 after = [
983 "network.target"
984 "kanidm-unixd.service"
985 ];
986 partOf = [ "kanidm-unixd.service" ];
987 restartTriggers = [
988 unixConfigFile
989 clientConfigFile
990 ];
991 serviceConfig = {
992 ExecStart = "${cfg.package}/bin/kanidm_unixd_tasks";
993
994 BindReadOnlyPaths = [
995 "/nix/store"
996 "-/etc/resolv.conf"
997 "-/etc/nsswitch.conf"
998 "-/etc/hosts"
999 "-/etc/passwd"
1000 "-/etc/group"
1001 "-/etc/shadow"
1002 "-/etc/localtime"
1003 "-/etc/kanidm"
1004 "-/etc/static/kanidm"
1005 ];
1006 BindPaths = [
1007 # To manage home directories
1008 "/home"
1009 # To connect to kanidm-unixd
1010 "/run/kanidm-unixd:/var/run/kanidm-unixd"
1011 ];
1012 # CAP_DAC_OVERRIDE is needed to ignore ownership of unixd socket
1013 CapabilityBoundingSet = [
1014 "CAP_CHOWN"
1015 "CAP_FOWNER"
1016 "CAP_DAC_OVERRIDE"
1017 "CAP_DAC_READ_SEARCH"
1018 ];
1019 IPAddressDeny = "any";
1020 # Need access to users
1021 PrivateUsers = false;
1022 # Need access to home directories
1023 ProtectHome = false;
1024 RestrictAddressFamilies = [ "AF_UNIX" ];
1025 TemporaryFileSystem = "/:ro";
1026 Restart = "on-failure";
1027 };
1028 environment.RUST_LOG = "info";
1029 };
1030
1031 # These paths are hardcoded
1032 environment.etc = mkMerge [
1033 (mkIf cfg.enableServer { "kanidm/server.toml".source = serverConfigFile; })
1034 (mkIf options.services.kanidm.clientSettings.isDefined {
1035 "kanidm/config".source = clientConfigFile;
1036 })
1037 (mkIf cfg.enablePam { "kanidm/unixd".source = unixConfigFile; })
1038 ];
1039
1040 system.nssModules = mkIf cfg.enablePam [ cfg.package ];
1041
1042 system.nssDatabases.group = optional cfg.enablePam "kanidm";
1043 system.nssDatabases.passwd = optional cfg.enablePam "kanidm";
1044
1045 users.groups = mkMerge [
1046 (mkIf cfg.enableServer { kanidm = { }; })
1047 (mkIf cfg.enablePam { kanidm-unixd = { }; })
1048 ];
1049 users.users = mkMerge [
1050 (mkIf cfg.enableServer {
1051 kanidm = {
1052 description = "Kanidm server";
1053 isSystemUser = true;
1054 group = "kanidm";
1055 packages = [ cfg.package ];
1056 };
1057 })
1058 (mkIf cfg.enablePam {
1059 kanidm-unixd = {
1060 description = "Kanidm PAM daemon";
1061 isSystemUser = true;
1062 group = "kanidm-unixd";
1063 };
1064 })
1065 ];
1066 };
1067
1068 meta.maintainers = with lib.maintainers; [
1069 Flakebi
1070 oddlama
1071 ];
1072 meta.buildDocsInSandbox = false;
1073}