diff --git a/.env.example b/.env.example index 31bdf41..c33df9a 100644 --- a/.env.example +++ b/.env.example @@ -14,11 +14,29 @@ PORTAL_RELEASE=v0.5.3 # The content this instance serves, and reloads live on every push. CONTENT_REPO=https://prosjekt.klingenbergbygg.no/tomtervel/questions CONTENT_BRANCH=main -SITE_NAME=Tomter Vel +SITE_NAME="Tomter Vel" # Generated once by bootstrap.sh if left empty. 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. OAUTH2_CLIENT_ID=tomtervel-portal OAUTH2_CLIENT_SECRET= diff --git a/.gitignore b/.gitignore index 37ea2d5..b3cec27 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ kanidm/server.toml nats/nats.conf certs/ .kanidm-recovered +gitea-oauth.secret diff --git a/README.md b/README.md index 8cecf09..aee45a2 100644 --- a/README.md +++ b/README.md @@ -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: `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 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 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 what to bump: `image: kanidm/server:` in `compose.yml`, then `podman compose up -d kanidm`. diff --git a/bootstrap.sh b/bootstrap.sh index ae637b9..fc121e2 100755 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -21,6 +21,9 @@ fi # Rendered configs (gitignored). 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 +# 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 <> portal.env chmod 600 portal.env # Kanidm's internal certificate: Caddy holds the public one. diff --git a/kanidm-setup.sh b/kanidm-setup.sh index e846aab..aa63782 100755 --- a/kanidm-setup.sh +++ b/kanidm-setup.sh @@ -1,105 +1,186 @@ #!/bin/sh -# The portal's OAuth2 client and one Kanidm group per desk, mapped into -# the `groups` claim under the names the pages use in `qualifies`. -# Portal reads that claim at login (portal src/auth.rs). Rerunnable. +# Everything portal needs from this Kanidm, all the way: the OAuth2 +# client people sign in through, one group per desk named exactly as +# 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), -# then writes the client secret into .env and portal.env and restarts -# the portal. Needs the kanidm CLI on this machine. +# Logs in first if needed (interactive, idm_admin's password from +# bootstrap.sh), writes the client secret and the onboarding token into +# portal.env, and restarts portal. Needs the kanidm CLI and curl. set -eu cd "$(dirname "$0")" . ./.env K="kanidm -D idm_admin -H https://$ID_HOST" C=$OAUTH2_CLIENT_ID +SA=portal-onboarding # Already signed in? Then do not ask again: this script is meant to be # rerunnable, and the session outlives a single run. $K self whoami >/dev/null 2>&1 || $K login 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_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" -$K system oauth2 add-redirect-url $C "https://$PORTAL_HOST/auth/callback" || true +# The desk groups are whatever the content gates pages on: every +# `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 -# this list equal to the `qualifies` values under questions/. -has_group tomtervel_members || $K group create tomtervel_members -for g in kasserer styret komiteer nabohjelp arrangementer elvesti horingsinstans lekeplasser miljogate pendlerforhold trafikk; do - has_group tomtervel_$g || $K group create tomtervel_$g - $K group add-members tomtervel_members tomtervel_$g +# The onboarding account. In idm_people_on_boarding it may create a +# person and issue a first credential reset and nothing else, so its +# token cannot touch anyone's existing credentials; idm_people_pii_read +# lets an invite find someone who already has an account by their +# email and add them instead of making a second account. Portal creates +# 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 -# Portal asks for openid, profile and email; groups arrive as a claim. -$K system oauth2 update-scope-map $C tomtervel_members openid profile email -for g in kasserer styret komiteer nabohjelp arrangementer elvesti horingsinstans lekeplasser miljogate pendlerforhold trafikk; do - $K system oauth2 update-claim-map $C groups tomtervel_$g $g +# The people who ask the questions: everyone a page names as +# responsible. Portal puts them here itself at start (RESPONSIBLE_GROUP); +# the group only has to exist and be portal's to fill. Kanidm names are +# 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 $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 # 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 -# that looks like it belongs to no one is the kind of thing people -# rightly hesitate over. +# nothing about whose site they are signing in to. The logo comes from +# the content repo, the same file site.yaml uses as the favicon. # -# The logo comes from the content repo, because that is already where -# this site's identity lives: the same file site.yaml uses as the -# favicon. Branding follows the content, not this repo. -$K system domain set-displayname "$SITE_NAME" || true -$K system oauth2 set-displayname $C "$SITE_NAME" || true - +# The client's name and logo are idm_admin's to set. The instance's own +# (the login page's heading and mark) are system settings only `admin` +# may change - idm_admin gets a bare "Item not found" - so that half +# runs when an admin session exists, and otherwise says what to run. +A="kanidm -D admin -H https://$ID_HOST" +$K system oauth2 set-displayname $C "$SITE_NAME" >/dev/null || true logo=$(mktemp --suffix=.svg) 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 || echo "note: could not set the client logo" + $K system oauth2 set-image $C "$logo" svg >/dev/null || echo "note: could not set the client logo" else 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 rm -f "$logo" -secret=$($K system oauth2 show-basic-secret $C 2>/dev/null | tail -1) -sed -i "s|^OAUTH2_CLIENT_SECRET=.*|OAUTH2_CLIENT_SECRET=$secret|" .env portal.env - -# Onboarding from a desk. A committee inviting a neighbour, and any -# state whose `grants:` makes someone a member, both go through Kanidm -# as this service account - not as a person, and not as an admin. It is -# in idm_people_on_boarding, which may create people and issue a first -# credential reset and nothing else, so the token cannot touch anyone's -# existing credentials. idm_people_pii_read lets an invite find someone -# who already has an account by their email, and add them to the group -# instead of making a second account for the same person. -# -# The token is shown once, by Kanidm, at creation. Written straight into -# the env files here and never printed. -SA=portal-onboarding -if ! $K service-account get $SA >/dev/null 2>&1; then - $K service-account create $SA "Portal onboarding" idm_admin - $K group add-members idm_people_on_boarding $SA - $K group add-members idm_people_pii_read $SA || true +# Sign-in to a project Gitea for the people who ask the questions: its +# own OAuth2 client, which only RESPONSIBLE_GROUP may use, carrying that +# group in the `groups` claim so Gitea can require it as well. Gitea +# does not do PKCE (Kanidm's own Gitea example turns it off), and short +# usernames keep Gitea accounts "kari", not "kari@id.vel...". +if [ -n "${GITEA_URL:-}" ] && [ -n "${RESPONSIBLE_GROUP:-}" ]; then + G=${GITEA_OAUTH2_CLIENT_ID:-prosjekt} + N=${GITEA_AUTH_NAME:-tomtervel} + has_client $G || $K system oauth2 create $G "${GITEA_LABEL:-Prosjekt}" "$GITEA_URL/user/login" + $K system oauth2 add-redirect-url $G "$GITEA_URL/user/oauth2/$N/callback" 2>/dev/null || true + $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 + $K system oauth2 update-claim-map-join $G groups array >/dev/null + $K system oauth2 warning-insecure-client-disable-pkce $G >/dev/null + $K system oauth2 prefer-short-username $G >/dev/null + gsecret=$($K system oauth2 show-basic-secret $G 2>/dev/null | tail -1) + discover="https://$ID_HOST/oauth2/openid/$G/.well-known/openid-configuration" + # On the host that runs that Gitea, add or update its source here; + # 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 + 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 - 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 - grep -q '^KANIDM_API_TOKEN=' portal.env \ - && sed -i "s|^KANIDM_API_TOKEN=.*|KANIDM_API_TOKEN=$token|" portal.env \ - || printf 'KANIDM_API_TOKEN=%s\n' "$token" >> portal.env + setenv KANIDM_API_TOKEN "$token" echo "onboarding token written to portal.env" 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 " retry by hand: $K service-account api-token generate $SA portal --readwrite" >&2 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 -echo "client $C configured; portal restarted with its secret" +podman compose up -d portal +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 "Give people their desk (membership is read at login):" -echo " $K group add-members tomtervel_styret " -echo "Create a person:" -echo " $K person create '' && $K person update --mail " -echo " $K person credential create-reset-token " +echo "Everyone after that is invited from a desk. By hand, if ever needed:" +echo " $K group add-members styret "