at master 40 kB view raw
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}