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