kanidm-setup, all the way: desk groups from the content, no prefix, onboarding that works, the first person, redaktør, and Gitea sign-in

The desk groups are read from the content's qualifies: and named as it
names them - this Kanidm is the vel's own - with the old tomtervel_*
groups renamed in place. The onboarding account manages every desk
group, since adding a member takes that right (measured: 404 without).
Sign-in scopes to idm_all_persons. SEED_ADMIN_EMAIL and
RESPONSIBLE_GROUP (redaktor) reach portal.env, and bootstrap no longer
drops the onboarding token when it rewrites portal.env. A second OAuth2
client lets redaktor sign in to prosjekt.klingenbergbygg.no; on the
Gitea host the script adds that sign-in source itself.

The login page's own name and logo need the admin account; the script
now says so rather than failing with 'Item not found'. .env.example
quotes SITE_NAME, which has a space and broke sourcing it.

Tested end to end against a local Kanidm 1.11.2, including a rerun and
a legacy tomtervel_kasserer with a member.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
This commit is contained in:
bl
2026-09-29 15:45:41 +02:00
co-authored by Claude Opus 5.5
parent 415dea7230
commit b653536391
5 changed files with 207 additions and 65 deletions
+19 -1
View File
@@ -14,11 +14,29 @@ PORTAL_RELEASE=v0.5.3
# The content this instance serves, and reloads live on every push. # The content this instance serves, and reloads live on every push.
CONTENT_REPO=https://prosjekt.klingenbergbygg.no/tomtervel/questions CONTENT_REPO=https://prosjekt.klingenbergbygg.no/tomtervel/questions
CONTENT_BRANCH=main CONTENT_BRANCH=main
SITE_NAME=Tomter Vel SITE_NAME="Tomter Vel"
# Generated once by bootstrap.sh if left empty. # Generated once by bootstrap.sh if left empty.
NATS_PASSWORD= NATS_PASSWORD=
# The first person in: portal invites this address into the site's most
# privileged group (the board, on the vel - the group whose desk may
# invite into the most groups) and mails them the one-time link, once.
# Everyone else they invite themselves, from their desk.
SEED_ADMIN_EMAIL=
SEED_ADMIN_NAME=
# The people who ask the questions: everyone a page names as
# responsible gets an account (portal makes it at start and mails them)
# and joins this group. Kanidm names are ASCII: redaktor, "Redaktør".
RESPONSIBLE_GROUP=redaktor
# A project Gitea they may sign in to with the same account: kanidm-setup
# makes its OAuth2 client (only RESPONSIBLE_GROUP may use it) and, on the
# host that runs that Gitea, adds the sign-in source. Empty: no Gitea.
GITEA_URL=https://prosjekt.klingenbergbygg.no
GITEA_AUTH_NAME=tomtervel
# Filled in by bootstrap.sh after it creates the Kanidm client. # Filled in by bootstrap.sh after it creates the Kanidm client.
OAUTH2_CLIENT_ID=tomtervel-portal OAUTH2_CLIENT_ID=tomtervel-portal
OAUTH2_CLIENT_SECRET= OAUTH2_CLIENT_SECRET=
+1
View File
@@ -4,3 +4,4 @@ kanidm/server.toml
nats/nats.conf nats/nats.conf
certs/ certs/
.kanidm-recovered .kanidm-recovered
gitea-oauth.secret
+35
View File
@@ -64,6 +64,36 @@ Then `sudo systemctl reload caddy`, point the content repo's `lint-and-reload`
reload step at `nats://127.0.0.1:4223`, and retire the old systemd portal: reload step at `nats://127.0.0.1:4223`, and retire the old systemd portal:
`sudo systemctl disable --now app@tomtervel-portal`. `sudo systemctl disable --now app@tomtervel-portal`.
## Accounts, all the way
`kanidm-setup.sh` sets up everything portal needs from this Kanidm,
and is safe to rerun:
- **Desk groups, named as the content names them.** Read from every
`qualifies:` under `questions/` in the content repo, so the list
cannot drift from the pages. This Kanidm is the vel's own, so there is
no prefix: the board's group is `styret`. Groups from before are
renamed in place (`tomtervel_styret` → `styret`), members and all.
- **Sign-in.** The OAuth2 client's scope map is `idm_all_persons`:
everyone here is the vel's, and what they see is decided by their
desk groups in the `groups` claim.
- **Onboarding.** The `portal-onboarding` service account manages
every desk group, so an invite from a desk and a `grants:` can add
people to them. Its token goes into `portal.env`.
- **The first person in.** `SEED_ADMIN_EMAIL` in `.env`: at start,
portal invites them into the most privileged group (the board) and
mails the link, once. Everyone else they invite from their desk.
- **The people who ask the questions.** Everyone a page names as
`responsible` gets an account at start, a mail saying they are listed
as responsible for asking that question, and a place in
`RESPONSIBLE_GROUP` (`redaktor`). Signed in, they can suggest changes
to their own pages.
- **The project Gitea.** With `GITEA_URL` set, `redaktor` may sign in
to it with the same account: its own OAuth2 client, only for that
group. Run on the Gitea host, the script adds the sign-in source
itself (and requires the `redaktor` claim there too); elsewhere it
leaves the secret in `gitea-oauth.secret` and prints the one command.
## People and desks ## People and desks
Each group in the content (`qualifies:` under `questions/`) is a Each group in the content (`qualifies:` under `questions/`) is a
@@ -168,6 +198,11 @@ older server does not have, so `system domain set-displayname` and
about versions. The CLI warns on every call; the warning is worth about versions. The CLI warns on every call; the warning is worth
reading. reading.
The same "Item not found" also comes back when the versions do match
and the account is `idm_admin`: the instance's own name and logo are
system settings only `admin` may change. `kanidm-setup.sh` sets them
when an `admin` session exists and says so when it does not.
The host's CLI comes from pacman and moves on its own, so the image is The host's CLI comes from pacman and moves on its own, so the image is
what to bump: `image: kanidm/server:<version>` in `compose.yml`, then what to bump: `image: kanidm/server:<version>` in `compose.yml`, then
`podman compose up -d kanidm`. `podman compose up -d kanidm`.
+7
View File
@@ -21,6 +21,9 @@ fi
# Rendered configs (gitignored). # Rendered configs (gitignored).
sed "s|\${ID_HOST}|$ID_HOST|g" kanidm/server.toml.tpl > kanidm/server.toml sed "s|\${ID_HOST}|$ID_HOST|g" kanidm/server.toml.tpl > kanidm/server.toml
sed "s|\${NATS_PASSWORD}|$NATS_PASSWORD|g" nats/nats.conf.tpl > nats/nats.conf sed "s|\${NATS_PASSWORD}|$NATS_PASSWORD|g" nats/nats.conf.tpl > nats/nats.conf
# The onboarding token is written by kanidm-setup.sh, once; a rerun of
# this script must not lose it, or every invite fails closed again.
kept_token=$(grep '^KANIDM_API_TOKEN=' portal.env 2>/dev/null | head -1 || true)
cat > portal.env <<EOF cat > portal.env <<EOF
NATS_URL=nats://portal:$NATS_PASSWORD@nats:4222 NATS_URL=nats://portal:$NATS_PASSWORD@nats:4222
KANIDM_URL=https://$ID_HOST KANIDM_URL=https://$ID_HOST
@@ -31,7 +34,11 @@ COOKIE_SECURE=true
CONTENT_REPO=$CONTENT_REPO CONTENT_REPO=$CONTENT_REPO
CONTENT_BRANCH=$CONTENT_BRANCH CONTENT_BRANCH=$CONTENT_BRANCH
SITE_NAME=$SITE_NAME SITE_NAME=$SITE_NAME
SEED_ADMIN_EMAIL=${SEED_ADMIN_EMAIL:-}
SEED_ADMIN_NAME=${SEED_ADMIN_NAME:-}
RESPONSIBLE_GROUP=${RESPONSIBLE_GROUP:-}
EOF EOF
[ -n "$kept_token" ] && echo "$kept_token" >> portal.env
chmod 600 portal.env chmod 600 portal.env
# Kanidm's internal certificate: Caddy holds the public one. # Kanidm's internal certificate: Caddy holds the public one.
+145 -64
View File
@@ -1,105 +1,186 @@
#!/bin/sh #!/bin/sh
# The portal's OAuth2 client and one Kanidm group per desk, mapped into # Everything portal needs from this Kanidm, all the way: the OAuth2
# the `groups` claim under the names the pages use in `qualifies`. # client people sign in through, one group per desk named exactly as
# Portal reads that claim at login (portal src/auth.rs). Rerunnable. # the content names it, and the service account portal onboards people
# with - so that inviting someone from a desk, a decision that grants a
# group, and the first person in (SEED_ADMIN_EMAIL) all simply work.
# Rerunnable: each step skips what is already there.
# #
# Logs in first (interactive, idm_admin's password from bootstrap.sh), # Logs in first if needed (interactive, idm_admin's password from
# then writes the client secret into .env and portal.env and restarts # bootstrap.sh), writes the client secret and the onboarding token into
# the portal. Needs the kanidm CLI on this machine. # portal.env, and restarts portal. Needs the kanidm CLI and curl.
set -eu set -eu
cd "$(dirname "$0")" cd "$(dirname "$0")"
. ./.env . ./.env
K="kanidm -D idm_admin -H https://$ID_HOST" K="kanidm -D idm_admin -H https://$ID_HOST"
C=$OAUTH2_CLIENT_ID C=$OAUTH2_CLIENT_ID
SA=portal-onboarding
# Already signed in? Then do not ask again: this script is meant to be # Already signed in? Then do not ask again: this script is meant to be
# rerunnable, and the session outlives a single run. # rerunnable, and the session outlives a single run.
$K self whoami >/dev/null 2>&1 || $K login $K self whoami >/dev/null 2>&1 || $K login
has_client() { $K system oauth2 get "$1" 2>/dev/null | grep -q '^name:'; } has_client() { $K system oauth2 get "$1" 2>/dev/null | grep -q '^name:'; }
has_group() { $K group get "$1" 2>/dev/null | grep -q '^name:'; } has_group() { $K group get "$1" 2>/dev/null | grep -q '^name:'; }
has_account() { $K service-account get "$1" 2>/dev/null | grep -q '^name:'; }
has_client $C || $K system oauth2 create $C "$SITE_NAME" "https://$PORTAL_HOST" # The desk groups are whatever the content gates pages on: every
$K system oauth2 add-redirect-url $C "https://$PORTAL_HOST/auth/callback" || true # `qualifies:` under questions/ in the content repo. Read from the repo,
# so this list cannot drift from the pages. This Kanidm is the vel's
# own, so a group is called exactly what the content calls it.
host=$(printf '%s' "$CONTENT_REPO" | sed -E 's|^(https?://[^/]+)/.*|\1|')
path=$(printf '%s' "$CONTENT_REPO" | sed -E 's|^https?://[^/]+/||')
tree=$(curl -sf "$host/api/v1/repos/$path/git/trees/$CONTENT_BRANCH?recursive=true&per_page=1000") || {
echo "could not read the content repo's file list ($CONTENT_REPO)" >&2; exit 1; }
groups=$(printf '%s' "$tree" | grep -oE '"path":"questions/[^"]+\.yaml"' | sed -E 's/^"path":"//; s/"$//' | while read -r f; do
curl -sf "$CONTENT_REPO/raw/branch/$CONTENT_BRANCH/$f" | sed -nE 's/^qualifies:[[:space:]]*"?([a-z0-9_-]+)"?[[:space:]]*$/\1/p'
done | sort -u)
[ -n "$groups" ] || { echo "the content gates no page on a group - nothing for a desk to sign in to" >&2; exit 1; }
echo "desk groups from the content: $(echo $groups)"
# The desk groups: every group the content gates a directory on. Keep # The onboarding account. In idm_people_on_boarding it may create a
# this list equal to the `qualifies` values under questions/. # person and issue a first credential reset and nothing else, so its
has_group tomtervel_members || $K group create tomtervel_members # token cannot touch anyone's existing credentials; idm_people_pii_read
for g in kasserer styret komiteer nabohjelp arrangementer elvesti horingsinstans lekeplasser miljogate pendlerforhold trafikk; do # lets an invite find someone who already has an account by their
has_group tomtervel_$g || $K group create tomtervel_$g # email and add them instead of making a second account. Portal creates
$K group add-members tomtervel_members tomtervel_$g # the person with their address in the same call, because this account
# may create but not modify.
if ! has_account $SA; then
$K service-account create $SA "Portal onboarding" idm_admin
fi
$K group add-members idm_people_on_boarding $SA >/dev/null
$K group add-members idm_people_pii_read $SA >/dev/null || true
# One group per desk, managed by the onboarding account: adding a
# member to a group takes the right to manage that group, and a desk
# invite or a `grants:` does exactly that. Groups made before this
# script dropped its old `tomtervel_` prefix are renamed in place, so
# their members and claim maps come along.
for g in $groups; do
if ! has_group "$g" && has_group "tomtervel_$g"; then
$K group rename "tomtervel_$g" "$g"
fi
has_group "$g" || $K group create "$g" $SA
$K group set-entry-manager "$g" $SA >/dev/null
done done
# Portal asks for openid, profile and email; groups arrive as a claim. # The people who ask the questions: everyone a page names as
$K system oauth2 update-scope-map $C tomtervel_members openid profile email # responsible. Portal puts them here itself at start (RESPONSIBLE_GROUP);
for g in kasserer styret komiteer nabohjelp arrangementer elvesti horingsinstans lekeplasser miljogate pendlerforhold trafikk; do # the group only has to exist and be portal's to fill. Kanidm names are
$K system oauth2 update-claim-map $C groups tomtervel_$g $g # ASCII, so "Redaktør" is its description.
if [ -n "${RESPONSIBLE_GROUP:-}" ]; then
has_group "$RESPONSIBLE_GROUP" || $K group create "$RESPONSIBLE_GROUP" $SA
$K group set-entry-manager "$RESPONSIBLE_GROUP" $SA >/dev/null
$K group set-description "$RESPONSIBLE_GROUP" "${RESPONSIBLE_GROUP_LABEL:-Redaktør}: står som ansvarlig for et spørsmål på siden" >/dev/null || true
fi
# The OAuth2 client. Everyone in this Kanidm is the vel's, so anyone
# may sign in; what they see is decided by their desk groups, which
# arrive in the `groups` claim under their own names.
has_client $C || $K system oauth2 create $C "$SITE_NAME" "https://$PORTAL_HOST"
$K system oauth2 add-redirect-url $C "https://$PORTAL_HOST/auth/callback" 2>/dev/null || true
$K system oauth2 update-scope-map $C idm_all_persons openid profile email
for g in $groups; do
$K system oauth2 update-claim-map $C groups "$g" "$g"
done done
$K system oauth2 update-claim-map-join $C groups array $K system oauth2 update-claim-map-join $C groups array
# The old umbrella group the scope map used to name, if this Kanidm
# still has it: idm_all_persons does its job now.
if has_group tomtervel_members; then
$K system oauth2 delete-scope-map $C tomtervel_members 2>/dev/null || true
$K group delete tomtervel_members
fi
# What a member sees when they land on the login page. Out of the box # What a member sees when they land on the login page. Out of the box
# Kanidm says "kanidm" and shows its own mark, which tells a neighbour # Kanidm says "kanidm" and shows its own mark, which tells a neighbour
# nothing about whose site they are signing in to - and a login page # nothing about whose site they are signing in to. The logo comes from
# that looks like it belongs to no one is the kind of thing people # the content repo, the same file site.yaml uses as the favicon.
# rightly hesitate over.
# #
# The logo comes from the content repo, because that is already where # The client's name and logo are idm_admin's to set. The instance's own
# this site's identity lives: the same file site.yaml uses as the # (the login page's heading and mark) are system settings only `admin`
# favicon. Branding follows the content, not this repo. # may change - idm_admin gets a bare "Item not found" - so that half
$K system domain set-displayname "$SITE_NAME" || true # runs when an admin session exists, and otherwise says what to run.
$K system oauth2 set-displayname $C "$SITE_NAME" || true A="kanidm -D admin -H https://$ID_HOST"
$K system oauth2 set-displayname $C "$SITE_NAME" >/dev/null || true
logo=$(mktemp --suffix=.svg) logo=$(mktemp --suffix=.svg)
if curl -sfL "$CONTENT_REPO/raw/branch/$CONTENT_BRANCH/images/logo.svg" -o "$logo" && [ -s "$logo" ]; then if curl -sfL "$CONTENT_REPO/raw/branch/$CONTENT_BRANCH/images/logo.svg" -o "$logo" && [ -s "$logo" ]; then
$K system domain set-image "$logo" svg || echo "note: could not set the instance logo" $K system oauth2 set-image $C "$logo" svg >/dev/null || echo "note: could not set the client logo"
$K system oauth2 set-image $C "$logo" svg || echo "note: could not set the client logo"
else else
echo "note: no images/logo.svg in the content repo - leaving the default mark" echo "note: no images/logo.svg in the content repo - leaving the default mark"
: > "$logo"
fi
if $A self whoami >/dev/null 2>&1; then
$A system domain set-displayname "$SITE_NAME" >/dev/null || echo "note: could not set the login page's name"
[ -s "$logo" ] && { $A system domain set-image "$logo" svg >/dev/null || echo "note: could not set the login page's logo"; }
else
echo "note: the login page's own name and logo need the admin account (bootstrap.sh printed its password):"
echo " $A login # then rerun this script"
fi fi
rm -f "$logo" rm -f "$logo"
secret=$($K system oauth2 show-basic-secret $C 2>/dev/null | tail -1) # Sign-in to a project Gitea for the people who ask the questions: its
sed -i "s|^OAUTH2_CLIENT_SECRET=.*|OAUTH2_CLIENT_SECRET=$secret|" .env portal.env # own OAuth2 client, which only RESPONSIBLE_GROUP may use, carrying that
# group in the `groups` claim so Gitea can require it as well. Gitea
# Onboarding from a desk. A committee inviting a neighbour, and any # does not do PKCE (Kanidm's own Gitea example turns it off), and short
# state whose `grants:` makes someone a member, both go through Kanidm # usernames keep Gitea accounts "kari", not "kari@id.vel...".
# as this service account - not as a person, and not as an admin. It is if [ -n "${GITEA_URL:-}" ] && [ -n "${RESPONSIBLE_GROUP:-}" ]; then
# in idm_people_on_boarding, which may create people and issue a first G=${GITEA_OAUTH2_CLIENT_ID:-prosjekt}
# credential reset and nothing else, so the token cannot touch anyone's N=${GITEA_AUTH_NAME:-tomtervel}
# existing credentials. idm_people_pii_read lets an invite find someone has_client $G || $K system oauth2 create $G "${GITEA_LABEL:-Prosjekt}" "$GITEA_URL/user/login"
# who already has an account by their email, and add them to the group $K system oauth2 add-redirect-url $G "$GITEA_URL/user/oauth2/$N/callback" 2>/dev/null || true
# instead of making a second account for the same person. $K system oauth2 update-scope-map $G "$RESPONSIBLE_GROUP" openid profile email >/dev/null
# $K system oauth2 update-claim-map $G groups "$RESPONSIBLE_GROUP" "$RESPONSIBLE_GROUP" >/dev/null
# The token is shown once, by Kanidm, at creation. Written straight into $K system oauth2 update-claim-map-join $G groups array >/dev/null
# the env files here and never printed. $K system oauth2 warning-insecure-client-disable-pkce $G >/dev/null
SA=portal-onboarding $K system oauth2 prefer-short-username $G >/dev/null
if ! $K service-account get $SA >/dev/null 2>&1; then gsecret=$($K system oauth2 show-basic-secret $G 2>/dev/null | tail -1)
$K service-account create $SA "Portal onboarding" idm_admin discover="https://$ID_HOST/oauth2/openid/$G/.well-known/openid-configuration"
$K group add-members idm_people_on_boarding $SA # On the host that runs that Gitea, add or update its source here;
$K group add-members idm_people_pii_read $SA || true # anywhere else, leave the secret in a file only this user can read.
if command -v gitea >/dev/null 2>&1 && sudo -n -u gitea true 2>/dev/null; then
GT="sudo -n -u gitea gitea --config ${GITEA_CONFIG:-/etc/gitea/app.ini} admin auth"
id=$($GT list 2>/dev/null | awk -v n="$N" '$2 == n { print $1 }')
if [ -n "$id" ]; then
$GT update-oauth --id "$id" --key "$G" --secret "$gsecret" --auto-discover-url "$discover" \
--required-claim-name groups --required-claim-value "$RESPONSIBLE_GROUP" --scopes "openid profile email" >/dev/null
echo "Gitea sign-in source '$N' updated"
else
$GT add-oauth --name "$N" --provider openidConnect --key "$G" --secret "$gsecret" --auto-discover-url "$discover" \
--required-claim-name groups --required-claim-value "$RESPONSIBLE_GROUP" --scopes "openid profile email" >/dev/null
echo "Gitea sign-in source '$N' added: $GITEA_URL/user/login has a 'Sign in with $N' button"
fi fi
else
umask 077; printf '%s\n' "$gsecret" > gitea-oauth.secret
echo "note: no gitea CLI here - on the Gitea host, add the source with the secret in gitea-oauth.secret:"
echo " gitea admin auth add-oauth --name $N --provider openidConnect --key $G --secret \"\$(cat gitea-oauth.secret)\" \\"
echo " --auto-discover-url $discover --required-claim-name groups --required-claim-value $RESPONSIBLE_GROUP --scopes 'openid profile email'"
fi
fi
# Secrets into portal.env, never printed. The token is shown once, by
# Kanidm, at creation; a rerun keeps the one portal.env already has.
setenv() { if grep -q "^$1=" portal.env; then sed -i "s|^$1=.*|$1=$2|" portal.env; else printf '%s=%s\n' "$1" "$2" >> portal.env; fi; }
secret=$($K system oauth2 show-basic-secret $C 2>/dev/null | tail -1)
sed -i "s|^OAUTH2_CLIENT_SECRET=.*|OAUTH2_CLIENT_SECRET=$secret|" .env
setenv OAUTH2_CLIENT_SECRET "$secret"
if ! grep -q '^KANIDM_API_TOKEN=.' portal.env 2>/dev/null; then if ! grep -q '^KANIDM_API_TOKEN=.' portal.env 2>/dev/null; then
token=$($K service-account api-token generate $SA "portal" --readwrite | tail -1) token=$($K service-account api-token generate $SA "portal" --readwrite 2>/dev/null | tail -1)
if [ -n "$token" ]; then if [ -n "$token" ]; then
grep -q '^KANIDM_API_TOKEN=' portal.env \ setenv KANIDM_API_TOKEN "$token"
&& sed -i "s|^KANIDM_API_TOKEN=.*|KANIDM_API_TOKEN=$token|" portal.env \
|| printf 'KANIDM_API_TOKEN=%s\n' "$token" >> portal.env
echo "onboarding token written to portal.env" echo "onboarding token written to portal.env"
else else
# Loudly, and with whatever the CLI said: the first version of this
# passed `--rw` for a flag that is spelled `--readwrite`, swallowed
# the error, and reported a bare warning that said nothing about
# why. A step that leaves invites broken should not be quiet about
# how it failed.
echo "warning: could not generate the onboarding token - invites will fail closed until it is set" >&2 echo "warning: could not generate the onboarding token - invites will fail closed until it is set" >&2
echo " retry by hand: $K service-account api-token generate $SA portal --readwrite" >&2 echo " retry by hand: $K service-account api-token generate $SA portal --readwrite" >&2
fi fi
fi fi
# The first person in, if .env names one: portal invites them into the
# most privileged group on its next start, once, and mails the link.
if [ -n "${SEED_ADMIN_EMAIL:-}" ]; then setenv SEED_ADMIN_EMAIL "$SEED_ADMIN_EMAIL"; fi
if [ -n "${SEED_ADMIN_NAME:-}" ]; then setenv SEED_ADMIN_NAME "$SEED_ADMIN_NAME"; fi
if [ -n "${RESPONSIBLE_GROUP:-}" ]; then setenv RESPONSIBLE_GROUP "$RESPONSIBLE_GROUP"; fi
podman compose restart portal podman compose up -d portal
echo "client $C configured; portal restarted with its secret" echo "portal restarted: signs in through $C, onboards through $SA, desks: $(echo $groups)"
if [ -n "${SEED_ADMIN_EMAIL:-}" ]; then echo "the first person, $SEED_ADMIN_EMAIL, gets their invite from portal now (once)"; fi
echo echo
echo "Give people their desk (membership is read at login):" echo "Everyone after that is invited from a desk. By hand, if ever needed:"
echo " $K group add-members tomtervel_styret <person>" echo " $K group add-members styret <person>"
echo "Create a person:"
echo " $K person create <name> '<Display Name>' && $K person update <name> --mail <email>"
echo " $K person credential create-reset-token <name>"