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