From 558fa6abc6d7ea107003d3be64c7861ec7aa2a1f Mon Sep 17 00:00:00 2001 From: oddlama Date: Sun, 10 Mar 2024 21:32:24 +0100 Subject: [PATCH] nixos/kanidm: add provisioning of groups, persons and oauth2 systems --- .../manual/release-notes/rl-2411.section.md | 2 + nixos/modules/services/security/kanidm.nix | 414 +++++++++++++- nixos/tests/all-tests.nix | 1 + nixos/tests/kanidm-provisioning.nix | 505 ++++++++++++++++++ nixos/tests/kanidm.nix | 13 +- pkgs/by-name/ka/kanidm/package.nix | 2 +- 6 files changed, 928 insertions(+), 9 deletions(-) create mode 100644 nixos/tests/kanidm-provisioning.nix diff --git a/nixos/doc/manual/release-notes/rl-2411.section.md b/nixos/doc/manual/release-notes/rl-2411.section.md index a40b91d9467e..0e66c8e9825e 100644 --- a/nixos/doc/manual/release-notes/rl-2411.section.md +++ b/nixos/doc/manual/release-notes/rl-2411.section.md @@ -332,6 +332,8 @@ - `nixosTests` now provide a working IPv6 setup for VLAN 1 by default. +- Kanidm can now be provisioned using the new [`services.kanidm.provision`] option, but requires using a patched version available via `pkgs.kanidm.withSecretProvisioning`. + - To facilitate dependency injection, the `imgui` package now builds a static archive using vcpkg' CMake rules. The derivation now installs "impl" headers selectively instead of by a wildcard. Use `imgui.src` if you just want to access the unpacked sources. diff --git a/nixos/modules/services/security/kanidm.nix b/nixos/modules/services/security/kanidm.nix index 1ab9dac48d47..8cc7e2774fd6 100644 --- a/nixos/modules/services/security/kanidm.nix +++ b/nixos/modules/services/security/kanidm.nix @@ -62,6 +62,94 @@ let #UMask = "0066"; }; + mkPresentOption = what: + lib.mkOption { + description = "Whether to ensure that this ${what} is present or absent."; + type = lib.types.bool; + default = true; + }; + + filterPresent = lib.filterAttrs (_: v: v.present); + + provisionStateJson = pkgs.writeText "provision-state.json" (builtins.toJSON { + inherit (cfg.provision) groups persons systems; + }); + + # Only recover the admin account if a password should explicitly be provisioned + # for the account. Otherwise it is not needed for provisioning. + maybeRecoverAdmin = lib.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 + ''; + + # 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" | ${lib.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 + + # Wait for the kanidm server to come online + count=0 + while ! ${lib.getExe pkgs.curl} -L --silent --max-time 1 --connect-timeout 1 --fail \ + ${lib.optionalString cfg.provision.acceptInvalidCerts "--insecure"} \ + ${cfg.provision.instanceUrl} >/dev/null + do + sleep 1 + if [[ "$count" -eq 30 ]]; then + echo "Tried for at least 30 seconds, giving up..." + exit 1 + fi + count=$((count++)) + done + + ${recoverIdmAdmin} + ${maybeRecoverAdmin} + + KANIDM_PROVISION_IDM_ADMIN_TOKEN=$KANIDM_IDM_ADMIN_PASSWORD \ + ${lib.getExe pkgs.kanidm-provision} \ + ${lib.optionalString (!cfg.provision.autoRemove) "--no-auto-remove"} \ + ${lib.optionalString cfg.provision.acceptInvalidCerts "--accept-invalid-certs"} \ + --url "${cfg.provision.instanceUrl}" \ + --state ${provisionStateJson} + ''; + + serverPort = + # ipv6: + if lib.hasInfix "]:" cfg.serverSettings.bindaddress + then lib.last (lib.splitString "]:" cfg.serverSettings.bindaddress) + else + # ipv4: + if lib.hasInfix "." cfg.serverSettings.bindaddress + then lib.last (lib.splitString ":" cfg.serverSettings.bindaddress) + # default is 8443 + else "8443"; in { options.services.kanidm = { @@ -207,10 +295,267 @@ in for possible values. ''; }; + + provision = { + enable = lib.mkEnableOption "provisioning of groups, users and oauth2 resource servers"; + + instanceUrl = lib.mkOption { + description = "The instance url to which the provisioning tool should connect."; + default = "https://localhost:${serverPort}"; + defaultText = ''"https://localhost:"''; + type = lib.types.str; + }; + + acceptInvalidCerts = lib.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 = lib.types.bool; + default = lib.hasPrefix "https://localhost:" cfg.provision.instanceUrl; + defaultText = ''lib.hasPrefix "https://localhost:" cfg.provision.instanceUrl''; + }; + + adminPasswordFile = lib.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 = lib.types.nullOr lib.types.path; + }; + + idmAdminPasswordFile = lib.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 = lib.types.nullOr lib.types.path; + }; + + autoRemove = lib.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 = lib.types.bool; + default = true; + }; + + groups = lib.mkOption { + description = "Provisioning of kanidm groups"; + default = {}; + type = lib.types.attrsOf (lib.types.submodule (groupSubmod: { + options = { + present = mkPresentOption "group"; + + members = lib.mkOption { + description = "List of kanidm entities (persons, groups, ...) which are part of this group."; + type = lib.types.listOf lib.types.str; + apply = lib.unique; + default = []; + }; + }; + config.members = lib.concatLists (lib.flip lib.mapAttrsToList cfg.provision.persons (person: personCfg: + lib.optional (personCfg.present && builtins.elem groupSubmod.config._module.args.name personCfg.groups) person + )); + })); + }; + + persons = lib.mkOption { + description = "Provisioning of kanidm persons"; + default = {}; + type = lib.types.attrsOf (lib.types.submodule { + options = { + present = mkPresentOption "person"; + + displayName = lib.mkOption { + description = "Display name"; + type = lib.types.str; + example = "My User"; + }; + + legalName = lib.mkOption { + description = "Full legal name"; + type = lib.types.nullOr lib.types.str; + example = "Jane Doe"; + default = null; + }; + + mailAddresses = lib.mkOption { + description = "Mail addresses. First given address is considered the primary address."; + type = lib.types.listOf lib.types.str; + example = ["jane.doe@example.com"]; + default = []; + }; + + groups = lib.mkOption { + description = "List of groups this person should belong to."; + type = lib.types.listOf lib.types.str; + apply = lib.unique; + default = []; + }; + }; + }); + }; + + systems.oauth2 = lib.mkOption { + description = "Provisioning of oauth2 resource servers"; + default = {}; + type = lib.types.attrsOf (lib.types.submodule { + options = { + present = mkPresentOption "oauth2 resource server"; + + public = lib.mkOption { + description = "Whether this is a public client (enforces PKCE, doesn't use a basic secret)"; + type = lib.types.bool; + default = false; + }; + + displayName = lib.mkOption { + description = "Display name"; + type = lib.types.str; + example = "Some Service"; + }; + + originUrl = lib.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 = lib.types.strMatching ".*://.*/$"; + example = "https://someservice.example.com/"; + }; + + originLanding = lib.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 = lib.types.str; + example = "https://someservice.example.com/home"; + }; + + basicSecretFile = lib.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 = lib.types.nullOr lib.types.path; + example = "/run/secrets/some-oauth2-basic-secret"; + default = null; + }; + + enableLocalhostRedirects = lib.mkOption { + description = "Allow localhost redirects. Only for public clients."; + type = lib.types.bool; + default = false; + }; + + enableLegacyCrypto = lib.mkOption { + description = "Enable legacy crypto on this client. Allows JWT signing algorthms like RS256."; + type = lib.types.bool; + default = false; + }; + + allowInsecureClientDisablePkce = lib.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! + Only for non-public clients. + ''; + type = lib.types.bool; + default = false; + }; + + preferShortUsername = lib.mkOption { + description = "Use 'name' instead of 'spn' in the preferred_username claim"; + type = lib.types.bool; + default = false; + }; + + scopeMaps = lib.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 = lib.types.attrsOf (lib.types.listOf lib.types.str); + default = {}; + }; + + supplementaryScopeMaps = lib.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 = lib.types.attrsOf (lib.types.listOf lib.types.str); + default = {}; + }; + + removeOrphanedClaimMaps = lib.mkOption { + description = "Whether claim maps not specified here but present in kanidm should be removed from kanidm."; + type = lib.types.bool; + default = true; + }; + + claimMaps = lib.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 = lib.types.attrsOf (lib.types.submodule { + options = { + joinType = lib.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 = lib.types.enum ["array" "csv" "ssv"]; + default = "array"; + }; + + valuesByGroup = lib.mkOption { + description = "Maps kanidm groups to values for the claim."; + default = {}; + type = lib.types.attrsOf (lib.types.listOf lib.types.str); + }; + }; + }); + }; + }; + }); + }; + }; }; config = lib.mkIf (cfg.enableClient || cfg.enableServer || cfg.enablePam) { - assertions = + assertions = let + entityList = type: attrs: lib.flip lib.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 = lib.foldl' (acc: { type, name }: + acc // { + ${name} = (acc.${name} or []) ++ [type]; + } + ) {} entities; + + assertGroupsKnown = opt: groups: let + knownGroups = lib.attrNames (filterPresent cfg.provision.groups); + unknownGroups = lib.subtractLists knownGroups groups; + in { + assertion = (cfg.enableServer && cfg.provision.enable) -> unknownGroups == []; + message = "${opt} refers to unknown groups: ${toString unknownGroups}"; + }; + + assertEntitiesKnown = opt: entities: let + unknownEntities = lib.subtractLists (lib.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) || (!lib.isStorePath cfg.serverSettings.tls_chain); @@ -251,7 +596,69 @@ in the instance it follows. ''; } - ]; + { + 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.provision.adminPasswordFile != null + || cfg.provision.idmAdminPasswordFile != null + || lib.any (x: x.basicSecretFile != null) (lib.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 = lib.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! + ${lib.concatLines (lib.mapAttrsToList (name: xs: " - '${name}' used as: ${toString xs}") duplicateNames)}''; + }) + ] + ++ lib.flip lib.mapAttrsToList (filterPresent cfg.provision.persons) (person: personCfg: + assertGroupsKnown "services.kanidm.provision.persons.${person}.groups" personCfg.groups + ) + ++ lib.flip lib.mapAttrsToList (filterPresent cfg.provision.groups) (group: groupCfg: + assertEntitiesKnown "services.kanidm.provision.groups.${group}.members" groupCfg.members + ) + ++ lib.concatLists (lib.flip lib.mapAttrsToList (filterPresent cfg.provision.systems.oauth2) ( + oauth2: oauth2Cfg: + [ + (assertGroupsKnown "services.kanidm.provision.systems.oauth2.${oauth2}.scopeMaps" (lib.attrNames oauth2Cfg.scopeMaps)) + (assertGroupsKnown "services.kanidm.provision.systems.oauth2.${oauth2}.supplementaryScopeMaps" (lib.attrNames oauth2Cfg.supplementaryScopeMaps)) + ] + ++ lib.concatLists (lib.flip lib.mapAttrsToList oauth2Cfg.claimMaps (claim: claimCfg: [ + (assertGroupsKnown "services.kanidm.provision.systems.oauth2.${oauth2}.claimMaps.${claim}.valuesByGroup" (lib.attrNames claimCfg.valuesByGroup)) + # At least one group must map to a value in each claim map + { + assertion = (cfg.provision.enable && cfg.enableServer) -> lib.any (xs: xs != []) (lib.attrValues claimCfg.valuesByGroup); + message = "services.kanidm.provision.systems.oauth2.${oauth2}.claimMaps.${claim} does not specify any values for any group"; + } + # Public clients cannot define a basic secret + { + assertion = (cfg.provision.enable && cfg.enableServer && oauth2Cfg.public) -> oauth2Cfg.basicSecretFile == null; + message = "services.kanidm.provision.systems.oauth2.${oauth2} is a public client and thus cannot specify a basic secret"; + } + # Public clients cannot disable PKCE + { + assertion = (cfg.provision.enable && cfg.enableServer && oauth2Cfg.public) -> !oauth2Cfg.allowInsecureClientDisablePkce; + message = "services.kanidm.provision.systems.oauth2.${oauth2} is a public client and thus cannot disable PKCE"; + } + # Non-public clients cannot enable localhost redirects + { + assertion = (cfg.provision.enable && cfg.enableServer && !oauth2Cfg.public) -> !oauth2Cfg.enableLocalhostRedirects; + message = "services.kanidm.provision.systems.oauth2.${oauth2} is a non-public client and thus cannot enable localhost redirects"; + } + ])) + )); environment.systemPackages = lib.mkIf cfg.enableClient [ cfg.package ]; @@ -277,6 +684,7 @@ in StateDirectoryMode = "0700"; RuntimeDirectory = "kanidmd"; ExecStart = "${cfg.package}/bin/kanidmd server -c ${serverConfigFile}"; + ExecStartPost = lib.mkIf cfg.provision.enable postStartScript; User = "kanidm"; Group = "kanidm"; @@ -419,6 +827,6 @@ in ]; }; - meta.maintainers = with lib.maintainers; [ erictapen Flakebi ]; + meta.maintainers = with lib.maintainers; [ erictapen Flakebi oddlama ]; meta.buildDocsInSandbox = false; } diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 41e0e07da82a..12b19700458b 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -484,6 +484,7 @@ in { k3s = handleTest ./k3s {}; kafka = handleTest ./kafka.nix {}; kanidm = handleTest ./kanidm.nix {}; + kanidm-provisioning = handleTest ./kanidm-provisioning.nix {}; karma = handleTest ./karma.nix {}; kavita = handleTest ./kavita.nix {}; kbd-setfont-decompress = handleTest ./kbd-setfont-decompress.nix {}; diff --git a/nixos/tests/kanidm-provisioning.nix b/nixos/tests/kanidm-provisioning.nix new file mode 100644 index 000000000000..c96e9647b411 --- /dev/null +++ b/nixos/tests/kanidm-provisioning.nix @@ -0,0 +1,505 @@ +import ./make-test-python.nix ( + { pkgs, ... }: + let + certs = import ./common/acme/server/snakeoil-certs.nix; + serverDomain = certs.domain; + + provisionAdminPassword = "very-strong-password-for-admin"; + provisionIdmAdminPassword = "very-strong-password-for-idm-admin"; + provisionIdmAdminPassword2 = "very-strong-alternative-password-for-idm-admin"; + in + { + name = "kanidm-provisioning"; + meta.maintainers = with pkgs.lib.maintainers; [ oddlama ]; + + nodes.provision = + { pkgs, lib, ... }: + { + services.kanidm = { + package = pkgs.kanidm.withSecretProvisioning; + enableServer = true; + serverSettings = { + origin = "https://${serverDomain}"; + domain = serverDomain; + bindaddress = "[::]:443"; + ldapbindaddress = "[::1]:636"; + tls_chain = certs."${serverDomain}".cert; + tls_key = certs."${serverDomain}".key; + }; + # So we can check whether provisioning did what we wanted + enableClient = true; + clientSettings = { + uri = "https://${serverDomain}"; + verify_ca = true; + verify_hostnames = true; + }; + }; + + specialisation.credentialProvision.configuration = + { ... }: + { + services.kanidm.provision = lib.mkForce { + enable = true; + adminPasswordFile = pkgs.writeText "admin-pw" provisionAdminPassword; + idmAdminPasswordFile = pkgs.writeText "idm-admin-pw" provisionIdmAdminPassword; + }; + }; + + specialisation.changedCredential.configuration = + { ... }: + { + services.kanidm.provision = lib.mkForce { + enable = true; + idmAdminPasswordFile = pkgs.writeText "idm-admin-pw" provisionIdmAdminPassword2; + }; + }; + + specialisation.addEntities.configuration = + { ... }: + { + services.kanidm.provision = lib.mkForce { + enable = true; + # Test whether credential recovery works without specific idmAdmin password + #idmAdminPasswordFile = + + groups.supergroup1 = { + members = [ "testgroup1" ]; + }; + + groups.testgroup1 = { }; + + persons.testuser1 = { + displayName = "Test User"; + legalName = "Jane Doe"; + mailAddresses = [ "jane.doe@example.com" ]; + groups = [ + "testgroup1" + "service1-access" + ]; + }; + + persons.testuser2 = { + displayName = "Powerful Test User"; + legalName = "Ryouiki Tenkai"; + groups = [ "service1-admin" ]; + }; + + groups.service1-access = { }; + groups.service1-admin = { }; + systems.oauth2.service1 = { + displayName = "Service One"; + originUrl = "https://one.example.com/"; + originLanding = "https://one.example.com/landing"; + basicSecretFile = pkgs.writeText "bs-service1" "very-strong-secret-for-service1"; + scopeMaps.service1-access = [ + "openid" + "email" + "profile" + ]; + supplementaryScopeMaps.service1-admin = [ "admin" ]; + claimMaps.groups = { + valuesByGroup.service1-admin = [ "admin" ]; + }; + }; + + systems.oauth2.service2 = { + displayName = "Service Two"; + originUrl = "https://two.example.com/"; + originLanding = "https://landing2.example.com/"; + # Test not setting secret + # basicSecretFile = + allowInsecureClientDisablePkce = true; + preferShortUsername = true; + }; + }; + }; + + specialisation.changeAttributes.configuration = + { ... }: + { + services.kanidm.provision = lib.mkForce { + enable = true; + # Changing admin credentials at any time should not be a problem: + idmAdminPasswordFile = pkgs.writeText "idm-admin-pw" provisionIdmAdminPassword; + + groups.supergroup1 = { + #members = ["testgroup1"]; + }; + + groups.testgroup1 = { }; + + persons.testuser1 = { + displayName = "Test User (changed)"; + legalName = "Jane Doe (changed)"; + mailAddresses = [ + "jane.doe@example.com" + "second.doe@example.com" + ]; + groups = [ + #"testgroup1" + "service1-access" + ]; + }; + + persons.testuser2 = { + displayName = "Powerful Test User (changed)"; + legalName = "Ryouiki Tenkai (changed)"; + groups = [ "service1-admin" ]; + }; + + groups.service1-access = { }; + groups.service1-admin = { }; + systems.oauth2.service1 = { + displayName = "Service One (changed)"; + originUrl = "https://changed-one.example.com/"; + originLanding = "https://changed-one.example.com/landing-changed"; + basicSecretFile = pkgs.writeText "bs-service1" "changed-very-strong-secret-for-service1"; + scopeMaps.service1-access = [ + "openid" + "email" + #"profile" + ]; + supplementaryScopeMaps.service1-admin = [ "adminchanged" ]; + claimMaps.groups = { + valuesByGroup.service1-admin = [ "adminchanged" ]; + }; + }; + + systems.oauth2.service2 = { + displayName = "Service Two (changed)"; + originUrl = "https://changed-two.example.com/"; + originLanding = "https://changed-landing2.example.com/"; + # Test not setting secret + # basicSecretFile = + allowInsecureClientDisablePkce = false; + preferShortUsername = false; + }; + }; + }; + + specialisation.removeAttributes.configuration = + { ... }: + { + services.kanidm.provision = lib.mkForce { + enable = true; + idmAdminPasswordFile = pkgs.writeText "idm-admin-pw" provisionIdmAdminPassword; + + groups.supergroup1 = { }; + + persons.testuser1 = { + displayName = "Test User (changed)"; + }; + + persons.testuser2 = { + displayName = "Powerful Test User (changed)"; + groups = [ "service1-admin" ]; + }; + + groups.service1-access = { }; + groups.service1-admin = { }; + systems.oauth2.service1 = { + displayName = "Service One (changed)"; + originUrl = "https://changed-one.example.com/"; + originLanding = "https://changed-one.example.com/landing-changed"; + basicSecretFile = pkgs.writeText "bs-service1" "changed-very-strong-secret-for-service1"; + # Removing maps requires setting them to the empty list + scopeMaps.service1-access = [ ]; + supplementaryScopeMaps.service1-admin = [ ]; + }; + + systems.oauth2.service2 = { + displayName = "Service Two (changed)"; + originUrl = "https://changed-two.example.com/"; + originLanding = "https://changed-landing2.example.com/"; + }; + }; + }; + + specialisation.removeEntities.configuration = + { ... }: + { + services.kanidm.provision = lib.mkForce { + enable = true; + idmAdminPasswordFile = pkgs.writeText "idm-admin-pw" provisionIdmAdminPassword; + }; + }; + + security.pki.certificateFiles = [ certs.ca.cert ]; + + networking.hosts."::1" = [ serverDomain ]; + networking.firewall.allowedTCPPorts = [ 443 ]; + + users.users.kanidm.shell = pkgs.bashInteractive; + + environment.systemPackages = with pkgs; [ + kanidm + openldap + ripgrep + jq + ]; + }; + + testScript = + { nodes, ... }: + let + # We need access to the config file in the test script. + filteredConfig = pkgs.lib.converge (pkgs.lib.filterAttrsRecursive ( + _: v: v != null + )) nodes.provision.services.kanidm.serverSettings; + serverConfigFile = (pkgs.formats.toml { }).generate "server.toml" filteredConfig; + + specialisations = "${nodes.provision.system.build.toplevel}/specialisation"; + in + '' + import re + + def assert_contains(haystack, needle): + if needle not in haystack: + print("The haystack that will cause the following exception is:") + print("---") + print(haystack) + print("---") + raise Exception(f"Expected string '{needle}' was not found") + + def assert_matches(haystack, expr): + if not re.search(expr, haystack): + print("The haystack that will cause the following exception is:") + print("---") + print(haystack) + print("---") + raise Exception(f"Expected regex '{expr}' did not match") + + def assert_lacks(haystack, needle): + if needle in haystack: + print("The haystack that will cause the following exception is:") + print("---") + print(haystack, end="") + print("---") + raise Exception(f"Unexpected string '{needle}' was found") + + provision.start() + + def provision_login(pw): + provision.wait_for_unit("kanidm.service") + provision.wait_until_succeeds("curl -Lsf https://${serverDomain} | grep Kanidm") + if pw is None: + pw = provision.succeed("su - kanidm -c 'kanidmd recover-account -c ${serverConfigFile} idm_admin 2>&1 | rg -o \'[A-Za-z0-9]{48}\' '").strip().removeprefix("'").removesuffix("'") + out = provision.succeed(f"KANIDM_PASSWORD={pw} kanidm login -D idm_admin") + assert_contains(out, "Login Success for idm_admin") + + with subtest("Test Provisioning - setup"): + provision_login(None) + provision.succeed("kanidm logout -D idm_admin") + + with subtest("Test Provisioning - credentialProvision"): + provision.succeed('${specialisations}/credentialProvision/bin/switch-to-configuration test') + provision_login("${provisionIdmAdminPassword}") + + # Test provisioned admin pw + out = provision.succeed("KANIDM_PASSWORD=${provisionAdminPassword} kanidm login -D admin") + assert_contains(out, "Login Success for admin") + provision.succeed("kanidm logout -D admin") + provision.succeed("kanidm logout -D idm_admin") + + with subtest("Test Provisioning - changedCredential"): + provision.succeed('${specialisations}/changedCredential/bin/switch-to-configuration test') + provision_login("${provisionIdmAdminPassword2}") + provision.succeed("kanidm logout -D idm_admin") + + with subtest("Test Provisioning - addEntities"): + provision.succeed('${specialisations}/addEntities/bin/switch-to-configuration test') + # Unspecified idm admin password + provision_login(None) + + out = provision.succeed("kanidm group get testgroup1") + assert_contains(out, "name: testgroup1") + + out = provision.succeed("kanidm group get supergroup1") + assert_contains(out, "name: supergroup1") + assert_contains(out, "member: testgroup1") + + out = provision.succeed("kanidm person get testuser1") + assert_contains(out, "name: testuser1") + assert_contains(out, "displayname: Test User") + assert_contains(out, "legalname: Jane Doe") + assert_contains(out, "mail: jane.doe@example.com") + assert_contains(out, "memberof: testgroup1") + assert_contains(out, "memberof: service1-access") + + out = provision.succeed("kanidm person get testuser2") + assert_contains(out, "name: testuser2") + assert_contains(out, "displayname: Powerful Test User") + assert_contains(out, "legalname: Ryouiki Tenkai") + assert_contains(out, "memberof: service1-admin") + assert_lacks(out, "mail:") + + out = provision.succeed("kanidm group get service1-access") + assert_contains(out, "name: service1-access") + + out = provision.succeed("kanidm group get service1-admin") + assert_contains(out, "name: service1-admin") + + out = provision.succeed("kanidm system oauth2 get service1") + assert_contains(out, "name: service1") + assert_contains(out, "displayname: Service One") + assert_contains(out, "oauth2_rs_origin: https://one.example.com/") + assert_contains(out, "oauth2_rs_origin_landing: https://one.example.com/landing") + assert_matches(out, 'oauth2_rs_scope_map: service1-access.*{"email", "openid", "profile"}') + assert_matches(out, 'oauth2_rs_sup_scope_map: service1-admin.*{"admin"}') + assert_matches(out, 'oauth2_rs_claim_map: groups:.*"admin"') + + out = provision.succeed("kanidm system oauth2 show-basic-secret service1") + assert_contains(out, "very-strong-secret-for-service1") + + out = provision.succeed("kanidm system oauth2 get service2") + assert_contains(out, "name: service2") + assert_contains(out, "displayname: Service Two") + assert_contains(out, "oauth2_rs_origin: https://two.example.com/") + assert_contains(out, "oauth2_rs_origin_landing: https://landing2.example.com/") + assert_contains(out, "oauth2_allow_insecure_client_disable_pkce: true") + assert_contains(out, "oauth2_prefer_short_username: true") + + provision.succeed("kanidm logout -D idm_admin") + + with subtest("Test Provisioning - changeAttributes"): + provision.succeed('${specialisations}/changeAttributes/bin/switch-to-configuration test') + provision_login("${provisionIdmAdminPassword}") + + out = provision.succeed("kanidm group get testgroup1") + assert_contains(out, "name: testgroup1") + + out = provision.succeed("kanidm group get supergroup1") + assert_contains(out, "name: supergroup1") + assert_lacks(out, "member: testgroup1") + + out = provision.succeed("kanidm person get testuser1") + assert_contains(out, "name: testuser1") + assert_contains(out, "displayname: Test User (changed)") + assert_contains(out, "legalname: Jane Doe (changed)") + assert_contains(out, "mail: jane.doe@example.com") + assert_contains(out, "mail: second.doe@example.com") + assert_lacks(out, "memberof: testgroup1") + assert_contains(out, "memberof: service1-access") + + out = provision.succeed("kanidm person get testuser2") + assert_contains(out, "name: testuser2") + assert_contains(out, "displayname: Powerful Test User (changed)") + assert_contains(out, "legalname: Ryouiki Tenkai (changed)") + assert_contains(out, "memberof: service1-admin") + assert_lacks(out, "mail:") + + out = provision.succeed("kanidm group get service1-access") + assert_contains(out, "name: service1-access") + + out = provision.succeed("kanidm group get service1-admin") + assert_contains(out, "name: service1-admin") + + out = provision.succeed("kanidm system oauth2 get service1") + assert_contains(out, "name: service1") + assert_contains(out, "displayname: Service One (changed)") + assert_contains(out, "oauth2_rs_origin: https://changed-one.example.com/") + assert_contains(out, "oauth2_rs_origin_landing: https://changed-one.example.com/landing") + assert_matches(out, 'oauth2_rs_scope_map: service1-access.*{"email", "openid"}') + assert_matches(out, 'oauth2_rs_sup_scope_map: service1-admin.*{"adminchanged"}') + assert_matches(out, 'oauth2_rs_claim_map: groups:.*"adminchanged"') + + out = provision.succeed("kanidm system oauth2 show-basic-secret service1") + assert_contains(out, "changed-very-strong-secret-for-service1") + + out = provision.succeed("kanidm system oauth2 get service2") + assert_contains(out, "name: service2") + assert_contains(out, "displayname: Service Two (changed)") + assert_contains(out, "oauth2_rs_origin: https://changed-two.example.com/") + assert_contains(out, "oauth2_rs_origin_landing: https://changed-landing2.example.com/") + assert_lacks(out, "oauth2_allow_insecure_client_disable_pkce: true") + assert_lacks(out, "oauth2_prefer_short_username: true") + + provision.succeed("kanidm logout -D idm_admin") + + with subtest("Test Provisioning - removeAttributes"): + provision.succeed('${specialisations}/removeAttributes/bin/switch-to-configuration test') + provision_login("${provisionIdmAdminPassword}") + + out = provision.succeed("kanidm group get testgroup1") + assert_lacks(out, "name: testgroup1") + + out = provision.succeed("kanidm group get supergroup1") + assert_contains(out, "name: supergroup1") + assert_lacks(out, "member: testgroup1") + + out = provision.succeed("kanidm person get testuser1") + assert_contains(out, "name: testuser1") + assert_contains(out, "displayname: Test User (changed)") + assert_lacks(out, "legalname: Jane Doe (changed)") + assert_lacks(out, "mail: jane.doe@example.com") + assert_lacks(out, "mail: second.doe@example.com") + assert_lacks(out, "memberof: testgroup1") + assert_lacks(out, "memberof: service1-access") + + out = provision.succeed("kanidm person get testuser2") + assert_contains(out, "name: testuser2") + assert_contains(out, "displayname: Powerful Test User (changed)") + assert_lacks(out, "legalname: Ryouiki Tenkai (changed)") + assert_contains(out, "memberof: service1-admin") + assert_lacks(out, "mail:") + + out = provision.succeed("kanidm group get service1-access") + assert_contains(out, "name: service1-access") + + out = provision.succeed("kanidm group get service1-admin") + assert_contains(out, "name: service1-admin") + + out = provision.succeed("kanidm system oauth2 get service1") + assert_contains(out, "name: service1") + assert_contains(out, "displayname: Service One (changed)") + assert_contains(out, "oauth2_rs_origin: https://changed-one.example.com/") + assert_contains(out, "oauth2_rs_origin_landing: https://changed-one.example.com/landing") + assert_lacks(out, "oauth2_rs_scope_map") + assert_lacks(out, "oauth2_rs_sup_scope_map") + assert_lacks(out, "oauth2_rs_claim_map") + + out = provision.succeed("kanidm system oauth2 show-basic-secret service1") + assert_contains(out, "changed-very-strong-secret-for-service1") + + out = provision.succeed("kanidm system oauth2 get service2") + assert_contains(out, "name: service2") + assert_contains(out, "displayname: Service Two (changed)") + assert_contains(out, "oauth2_rs_origin: https://changed-two.example.com/") + assert_contains(out, "oauth2_rs_origin_landing: https://changed-landing2.example.com/") + assert_lacks(out, "oauth2_allow_insecure_client_disable_pkce: true") + assert_lacks(out, "oauth2_prefer_short_username: true") + + provision.succeed("kanidm logout -D idm_admin") + + with subtest("Test Provisioning - removeEntities"): + provision.succeed('${specialisations}/removeEntities/bin/switch-to-configuration test') + provision_login("${provisionIdmAdminPassword}") + + out = provision.succeed("kanidm group get testgroup1") + assert_lacks(out, "name: testgroup1") + + out = provision.succeed("kanidm group get supergroup1") + assert_lacks(out, "name: supergroup1") + + out = provision.succeed("kanidm person get testuser1") + assert_lacks(out, "name: testuser1") + + out = provision.succeed("kanidm person get testuser2") + assert_lacks(out, "name: testuser2") + + out = provision.succeed("kanidm group get service1-access") + assert_lacks(out, "name: service1-access") + + out = provision.succeed("kanidm group get service1-admin") + assert_lacks(out, "name: service1-admin") + + out = provision.succeed("kanidm system oauth2 get service1") + assert_lacks(out, "name: service1") + + out = provision.succeed("kanidm system oauth2 get service2") + assert_lacks(out, "name: service2") + + provision.succeed("kanidm logout -D idm_admin") + ''; + } +) diff --git a/nixos/tests/kanidm.nix b/nixos/tests/kanidm.nix index 8ed9af63f1d4..a2f4b98a728c 100644 --- a/nixos/tests/kanidm.nix +++ b/nixos/tests/kanidm.nix @@ -9,9 +9,9 @@ import ./make-test-python.nix ({ pkgs, ... }: in { name = "kanidm"; - meta.maintainers = with pkgs.lib.maintainers; [ erictapen Flakebi ]; + meta.maintainers = with pkgs.lib.maintainers; [ erictapen Flakebi oddlama ]; - nodes.server = { config, pkgs, lib, ... }: { + nodes.server = { pkgs, ... }: { services.kanidm = { enableServer = true; serverSettings = { @@ -34,7 +34,7 @@ import ./make-test-python.nix ({ pkgs, ... }: environment.systemPackages = with pkgs; [ kanidm openldap ripgrep ]; }; - nodes.client = { pkgs, nodes, ... }: { + nodes.client = { nodes, ... }: { services.kanidm = { enableClient = true; clientSettings = { @@ -62,10 +62,10 @@ import ./make-test-python.nix ({ pkgs, ... }: (pkgs.lib.filterAttrsRecursive (_: v: v != null)) nodes.server.services.kanidm.serverSettings; serverConfigFile = (pkgs.formats.toml { }).generate "server.toml" filteredConfig; - in '' - start_all() + server.start() + client.start() server.wait_for_unit("kanidm.service") client.systemctl("start network-online.target") client.wait_for_unit("network-online.target") @@ -122,5 +122,8 @@ import ./make-test-python.nix ({ pkgs, ... }: client.wait_until_succeeds("systemctl is-active user@$(id -u testuser).service") client.send_chars("touch done\n") client.wait_for_file("/home/testuser@${serverDomain}/done") + + server.shutdown() + client.shutdown() ''; }) diff --git a/pkgs/by-name/ka/kanidm/package.nix b/pkgs/by-name/ka/kanidm/package.nix index cc80eb99ada3..dc032583ae07 100644 --- a/pkgs/by-name/ka/kanidm/package.nix +++ b/pkgs/by-name/ka/kanidm/package.nix @@ -107,7 +107,7 @@ rustPlatform.buildRustPackage rec { passthru = { tests = { - inherit (nixosTests) kanidm; + inherit (nixosTests) kanidm kanidm-provisioning; }; updateScript = nix-update-script { };