From 1b0934b565afc479a9eb7c64a80e7030ab6229a8 Mon Sep 17 00:00:00 2001 From: oddlama Date: Mon, 11 Mar 2024 16:54:02 +0100 Subject: [PATCH] feat: update kanidm and provisioning --- hosts/ward/guests/kanidm.nix | 28 +- modules/kanidm.nix | 762 ++++++++++++++++++----------------- pkgs/default.nix | 23 +- pkgs/deploy.nix | 5 +- pkgs/kanidm-provision.nix | 26 ++ secrets/global.nix.age | Bin 2146 -> 2262 bytes 6 files changed, 449 insertions(+), 395 deletions(-) create mode 100644 pkgs/kanidm-provision.nix diff --git a/hosts/ward/guests/kanidm.nix b/hosts/ward/guests/kanidm.nix index be2e22a..a15db2e 100644 --- a/hosts/ward/guests/kanidm.nix +++ b/hosts/ward/guests/kanidm.nix @@ -37,28 +37,24 @@ in { age.secrets.kanidm-oauth2-immich = { generator.script = "alnum"; - generator.tags = ["oauth2"]; mode = "440"; group = "kanidm"; }; age.secrets.kanidm-oauth2-grafana = { generator.script = "alnum"; - generator.tags = ["oauth2"]; mode = "440"; group = "kanidm"; }; age.secrets.kanidm-oauth2-forgejo = { generator.script = "alnum"; - generator.tags = ["oauth2"]; mode = "440"; group = "kanidm"; }; age.secrets.kanidm-oauth2-web-sentinel = { generator.script = "alnum"; - generator.tags = ["oauth2"]; mode = "440"; group = "kanidm"; }; @@ -122,24 +118,24 @@ in { inherit (config.repo.secrets.global.kanidm) persons; # Immich - groups.immich = {}; + groups."immich.access" = {}; systems.oauth2.immich = { displayName = "Immich"; - originUrl = "https://${sentinelCfg.networking.providedDomains.immich}"; + originUrl = "https://${sentinelCfg.networking.providedDomains.immich}/"; basicSecretFile = config.age.secrets.kanidm-oauth2-immich.path; - scopeMaps.immich = ["openid" "email" "profile"]; + scopeMaps."immich.access" = ["openid" "email" "profile"]; }; # Grafana - groups.grafana = {}; + groups."grafana.access" = {}; groups."grafana.admins" = {}; groups."grafana.editors" = {}; groups."grafana.server-admins" = {}; systems.oauth2.grafana = { displayName = "Grafana"; - originUrl = "https://${sentinelCfg.networking.providedDomains.grafana}"; + originUrl = "https://${sentinelCfg.networking.providedDomains.grafana}/"; basicSecretFile = config.age.secrets.kanidm-oauth2-grafana.path; - scopeMaps.grafana = ["openid" "email" "profile"]; + scopeMaps."grafana.access" = ["openid" "email" "profile"]; supplementaryScopeMaps = { "grafana.admins" = ["admin"]; "grafana.editors" = ["editor"]; @@ -148,27 +144,27 @@ in { }; # Forgejo - groups.forgejo = {}; + groups."forgejo.access" = {}; groups."forgejo.admins" = {}; systems.oauth2.forgejo = { displayName = "Forgejo"; - originUrl = "https://${sentinelCfg.networking.providedDomains.forgejo}"; + originUrl = "https://${sentinelCfg.networking.providedDomains.forgejo}/"; basicSecretFile = config.age.secrets.kanidm-oauth2-forgejo.path; - scopeMaps.forgejo = ["openid" "email" "profile"]; + scopeMaps."forgejo.access" = ["openid" "email" "profile"]; supplementaryScopeMaps = { "forgejo.admins" = ["admin"]; }; }; # Web Sentinel - groups.web-sentinel = {}; + groups."web-sentinel.access" = {}; groups."web-sentinel.adguardhome" = {}; groups."web-sentinel.influxdb" = {}; systems.oauth2.web-sentinel = { displayName = "Web Sentinel"; - originUrl = "https://oauth2.${personalDomain}"; + originUrl = "https://oauth2.${personalDomain}/"; basicSecretFile = config.age.secrets.kanidm-oauth2-web-sentinel.path; - scopeMaps.web-sentinel = ["openid" "email"]; + scopeMaps."web-sentinel.access" = ["openid" "email"]; supplementaryScopeMaps = { "web-sentinel.adguardhome" = ["access_adguardhome"]; "web-sentinel.influxdb" = ["access_influxdb"]; diff --git a/modules/kanidm.nix b/modules/kanidm.nix index 2791e86..375aac5 100644 --- a/modules/kanidm.nix +++ b/modules/kanidm.nix @@ -12,20 +12,15 @@ attrValues concatLines concatLists - concatMap - concatMapStrings converge - escapeShellArg - escapeShellArgs filter + filterAttrs filterAttrsRecursive flip foldl' getExe - hasInfix hasPrefix isStorePath - mapAttrs mapAttrsToList mdDoc mkEnableOption @@ -35,9 +30,10 @@ mkOption mkPackageOption optional - optionals + optionalString subtractLists types + unique ; cfg = config.services.kanidm; @@ -88,10 +84,10 @@ ProtectHostname = true; # Would re-mount paths ignored by temporary root #ProtectSystem = "strict"; - # ProtectControlGroups = true; # needed for restarter script + ProtectControlGroups = true; ProtectKernelLogs = true; ProtectKernelModules = true; - # ProtectKernelTunables = true; # needed for restarter script + ProtectKernelTunables = true; ProtectProc = "invisible"; RestrictAddressFamilies = []; RestrictNamespaces = true; @@ -110,38 +106,49 @@ default = true; }; - mkScript = script: - mkOption { - readOnly = true; - internal = true; - type = types.str; - default = script; - }; + filterPresent = filterAttrs (_: v: v.present); - mappingsJson = pkgs.writeText "mappings.json" (builtins.toJSON { - account_credentials.admin = cfg.provision.adminPasswordFile; - account_credentials.idm_admin = cfg.provision.idmAdminPasswordFile; - oauth2_basic_secrets = mapAttrs (_: x: x.basicSecretFile) cfg.provision.systems.oauth2; + provisionStateJson = pkgs.writeText "provision-state.json" (builtins.toJSON { + inherit (cfg.provision) groups persons systems; }); - preStartScript = pkgs.writeShellScript "pre-start-manipulate" '' - if ! test -e ${escapeShellArg cfg.serverSettings.db_path}; then - touch "$STATE_DIRECTORY/.first_startup" - else - ${getExe pkgs.kanidm-secret-manipulator} ${escapeShellArg cfg.serverSettings.db_path} ${mappingsJson} + # Only recover the admin account if a password should explicitly be provisioned + # for the account. Otherwise it is not needed for provisioning. + maybeRecoverAdmin = optionalString (cfg.provision.adminPasswordFile != null) '' + KANIDM_ADMIN_PASSWORD=$(< ${cfg.provision.adminPasswordFile}) + # We always reset the admin account password if a desired password was specified. + if ! KANIDM_RECOVER_ACCOUNT_PASSWORD=$KANIDM_ADMIN_PASSWORD ${cfg.package}/bin/kanidmd recover-account -c ${serverConfigFile} admin --from-environment >/dev/null; then + echo "Failed to recover admin account" >&2 + exit 1 fi ''; - restarterScript = pkgs.writeShellScript "post-start-restarter" '' - set -euo pipefail - if test -e "$STATE_DIRECTORY/.needs_restart"; then - rm -f "$STATE_DIRECTORY/.needs_restart" - echo "Restarting kanidm.service..." - #kill -TERM $MAINPID - #echo "Restarting kanidm.service via dbus..." - ${pkgs.dbus}/bin/dbus-send --system --type=method_call --print-reply --dest=org.freedesktop.systemd1 /org/freedesktop/systemd1 org.freedesktop.systemd1.Manager.RestartUnit string:"kanidm.service" string:"replace" - fi - ''; + # Recover the idm_admin account. If a password should explicitly be provisioned + # for the account we set it, otherwise we generate a new one because it is required + # for provisioning. + recoverIdmAdmin = + if cfg.provision.idmAdminPasswordFile != null + then '' + KANIDM_IDM_ADMIN_PASSWORD=$(< ${cfg.provision.idmAdminPasswordFile}) + # We always reset the idm_admin account password if a desired password was specified. + if ! KANIDM_RECOVER_ACCOUNT_PASSWORD=$KANIDM_IDM_ADMIN_PASSWORD ${cfg.package}/bin/kanidmd recover-account -c ${serverConfigFile} idm_admin --from-environment >/dev/null; then + echo "Failed to recover idm_admin account" >&2 + exit 1 + fi + '' + else '' + # Recover idm_admin account + if ! recover_out=$(${cfg.package}/bin/kanidmd recover-account -c ${serverConfigFile} idm_admin -o json); then + echo "$recover_out" >&2 + echo "kanidm provision: Failed to recover admin account" >&2 + exit 1 + fi + if ! KANIDM_IDM_ADMIN_PASSWORD=$(grep '{"password' <<< "$recover_out" | ${getExe pkgs.jq} -r .password); then + echo "$recover_out" >&2 + echo "kanidm provision: Failed to parse password for idm_admin account" >&2 + exit 1 + fi + ''; postStartScript = pkgs.writeShellScript "post-start" '' set -euo pipefail @@ -150,7 +157,7 @@ count=0 while ! test -e /run/kanidmd/sock; do sleep 0.1 - if [ "$count" -eq 600 ]; then + if [[ "$count" -eq 600 ]]; then echo "Tried for 60 seconds, giving up..." exit 1 fi @@ -161,113 +168,11 @@ count=$((count++)) done - # If this is the first start, we login this time by recovering the admin account - # and force a restart afterwards to rewrite the password. - if test -e "$STATE_DIRECTORY/.first_startup"; then - # Recover admin account - if ! recover_out=$(${cfg.package}/bin/kanidmd recover-account -c ${serverConfigFile} admin); then - echo "$recover_out" >&2 - echo "kanidm provision: Failed to recover admin account" >&2 - exit 1 - fi - if ! KANIDM_PASSWORD_ADMIN=$(grep -o '[A-Za-z0-9]\{48\}' <<< "$recover_out"); then - echo "$recover_out" >&2 - echo "kanidm provision: Failed to parse password for admin account" >&2 - exit 1 - fi + ${recoverIdmAdmin} + ${maybeRecoverAdmin} - # Recover idm_admin account - if ! recover_out=$(${cfg.package}/bin/kanidmd recover-account -c ${serverConfigFile} idm_admin); then - echo "$recover_out" >&2 - echo "kanidm provision: Failed to recover admin account" >&2 - exit 1 - fi - if ! KANIDM_PASSWORD_IDM=$(grep -o '[A-Za-z0-9]\{48\}' <<< "$recover_out"); then - echo "$recover_out" >&2 - echo "kanidm provision: Failed to parse password for idm_admin account" >&2 - exit 1 - fi - needs_rewrite=1 - rm -f "$STATE_DIRECTORY/.first_startup" - else - # Login using the admin password - KANIDM_PASSWORD_ADMIN="$(< ${escapeShellArg cfg.provision.adminPasswordFile})" - KANIDM_PASSWORD_IDM="$(< ${escapeShellArg cfg.provision.idmAdminPasswordFile})" - fi - - # Set $HOME so kanidm can save the token temporarily - export TMPDIR=$(mktemp -d) - mkdir -p "$TMPDIR"/{.config,.cache} - touch "$TMPDIR/.config/kanidm" - trap 'rm -rf $TMPDIR' EXIT - export HOME=$TMPDIR - - # Login to admin and idm_admin - KANIDM_PASSWORD=$KANIDM_PASSWORD_ADMIN ${cfg.package}/bin/kanidm login --name admin \ - || { echo "kanidm provision: Failed to login as admin, see kanidm logs." >&2; exit 1; } - KANIDM_PASSWORD=$KANIDM_PASSWORD_IDM ${cfg.package}/bin/kanidm login --name idm_admin \ - || { echo "kanidm provision: Failed to login as idm_admin, see kanidm logs." >&2; exit 1; } - - # Wrapper function that detects kanidm errors by detecting any output to stderr - # (stderr and stdout are swapped when calling this) - function kanidm-detect-err() { - if ! err=$(${cfg.package}/bin/kanidm "$@" 3>&2 2>&1 1>&3-); then - echo "$err" - echo "kanidm ''${*@Q}: failed with status $?, see error above" - exit 1 - fi - if [[ -n "$err" ]]; then - echo "$err" - echo "kanidm ''${*@Q}: failed, see error above" - exit 1 - fi - } - - # Wrapper function to easily execute commands as admin or idm_admin - function kanidm-as-user() { - name=$1 - shift - kanidm-detect-err "$@" --name "$name" 3>&2 2>&1 1>&3- - } - - function kanidm-admin() { kanidm-as-user admin "$@"; } - function kanidm-idm() { kanidm-as-user idm_admin "$@"; } - - known_groups=$(kanidm-admin group list --output=json) - function group_exists() { - if ! x=$(${getExe pkgs.jq} <<< "$known_groups" ".[] | select(.name[0] == \"$1\")"); then - echo "kanidm provision: Failed to parse groups list." >&2 - exit 1 - fi - [[ -n "$x" ]] - } - - known_persons=$(kanidm-admin person list --output=json) - function person_exists() { - if ! x=$(${getExe pkgs.jq} <<< "$known_persons" ".[] | select(.name[0] == \"$1\")"); then - echo "kanidm provision: Failed to parse persons list." >&2 - exit 1 - fi - [[ -n "$x" ]] - } - - known_oauth2_systems=$(kanidm-admin system oauth2 list --output=json) - function oauth2_system_exists() { - if ! x=$(${getExe pkgs.jq} <<< "$known_oauth2_systems" ".[] | select(.oauth2_rs_name[0] == \"$1\")"); then - echo "kanidm provision: Failed to parse oauth2 systems list." >&2 - exit 1 - fi - [[ -n "$x" ]] - } - - ${concatMapStrings (x: x._script) (attrValues cfg.provision.groups)} - ${concatMapStrings (x: x._script) (attrValues cfg.provision.persons)} - ${concatMapStrings (x: x._script) (attrValues cfg.provision.systems.oauth2)} - - if [[ "''${needs_rewrite-0}" == 1 ]]; then - echo "Queueing service restart to rewrite secrets" - touch "$STATE_DIRECTORY/.needs_restart" - fi + KANIDM_PROVISION_IDM_ADMIN_TOKEN=$KANIDM_IDM_ADMIN_PASSWORD \ + ${getExe pkgs.kanidm-provision} --url "${cfg.provision.instanceUrl}" --state ${provisionStateJson} ${optionalString cfg.provision.acceptInvalidCerts "--accept-invalid-certs"} ''; in { options.services.kanidm = { @@ -277,182 +182,6 @@ in { package = mkPackageOption pkgs "kanidm" {}; - provision = { - enable = mkEnableOption "provisioning of systems (oauth2), groups and users"; - - adminPasswordFile = mkOption { - description = mdDoc "Path to a file containing the admin password for kanidm. Do NOT use a file from the nix store here!"; - example = "/run/secrets/kanidm-admin-password"; - type = types.path; - }; - - idmAdminPasswordFile = mkOption { - description = mdDoc "Path to a file containing the idm admin password for kanidm. Do NOT use a file from the nix store here!"; - example = "/run/secrets/kanidm-idm-admin-password"; - type = types.path; - }; - - persons = mkOption { - description = mdDoc "Provisioning of kanidm persons"; - default = {}; - type = types.attrsOf (types.submodule (personSubmod: let - inherit (personSubmod.config._module.args) name; - updateArgs = - ["--displayname" personSubmod.config.displayName] - ++ optionals (personSubmod.config.legalName != null) - ["--legalname" personSubmod.config.legalName] - # mail addresses - ++ concatMap (addr: ["--mail" addr]) personSubmod.config.mailAddresses; - in { - options = { - _script = mkScript ( - if personSubmod.config.present - then - '' - if ! person_exists ${escapeShellArg name}; then - kanidm-idm person create ${escapeShellArg name} \ - ${escapeShellArg personSubmod.config.displayName} - fi - kanidm-idm person update ${escapeShellArg name} ${escapeShellArgs updateArgs} - '' - + flip concatMapStrings personSubmod.config.groups (group: '' - kanidm-idm group add-members ${escapeShellArg group} ${escapeShellArg name} - '') - else '' - if person_exists ${escapeShellArg name}; then - kanidm-idm person delete ${escapeShellArg name} - fi - '' - ); - - present = mkPresentOption "person"; - - displayName = mkOption { - description = mdDoc "Display name"; - type = types.str; - example = "My User"; - }; - - legalName = mkOption { - description = mdDoc "Full legal name"; - type = types.nullOr types.str; - example = "Jane Doe"; - default = null; - }; - - mailAddresses = mkOption { - description = mdDoc "Mail addresses. First given address is considered the primary address."; - type = types.listOf types.str; - example = ["jane.doe@example.com"]; - default = []; - }; - - groups = mkOption { - description = mdDoc "List of kanidm groups to which this user belongs."; - type = types.listOf types.str; - default = []; - }; - }; - })); - }; - - groups = mkOption { - description = mdDoc "Provisioning of kanidm groups"; - default = {}; - type = types.attrsOf (types.submodule (groupSubmod: let - inherit (groupSubmod.config._module.args) name; - in { - options = { - _script = mkScript ( - if groupSubmod.config.present - then '' - if ! group_exists ${escapeShellArg name}; then - kanidm-admin group create ${escapeShellArg name} - fi - '' - else '' - if group_exists ${escapeShellArg name}; then - kanidm-admin group delete ${escapeShellArg name} - fi - '' - ); - - present = mkPresentOption "group"; - }; - })); - }; - - systems.oauth2 = mkOption { - description = mdDoc "Provisioning of oauth2 systems"; - default = {}; - type = types.attrsOf (types.submodule (oauth2Submod: let - inherit (oauth2Submod.config._module.args) name; - in { - options = { - _script = mkScript ( - if oauth2Submod.config.present - then - '' - if ! oauth2_system_exists ${escapeShellArg name}; then - kanidm-admin system oauth2 create \ - ${escapeShellArg name} \ - ${escapeShellArg oauth2Submod.config.displayName} \ - ${escapeShellArg oauth2Submod.config.originUrl} - needs_rewrite=1 - fi - '' - + concatLines (flip mapAttrsToList oauth2Submod.config.scopeMaps (group: scopes: '' - kanidm-admin system oauth2 update-scope-map ${escapeShellArg name} \ - ${escapeShellArg group} ${escapeShellArgs scopes} - '')) - + concatLines (flip mapAttrsToList oauth2Submod.config.supplementaryScopeMaps (group: scopes: '' - kanidm-admin system oauth2 update-sup-scope-map ${escapeShellArg name} \ - ${escapeShellArg group} ${escapeShellArgs scopes} - '')) - else '' - if oauth2_system_exists ${escapeShellArg name}; then - kanidm-admin system oauth2 delete ${escapeShellArg name} - fi - '' - ); - - present = mkPresentOption "oauth2 system"; - - displayName = mkOption { - description = mdDoc "Display name"; - type = types.str; - example = "Some Service"; - }; - - originUrl = mkOption { - description = mdDoc "The basic secret to use for this service. If null, the random secret generated by kanidm will not be touched. Do NOT use a path from the nix store here!"; - type = types.str; - example = "https://someservice.example.com/"; - }; - - basicSecretFile = mkOption { - description = mdDoc "The basic secret to use for this service. If null, the random secret generated by kanidm will not be touched. Do NOT use a path from the nix store here!"; - type = types.nullOr types.path; - example = "/run/secrets/some-oauth2-basic-secret"; - default = null; - }; - - scopeMaps = mkOption { - description = mdDoc "Maps kanidm groups to provided scopes. See [Scope Relations](https://kanidm.github.io/kanidm/stable/integrations/oauth2.html#scope-relationships) for more information."; - type = types.attrsOf (types.listOf types.str); - default = {}; - }; - - supplementaryScopeMaps = mkOption { - description = mdDoc "Maps kanidm groups to provided supplementary scopes. See [Scope Relations](https://kanidm.github.io/kanidm/stable/integrations/oauth2.html#scope-relationships) for more information."; - type = types.attrsOf (types.listOf types.str); - default = {}; - }; - }; - })); - }; - }; - serverSettings = mkOption { type = types.submodule { freeformType = settingsFormat.type; @@ -514,12 +243,34 @@ in { default = "WriteReplica"; type = types.enum ["WriteReplica" "WriteReplicaNoUI" "ReadOnlyReplica"]; }; + online_backup = { + path = mkOption { + description = mdDoc "Path to the output directory for backups."; + type = types.path; + default = "/var/lib/kanidm/backups"; + }; + schedule = mkOption { + description = mdDoc "The schedule for backups in cron format."; + type = types.str; + default = "00 22 * * *"; + }; + versions = mkOption { + description = mdDoc '' + Number of backups to keep. + + The default is set to `0`, in order to disable backups by default. + ''; + type = types.ints.unsigned; + default = 0; + example = 7; + }; + }; }; }; default = {}; description = mdDoc '' Settings for Kanidm, see - [the documentation](https://github.com/kanidm/kanidm/blob/master/kanidm_book/src/server_configuration.md) + [the documentation](https://kanidm.github.io/kanidm/stable/server_configuration.html) and [example configuration](https://github.com/kanidm/kanidm/blob/master/examples/server.toml) for possible values. ''; @@ -537,7 +288,7 @@ in { }; description = mdDoc '' Configure Kanidm clients, needed for the PAM daemon. See - [the documentation](https://github.com/kanidm/kanidm/blob/master/kanidm_book/src/client_tools.md#kanidm-configuration) + [the documentation](https://kanidm.github.io/kanidm/stable/client_tools.html#kanidm-configuration) and [example configuration](https://github.com/kanidm/kanidm/blob/master/examples/config) for possible values. ''; @@ -547,23 +298,276 @@ in { type = types.submodule { freeformType = settingsFormat.type; - options.pam_allowed_login_groups = mkOption { - description = mdDoc "Kanidm groups that are allowed to login using PAM."; - example = "my_pam_group"; - type = types.listOf types.str; + options = { + pam_allowed_login_groups = mkOption { + description = mdDoc "Kanidm groups that are allowed to login using PAM."; + example = "my_pam_group"; + type = types.listOf types.str; + }; + hsm_pin_path = mkOption { + description = mdDoc "Path to a HSM pin."; + default = "/var/cache/kanidm-unixd/hsm-pin"; + type = types.path; + }; }; }; description = mdDoc '' Configure Kanidm unix daemon. - See [the documentation](https://github.com/kanidm/kanidm/blob/master/kanidm_book/src/pam_and_nsswitch.md#the-unix-daemon) + See [the documentation](https://kanidm.github.io/kanidm/stable/integrations/pam_and_nsswitch.html#the-unix-daemon) and [example configuration](https://github.com/kanidm/kanidm/blob/master/examples/unixd) for possible values. ''; }; + + provision = { + enable = mkEnableOption "provisioning of groups, users and oauth2 resource servers"; + + instanceUrl = mkOption { + description = "The instance url to which the provisioning tool should connect."; + default = "https://localhost"; + type = types.str; + }; + + acceptInvalidCerts = mkOption { + description = '' + Whether to allow invalid certificates when provisioning the target instance. + By default this is only allowed when the instanceUrl is localhost. This is + dangerous when used with an external URL. + ''; + type = types.bool; + default = cfg.provision.instanceUrl == "https://localhost"; + defaultText = ''services.kanidm.provision.instanceUrl == "https://localhost"''; + }; + + adminPasswordFile = mkOption { + description = "Path to a file containing the admin password for kanidm. Do NOT use a file from the nix store here!"; + example = "/run/secrets/kanidm-admin-password"; + default = null; + type = types.nullOr types.path; + }; + + idmAdminPasswordFile = mkOption { + description = '' + Path to a file containing the idm admin password for kanidm. Do NOT use a file from the nix store here! + If this is not given but provisioning is enabled, the idm_admin password will be reset on each restart. + ''; + example = "/run/secrets/kanidm-idm-admin-password"; + default = null; + type = types.nullOr types.path; + }; + + autoRemove = mkOption { + description = '' + Determines whether deleting an entity in this provisioning config should automatically + cause them to be removed from kanidm, too. This works because the provisioning tool tracks + all entities it has ever created. If this is set to false, you need to explicitly specify + `present = false` to delete an entity. + ''; + type = types.bool; + default = true; + }; + + groups = mkOption { + description = "Provisioning of kanidm groups"; + default = {}; + type = types.attrsOf (types.submodule (groupSubmod: { + options = { + present = mkPresentOption "group"; + + members = mkOption { + description = "List of kanidm entities (persons, groups, ...) which are part of this group."; + type = types.listOf types.str; + apply = unique; + default = []; + }; + }; + config.members = concatLists (flip mapAttrsToList cfg.provision.persons ( + person: personCfg: + optional (personCfg.present && builtins.elem groupSubmod.config._module.args.name personCfg.groups) person + )); + })); + }; + + persons = mkOption { + description = "Provisioning of kanidm persons"; + default = {}; + type = types.attrsOf (types.submodule { + options = { + present = mkPresentOption "person"; + + displayName = mkOption { + description = "Display name"; + type = types.str; + example = "My User"; + }; + + legalName = mkOption { + description = "Full legal name"; + type = types.nullOr types.str; + example = "Jane Doe"; + default = null; + }; + + mailAddresses = mkOption { + description = "Mail addresses. First given address is considered the primary address."; + type = types.listOf types.str; + example = ["jane.doe@example.com"]; + default = []; + }; + + groups = mkOption { + description = "List of groups this person should belong to."; + type = types.listOf types.str; + apply = unique; + default = []; + }; + }; + }); + }; + + systems.oauth2 = mkOption { + description = "Provisioning of oauth2 resource servers"; + default = {}; + type = types.attrsOf (types.submodule { + options = { + present = mkPresentOption "oauth2 resource server"; + + displayName = mkOption { + description = "Display name"; + type = types.str; + example = "Some Service"; + }; + + originUrl = mkOption { + description = "The origin URL of the service. OAuth2 redirects will only be allowed to sites under this origin. Must end with a slash."; + type = types.strMatching ".*://.*/$"; + example = "https://someservice.example.com/"; + }; + + originLanding = mkOption { + 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."; + type = types.nullOr types.str; + default = null; + example = "https://someservice.example.com/home"; + }; + + basicSecretFile = mkOption { + description = '' + The basic secret to use for this service. If null, the random secret generated + by kanidm will not be touched. Do NOT use a path from the nix store here! + ''; + type = types.nullOr types.path; + example = "/run/secrets/some-oauth2-basic-secret"; + default = null; + }; + + allowInsecureClientDisablePkce = mkOption { + description = '' + Disable PKCE on this oauth2 resource server to work around insecure clients + that may not support it. You should request the client to enable PKCE! + ''; + type = types.bool; + default = false; + }; + + preferShortUsername = mkOption { + description = "Use 'name' instead of 'spn' in the preferred_username claim"; + type = types.bool; + default = false; + }; + + scopeMaps = mkOption { + description = '' + Maps kanidm groups to returned oauth scopes. + See [Scope Relations](https://kanidm.github.io/kanidm/stable/integrations/oauth2.html#scope-relationships) for more information. + ''; + type = types.attrsOf (types.listOf types.str); + default = {}; + }; + + supplementaryScopeMaps = mkOption { + description = '' + Maps kanidm groups to additionally returned oauth scopes. + See [Scope Relations](https://kanidm.github.io/kanidm/stable/integrations/oauth2.html#scope-relationships) for more information. + ''; + type = types.attrsOf (types.listOf types.str); + default = {}; + }; + + removeOrphanedClaimMaps = mkOption { + description = "Whether claim maps not specified here but present in kanidm should be removed from kanidm."; + type = types.bool; + default = true; + }; + + claimMaps = mkOption { + description = '' + Adds additional claims (and values) based on which kanidm groups an authenticating party belongs to. + See [Claim Maps](https://kanidm.github.io/kanidm/master/integrations/oauth2.html#custom-claim-maps) for more information. + ''; + default = {}; + type = types.attrsOf (types.submodule { + options = { + joinType = mkOption { + description = '' + Determines how multiple values are joined to create the claim value. + See [Claim Maps](https://kanidm.github.io/kanidm/master/integrations/oauth2.html#custom-claim-maps) for more information. + ''; + type = types.enum ["array" "csv" "ssv"]; + default = "array"; + }; + + valuesByGroup = mkOption { + description = "Maps kanidm groups to values for the claim."; + default = {}; + type = types.attrsOf (types.listOf types.str); + }; + }; + }); + }; + }; + }); + }; + }; }; config = mkIf (cfg.enableClient || cfg.enableServer || cfg.enablePam) { - assertions = + assertions = let + entityList = type: attrs: flip mapAttrsToList (filterPresent attrs) (name: _: {inherit type name;}); + entities = + entityList "group" cfg.provision.groups + ++ entityList "person" cfg.provision.persons + ++ entityList "oauth2" cfg.provision.systems.oauth2; + + # Accumulate entities by name. Track corresponding entity types for later duplicate check. + entitiesByName = + foldl' ( + acc: { + type, + name, + }: + acc + // { + ${name} = (acc.${name} or []) ++ [type]; + } + ) {} + entities; + + assertGroupsKnown = opt: groups: let + knownGroups = attrNames (filterPresent cfg.provision.groups); + unknownGroups = subtractLists knownGroups groups; + in { + assertion = (cfg.enableServer && cfg.provision.enable) -> unknownGroups == []; + message = "${opt} refers to unknown groups: ${toString unknownGroups}"; + }; + + assertEntitiesKnown = opt: entities: let + unknownEntities = subtractLists (attrNames entitiesByName) entities; + in { + assertion = (cfg.enableServer && cfg.provision.enable) -> unknownEntities == []; + message = "${opt} refers to unknown entities: ${toString unknownEntities}"; + }; + in [ { assertion = !cfg.enableServer || ((cfg.serverSettings.tls_chain or null) == null) || (!isStorePath cfg.serverSettings.tls_chain); @@ -611,38 +615,67 @@ in { assertion = cfg.provision.enable -> cfg.enableServer; message = " requires to be true"; } + # If any secret is provisioned, the kanidm package must have some required patches applied to it { - assertion = cfg.provision.enable -> cfg.enableClient; - message = " requires to be able to use the kanidm client locally for provisioning."; + assertion = + (cfg.provision.enable + && ( + cfg.provision.adminPasswordFile + != null + || cfg.provision.idmAdminPasswordFile != null + || any (x: x.basicSecretFile != null) (attrValues (filterPresent cfg.provision.systems.oauth2)) + )) + -> cfg.package.enableSecretProvisioning; + message = '' + Specifying an admin account password or oauth2 basicSecretFile requires kanidm to be built with the secret provisioning patches. + You may want to set `services.kanidm.package = pkgs.kanidm.withSecretProvisioning;`. + ''; } + # Entity names must be globally unique: + (let + # Filter all names that occurred in more than one entity type. + duplicateNames = filterAttrs (_: v: builtins.length v > 1) entitiesByName; + in { + assertion = cfg.provision.enable -> duplicateNames == {}; + message = '' + services.kanidm.provision requires all entity names (group, person, oauth2, ...) to be unique! + ${concatLines (mapAttrsToList (name: xs: " - '${name}' used as: ${toString xs}") duplicateNames)}''; + }) ] - ++ flip mapAttrsToList cfg.provision.persons (person: personCfg: let - unknownGroups = subtractLists (attrNames cfg.provision.groups) personCfg.groups; - in { - assertion = (cfg.enableServer && cfg.provision.enable) -> unknownGroups == []; - message = "kanidm: provision.persons.${person}.groups: Refers to unknown groups: ${toString unknownGroups}"; - }) - ++ concatLists (flip mapAttrsToList cfg.provision.systems.oauth2 (oauth2: oauth2Cfg: [ - { - assertion = (cfg.enableServer && cfg.provision.enable) -> hasInfix "://" oauth2Cfg.originUrl; - message = "kanidm: provision.systems.oauth2.${oauth2}.originUrl: Missing a schema like 'https://': ${oauth2Cfg.originUrl}"; - } - (let - unknownGroups = subtractLists (attrNames cfg.provision.groups) (attrNames oauth2Cfg.scopeMaps); - in { - assertion = (cfg.enableServer && cfg.provision.enable) -> unknownGroups == []; - message = "kanidm: provision.systems.oauth2.${oauth2}.scopeMaps: Refers to unknown groups: ${toString unknownGroups}"; - }) - (let - unknownGroups = subtractLists (attrNames cfg.provision.groups) (attrNames oauth2Cfg.supplementaryScopeMaps); - in { - assertion = (cfg.enableServer && cfg.provision.enable) -> unknownGroups == []; - message = "kanidm: provision.systems.oauth2.${oauth2}.supplementaryScopeMaps: Refers to unknown groups: ${toString unknownGroups}"; - }) - ])); + ++ flip mapAttrsToList (filterPresent cfg.provision.persons) ( + person: personCfg: + assertGroupsKnown "services.kanidm.provision.persons.${person}.groups" personCfg.groups + ) + ++ flip mapAttrsToList (filterPresent cfg.provision.groups) ( + group: groupCfg: + assertEntitiesKnown "services.kanidm.provision.groups.${group}.members" groupCfg.members + ) + ++ concatLists (flip mapAttrsToList (filterPresent cfg.provision.systems.oauth2) ( + oauth2: oauth2Cfg: + [ + (assertGroupsKnown "services.kanidm.provision.systems.oauth2.${oauth2}.scopeMaps" (attrNames oauth2Cfg.scopeMaps)) + (assertGroupsKnown "services.kanidm.provision.systems.oauth2.${oauth2}.supplementaryScopeMaps" (attrNames oauth2Cfg.supplementaryScopeMaps)) + ] + ++ concatLists (flip mapAttrsToList oauth2Cfg.claimMaps (claim: claimCfg: [ + (assertGroupsKnown "services.kanidm.provision.systems.oauth2.${oauth2}.claimMaps.${claim}.valuesByGroup" (attrNames claimCfg.valuesByGroup)) + # At least one group must map to a value in each claim map + { + assertion = (cfg.provision.enable && cfg.enableServer) -> any (xs: xs != []) (attrValues claimCfg.valuesByGroup); + message = "services.kanidm.provision.systems.oauth2.${oauth2}.claimMaps.${claim} does not specify any values for any group"; + } + ])) + )); environment.systemPackages = mkIf cfg.enableClient [cfg.package]; + systemd.tmpfiles.settings."10-kanidm" = { + ${cfg.serverSettings.online_backup.path}.d = { + mode = "0700"; + user = "kanidm"; + group = "kanidm"; + }; + }; + systemd.services.kanidm = mkIf cfg.enableServer { description = "kanidm identity management daemon"; wantedBy = ["multi-user.target"]; @@ -651,39 +684,22 @@ in { # Merge paths and ignore existing prefixes needs to sidestep mkMerge (defaultServiceConfig // { - BindReadOnlyPaths = mergePaths ( - defaultServiceConfig.BindReadOnlyPaths - ++ certPaths - # If provisioning is enabled, we need access to the client config to use the kanidm cli, - # and to the installed system certificates. - ++ optionals cfg.provision.enable [ - "-/etc/ssl/certs" - "-/etc/static/ssl/certs" - "-/etc/kanidm" - "-/etc/static/kanidm" - ] - ); + BindReadOnlyPaths = mergePaths (defaultServiceConfig.BindReadOnlyPaths ++ certPaths); }) { StateDirectory = "kanidm"; StateDirectoryMode = "0700"; RuntimeDirectory = "kanidmd"; - ExecStartPre = mkIf cfg.provision.enable [preStartScript]; ExecStart = "${cfg.package}/bin/kanidmd server -c ${serverConfigFile}"; - ExecStartPost = - mkIf cfg.provision.enable - [ - postStartScript - # Only the restarter runs with elevated privileges - "+${restarterScript}" - ]; + ExecStartPost = mkIf cfg.provision.enable postStartScript; User = "kanidm"; Group = "kanidm"; BindPaths = [ # To create the socket "/run/kanidmd:/run/kanidmd" - "/run/dbus/system_bus_socket" + # To store backups + cfg.serverSettings.online_backup.path ]; AmbientCapabilities = ["CAP_NET_BIND_SERVICE"]; diff --git a/pkgs/default.nix b/pkgs/default.nix index e228485..a5a48d2 100644 --- a/pkgs/default.nix +++ b/pkgs/default.nix @@ -5,14 +5,31 @@ (_final: prev: { deploy = prev.callPackage ./deploy.nix {}; git-fuzzy = prev.callPackage ./git-fuzzy {}; + kanidm = prev.kanidm.overrideAttrs (old: let + provisionSrc = prev.fetchFromGitHub { + owner = "oddlama"; + repo = "kanidm-provision"; + rev = "aa7a1c8ec04622745b385bd3b0462e1878f56b51"; + hash = "sha256-NRolS3l2kARjkhWP7FYUG//KCEiueh48ZrADdCDb9Zg="; + }; + in { + patches = + old.patches + ++ [ + "${provisionSrc}/patches/${old.version}-oauth2-basic-secret-modify.patch" + "${provisionSrc}/patches/${old.version}-recover-account.patch" + ]; + passthru.enableSecretProvisioning = true; + }); + kanidm-provision = prev.callPackage ./kanidm-provision.nix {}; kanidm-secret-manipulator = prev.callPackage ./kanidm-secret-manipulator.nix {}; segoe-ui-ttf = prev.callPackage ./segoe-ui-ttf.nix {}; zsh-histdb-skim = prev.callPackage ./zsh-skim-histdb.nix {}; awakened-poe-trade = prev.callPackage ./awakened-poe-trade.nix {}; - neovim-clean = prev.neovim-unwrapped.overrideAttrs (_neovimFinal: neovimPrev: { - nativeBuildInputs = (neovimPrev.nativeBuildInputs or []) ++ [prev.makeWrapper]; + neovim-clean = prev.neovim-unwrapped.overrideAttrs (old: { + nativeBuildInputs = (old.nativeBuildInputs or []) ++ [prev.makeWrapper]; postInstall = - (neovimPrev.postInstall or "") + (old.postInstall or "") + '' wrapProgram $out/bin/nvim --add-flags "--clean" ''; diff --git a/pkgs/deploy.nix b/pkgs/deploy.nix index 8edfdb1..1717c4d 100644 --- a/pkgs/deploy.nix +++ b/pkgs/deploy.nix @@ -1,7 +1,5 @@ { bc, - lib, - nvd, writeShellApplication, }: let deploy = writeShellApplication { @@ -109,7 +107,8 @@ ssh "$host" -- "$store_path"/bin/switch-to-configuration "$ACTION" \ || echo "Error while activating new system" >&2 if [[ -n "$prev_system" ]]; then - ssh "$host" -- ${lib.getExe nvd} --color always diff "$prev_system" "$store_path" || true + # nvd must be installed on the target system for this to work + ssh "$host" -- nvd --color always diff "$prev_system" "$store_path" || true fi time_next echo " Applied ✅ $host in ''${T_LAST}s" diff --git a/pkgs/kanidm-provision.nix b/pkgs/kanidm-provision.nix new file mode 100644 index 0000000..c3b5891 --- /dev/null +++ b/pkgs/kanidm-provision.nix @@ -0,0 +1,26 @@ +{ + lib, + rustPlatform, + fetchFromGitHub, +}: +rustPlatform.buildRustPackage rec { + pname = "kanidm-provision"; + version = "1.0.0"; + + src = fetchFromGitHub { + owner = "oddlama"; + repo = "kanidm-provision"; + rev = "v${version}"; + hash = "sha256-T6kiBUdOMHCWRUF/vepoPrvaULDQrUGYsd/3I11HCLY="; + }; + + cargoHash = "sha256-nHp3C6szJxOogH/kETIqcQQNhFqBCO0P66j7n3UHuwo="; + + meta = with lib; { + description = "A small utility to help with kanidm provisioning"; + homepage = "https://github.com/oddlama/kanidm-provision"; + license = with licenses; [asl20 mit]; + maintainers = with maintainers; [oddlama]; + mainProgram = "kanidm-provision"; + }; +} diff --git a/secrets/global.nix.age b/secrets/global.nix.age index 3fb5cc29004368b21bca5ecbe4d1356d471b7d75..fad7907964da2e9762bc2a39bc85e96a2720a31f 100644 GIT binary patch delta 2254 zcmV;<2r>8K5Y`cpAb%@oM`ue@W^FkyYb!}^c4Jg{cV%ua&ZbodO|sKGC4O;T0?DCF>^>SP-{(9N=8a}Icst+IXQMiLUCnjW=BqR zb9V|YJ|H!AS}kXCWnpt=AZ;Labs%VMXl@EwZBuMVYHCqWN@sL(F)KBAT2yl|Vo+I8 zNKq?xD_V0>FMo4bGgwkKSaNrGVMR7&HdrrdLoq~0P)1NSH*I-Z3M*nZS!!rmbag8= zGDAamXm?^lSt~S8D|2mSI8AdlK|*3`L3dPDV@oz-VRckCdQ?VlW-?@KN^^E=3N0-y zAaP|iZZk4*MQbZ}Qxc}-+EP+2o+X@6p8d1zNKNJTGgGz#IZ!s+Am z^!QYlyN_3BUD?iy6Q$$R>haX1O4fL8PEz0UF}&jd8M|+^oaD>7V&}l#8|ZtbF+{1BgK<5^=#gWXW~jlSg3Rt+;uH`n6P^h z4%u6-$$xbun$n%otVR~fEmj>0u)_?hCedM(UI4PsmEykl{BolmbPnA8V)= z#1wWx;8h5Ym4zpQ2R$~PN#hwF31IRe(dS2_EPu2f$^eD$>*xjhhs2kPmdPEP0Y*C{ zs%>GP!#*8A#KQ);SWI~U74d5a=QwLhahfod zZ#OGuzR@sdpXAjA2Tw2CrwzC}LYcsTia<@4q^#N#smhj-J>RedKL}zxoyDr$FF$&d zF6Z|pdy($kC1t6wla~wdHPl^d!HG|@jbA;Kxb7LeDL%S+Y8dz-VJUTiq% zsKM0cHbJYmp#J(9IlzSejf--o%{vk-P=5`WoAm7IsU^>zHxmoTgY^=uj3XxGm|Xv^ z(fBF(TLL$d6Gf8d5%z3;Q8K#H2W#Zs4qI7EaDED~4*pz|V~c`8l1PTKn1rui%=>_a zpJcfSzZlQ{_boGp_dT=Tdz#5#HSVL)n-5-{>&2GWOO5yB^NqC;e2oM${7IR$7=Kh7 zs!(QKdQ|E~PZ&zd{%CxR@Q6y|>GsB7UD;K`jlKMOE?xX6^4IS)nx1h_s*T^UAvCV` zfdW%#Z0>2b4J87h<><=5;(S}b`LlMbVNG2m`8LjZuf`gkDw$EOJ_oRbc~ERk=-gfZ zpQjO(|9f@BOqC~aW!DRr|jW@|FPv;bk>Wk(YD*210ux!~d1iDkHz>ADQog6&D$%Iycz6|r+ z;7f(D=YfIQe(t1=BB^m^pF?Vz^!_t4-3#FM2JdXIm_t*~WPsgnpCl8&DRB$ToRqva^VMTQSvB|g^Og0~mR#_bx$Tjt6@XvT_BT$tP zZE;<#i`0M`_?!R~zHhHF(y5tbI=@Pk%W*nxE$dh40H3 zz7Q*X)Dyky8#Tn!TN{;OFrFv&jEpsIsYh?>-dcsLvt5qk}i;O>VA21t*m6lkj}(g9lQ591Hel>6A}6;vzi$X-dmL4}m;==Dry1h;no8 z57kJ}jNs=327{Nd37ns!dv!{F5i;>JCR7ipiLe&7!1%QlW^kjAlG%e=q?O{%4(q|6 zH=5|29)o4BHOE7zCzu^5#7vP-sU*8Yh9TEUx^zXzB!84p5@7a)+VWd;o`j#^13tg$ zzO1adMzaqo$TD+vmS|-OZrFPm7%B_7?;faw1~gOM7MXxR^oN(IVIFzT^$QaA4KEoM zRyiBodr10Vx?_mm#k8eaH9Y~JHRWJo$KNm!Ta&aruk&PK_ar`Rlgv@g+}w*mPMOTQ zn)Gjl+DEx7hfja1pR-uo(j4XDcy?pnU7|vw?fxnVP&=(3J^WK25aWpB{WA4eeS672 c`rZ0MTKUQn5D^prMuZ%vxE2ZjGBFgJ-#KtC(EtDd delta 2137 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 PeXrP?IaS9$MUvVhV)NcH