#!/usr/bin/env bash # create-user.sh — create a user in LLDAP and add them to net-kingdom-users # # Usage: # ./create-user.sh [display-name] [--admin] [--test] [lldap-url] [secrets-dir] # # LDAP uid — e.g. "bernd" or "testuser" # e.g. "bernd@coulomb.social" # defaults to # --admin also add to net-kingdom-admins # --test set password automatically as (spaces→hyphens) # e.g. display "Test User" → password "Test-User-Pwd" # default: https://lldap.coulomb.social # default: ../../bootstrap/secrets # # Examples: # ./create-user.sh testuser test.user@coulomb.social "Test User" --test # ./create-user.sh bernd bernd@coulomb.social "Bernd W" --admin set -euo pipefail USERNAME="${1:-}" EMAIL="${2:-}" DISPLAY_NAME="${3:-$USERNAME}" LLDAP_URL="https://lldap.coulomb.social" SECRETS_DIR="../../bootstrap/secrets" ADMIN_FLAG="" TEST_FLAG="" for arg in "$@"; do [[ "$arg" == "--admin" ]] && ADMIN_FLAG="yes" [[ "$arg" == "--test" ]] && TEST_FLAG="yes" done # Allow lldap-url and secrets-dir as positional 4/5 if not a flag for pos in 4 5; do val="${!pos:-}" [[ "$val" == "--admin" || "$val" == "--test" || -z "$val" ]] && continue [[ $pos -eq 4 ]] && LLDAP_URL="$val" [[ $pos -eq 5 ]] && SECRETS_DIR="$val" done if [[ -z "$USERNAME" || -z "$EMAIL" ]]; then echo "Usage: $0 [display-name] [--admin]" >&2 exit 1 fi LLDAP_ENV="$SECRETS_DIR/lldap/secrets.env" LLDAP_ADMIN_PASS="${LLDAP_ADMIN_PASS:-}" if [[ -z "$LLDAP_ADMIN_PASS" ]]; then if [[ -f "$LLDAP_ENV" ]]; then read_env() { bash -c "source '$1' 2>/dev/null; echo \${$2}"; } LLDAP_ADMIN_PASS=$(read_env "$LLDAP_ENV" LLDAP_LDAP_USER_PASS) else # Safer fallback for dry-runs / automation (NET-WP-0019-T02): pull directly from k8s secret # without requiring (or writing) a local secrets.env file on disk. if command -v kubectl >/dev/null 2>&1 || [[ -n "${KUBECTL:-}" ]]; then KUBECTL_BIN="${KUBECTL:-kubectl}" LLDAP_ADMIN_PASS="$($KUBECTL_BIN get secret -n sso lldap-secrets -o jsonpath='{.data.LLDAP_LDAP_USER_PASS}' 2>/dev/null | base64 -d 2>/dev/null || true)" fi fi fi if [[ -z "$LLDAP_ADMIN_PASS" ]]; then echo "ERROR: Could not obtain LLDAP admin password (no $LLDAP_ENV and no k8s fallback succeeded)." >&2 echo " For dry-runs prefer setting LLDAP_ADMIN_PASS=... or ensuring kubectl can reach the sso/lldap-secrets secret." >&2 exit 1 fi # ── Authenticate ────────────────────────────────────────────────────────────── echo "Authenticating to LLDAP at $LLDAP_URL ..." LLDAP_TOKEN=$(curl -sf -X POST "$LLDAP_URL/auth/simple/login" \ -H "Content-Type: application/json" \ -d "{\"username\":\"admin\",\"password\":\"$LLDAP_ADMIN_PASS\"}" \ | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])") # gql — execute a GraphQL query against LLDAP. # Passes query and variables via environment to avoid shell quoting issues # with special characters (spaces, quotes) in display names or emails. gql() { local query="$1" local _empty="{}" local vars="${2:-$_empty}" local body body=$(GQL_QUERY="$query" GQL_VARS="$vars" python3 -c " import json, os print(json.dumps({ 'query': os.environ['GQL_QUERY'], 'variables': json.loads(os.environ['GQL_VARS']) })) ") curl -sf -X POST "$LLDAP_URL/api/graphql" \ -H "Authorization: Bearer $LLDAP_TOKEN" \ -H "Content-Type: application/json" \ -d "$body" } # Build variables JSON safely via env vars. # Set VAR_INT_KEYS to a comma-separated list of keys that should be JSON integers. make_vars() { python3 -c " import json, os d = {} int_keys = set(k for k in os.environ.get('VAR_INT_KEYS','').split(',') if k) for k in os.environ.get('VAR_KEYS','').split(','): if k: v = os.environ.get('VAR_' + k, '') d[k] = int(v) if k in int_keys else v print(json.dumps(d)) " } # ── Create user ─────────────────────────────────────────────────────────────── echo "Creating user '$USERNAME' ($EMAIL, display='$DISPLAY_NAME') ..." VARS=$(VAR_KEYS="id,email,display" \ VAR_id="$USERNAME" \ VAR_email="$EMAIL" \ VAR_display="$DISPLAY_NAME" \ make_vars) CREATE_RESP=$(gql \ 'mutation CreateUser($id: String!, $email: String!, $display: String!) { createUser(user: {id: $id, email: $email, displayName: $display}) { id email displayName } }' \ "$VARS") ERR=$(echo "$CREATE_RESP" | python3 -c \ "import sys,json; d=json.load(sys.stdin); errs=d.get('errors',[]); print(errs[0]['message'] if errs else '')" \ 2>/dev/null || echo "parse error") if [[ -n "$ERR" ]]; then echo " ERROR: $ERR" >&2 exit 1 fi echo " User '$USERNAME' created." # ── Look up group IDs ───────────────────────────────────────────────────────── GROUPS_RESP=$(gql 'query { groups { id displayName } }') get_group_id() { local name="$1" echo "$GROUPS_RESP" | GRP_NAME="$name" python3 -c " import sys,json,os d=json.load(sys.stdin) grps=d.get('data',{}).get('groups',[]) matches=[str(g['id']) for g in grps if g['displayName']==os.environ['GRP_NAME']] print(matches[0] if matches else '') " } USERS_GID=$(get_group_id "net-kingdom-users") ADMINS_GID=$(get_group_id "net-kingdom-admins") if [[ -z "$USERS_GID" ]]; then echo " ERROR: group 'net-kingdom-users' not found — run bootstrap-users.sh first." >&2 exit 1 fi # ── Add to net-kingdom-users ────────────────────────────────────────────────── echo "Adding '$USERNAME' to net-kingdom-users (id=$USERS_GID) ..." VARS=$(VAR_KEYS="uid,gid" VAR_INT_KEYS="gid" VAR_uid="$USERNAME" VAR_gid="$USERS_GID" make_vars) ADD_RESP=$(gql \ 'mutation AddToGroup($uid: String!, $gid: Int!) { addUserToGroup(userId: $uid, groupId: $gid) { ok } }' \ "$VARS") ERR=$(echo "$ADD_RESP" | python3 -c \ "import sys,json; d=json.load(sys.stdin); errs=d.get('errors',[]); print(errs[0]['message'] if errs else '')" \ 2>/dev/null || echo "") [[ -n "$ERR" ]] && echo " WARNING: $ERR" || echo " Added to net-kingdom-users." # ── Add to net-kingdom-admins (optional) ───────────────────────────────────── if [[ -n "$ADMIN_FLAG" ]]; then if [[ -z "$ADMINS_GID" ]]; then echo " WARNING: group 'net-kingdom-admins' not found — skipping." >&2 else echo "Adding '$USERNAME' to net-kingdom-admins (id=$ADMINS_GID) ..." VARS=$(VAR_KEYS="uid,gid" VAR_INT_KEYS="gid" VAR_uid="$USERNAME" VAR_gid="$ADMINS_GID" make_vars) ADD_RESP=$(gql \ 'mutation AddToGroup($uid: String!, $gid: Int!) { addUserToGroup(userId: $uid, groupId: $gid) { ok } }' \ "$VARS") ERR=$(echo "$ADD_RESP" | python3 -c \ "import sys,json; d=json.load(sys.stdin); errs=d.get('errors',[]); print(errs[0]['message'] if errs else '')" \ 2>/dev/null || echo "") [[ -n "$ERR" ]] && echo " WARNING: $ERR" || echo " Added to net-kingdom-admins." fi fi # ── Set password ────────────────────────────────────────────────────────────── echo "" echo "Setting password for '$USERNAME' ..." if [[ -n "$TEST_FLAG" ]]; then # Derive password from display name: "Test User" → "Test-User-Pwd" USER_PASS=$(echo "$DISPLAY_NAME" | tr ' ' '-')-Pwd echo " [--test] Using derived password: $USER_PASS" else read -r -s -p " Enter password (leave blank to skip): " USER_PASS echo "" fi if [[ -n "$USER_PASS" ]]; then # LLDAP has no GraphQL mutation for password setting. # Use RFC 3062 LDAP Password Modify extended operation via a kubectl # port-forward to the in-cluster LLDAP LDAP port (ClusterIP only). LLDAP_BIND_DN="uid=admin,ou=people,dc=netkingdom,dc=local" TARGET_DN="uid=$USERNAME,ou=people,dc=netkingdom,dc=local" LOCAL_PORT=13891 # avoid conflict with other forwarders echo " Opening kubectl port-forward to lldap:3890 ..." KUBECONFIG="${KUBECONFIG:-$HOME/.kube/config-railiance01}" \ kubectl port-forward svc/lldap "$LOCAL_PORT:3890" -n sso &>/dev/null & PF_PID=$! # Wait for the forwarder to be ready (up to 10s) for i in $(seq 1 10); do nc -z 127.0.0.1 "$LOCAL_PORT" 2>/dev/null && break sleep 1 done PASS_ERR=$(LLDAP_USER_PASS="$LLDAP_ADMIN_PASS" \ LLDAP_BIND_DN_VAR="$LLDAP_BIND_DN" \ TARGET_DN_VAR="$TARGET_DN" \ NEW_PASS="$USER_PASS" \ LOCAL_PORT_VAR="$LOCAL_PORT" \ python3 -c " import os, sys try: from ldap3 import Server, Connection s = Server('127.0.0.1', port=int(os.environ['LOCAL_PORT_VAR'])) c = Connection(s, user=os.environ['LLDAP_BIND_DN_VAR'], password=os.environ['LLDAP_USER_PASS']) c.bind() c.extend.standard.modify_password( user=os.environ['TARGET_DN_VAR'], new_password=os.environ['NEW_PASS']) if c.result['result'] != 0: print(c.result['description'], file=sys.stderr) sys.exit(1) c.unbind() except ImportError: print('ldap3 not installed — install with: pip install ldap3 --break-system-packages', file=sys.stderr) sys.exit(1) " 2>&1) kill "$PF_PID" 2>/dev/null; wait "$PF_PID" 2>/dev/null || true if [[ -n "$PASS_ERR" ]]; then echo " WARNING: password not set — $PASS_ERR" >&2 echo " Set manually via the LLDAP WebUI at $LLDAP_URL" else echo " Password set." fi else echo " Skipped — set password via $LLDAP_URL as admin." fi # ── Done ────────────────────────────────────────────────────────────────────── echo "" echo "════════════════════════════════════════════════════════════" echo " User '$USERNAME' ready." [[ -n "$ADMIN_FLAG" ]] && echo " Groups: net-kingdom-users, net-kingdom-admins" || echo " Group: net-kingdom-users" echo "════════════════════════════════════════════════════════════" echo "" echo "Next steps:" echo " 1. User self-enrolls TOTP at https://pink-account.coulomb.social" echo " 2. Verify in privacyIDEA: GET /user/?realm=coulomb&username=$USERNAME"