feat: update kanidm and provisioning

This commit is contained in:
oddlama 2024-03-11 16:54:02 +01:00
parent ee5556401c
commit 1b0934b565
No known key found for this signature in database
GPG key ID: 14EFE510775FE39A
6 changed files with 449 additions and 395 deletions

View file

@ -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"];

View file

@ -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 = "<option>services.kanidm.provision</option> requires <option>services.kanidm.enableServer</option> 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 = "<option>services.kanidm.provision</option> requires <option>services.kanidm.enableClient</option> 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"];

View file

@ -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"
'';

View file

@ -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"

26
pkgs/kanidm-provision.nix Normal file
View file

@ -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";
};
}

Binary file not shown.