#!/usr/bin/env bash # seed-test-accounts.sh — provision the 3 pentester accounts on a target # environment (staging only ; refuses to run against prod). # # Per docs/PENTEST_SCOPE_2026.md §"Authentication context", an external # pentest engagement needs three pre-seeded accounts (listener, creator, # admin). This script : # # 1. Generates a 32-char random password for each role. # 2. Calls the staging admin API to create / reset each account. # 3. Promotes creator to creator, admin to admin (via direct DB UPDATE # because the public API doesn't expose role changes — operator # runs that step from a maintenance shell). # 4. Writes a 1Password import JSON to stdout so the operator can # `op item template` it into the shared vault. NEVER prints # passwords to the screen. # # Usage : # bash scripts/pentest/seed-test-accounts.sh staging # # Output : # 1Password JSON on stdout (3 entries). Pipe into a file, then # `op item create --vault Pentest-2026 - < file.json`. # # Exit codes : # 0 — three accounts provisioned, JSON emitted # 1 — API call failed (account creation or login probe) # 2 — wrong target environment (e.g. operator passed "prod") # 3 — required env var or tool missing set -euo pipefail ENV_NAME=${1:-} if [ -z "$ENV_NAME" ]; then cat >&2 < env : staging (the only accepted value — prod is refused) Required env vars : STAGING_URL base URL (e.g. https://staging.veza.fr) STAGING_ADMIN_EMAIL admin who creates the accounts STAGING_ADMIN_PASSWORD admin password (provisioning cred only) Output : 1Password import JSON for vault Pentest-2026, on stdout. Passwords are NEVER printed to the operator's screen. EOF exit 3 fi if [ "$ENV_NAME" != "staging" ]; then echo "ERROR: this script refuses to run against any env other than 'staging'." >&2 echo " Pentest accounts on production violate the engagement scope." >&2 exit 2 fi STAGING_URL=${STAGING_URL:-?} STAGING_ADMIN_EMAIL=${STAGING_ADMIN_EMAIL:-?} STAGING_ADMIN_PASSWORD=${STAGING_ADMIN_PASSWORD:-?} [ "$STAGING_URL" = "?" ] && { echo "STAGING_URL required" >&2; exit 3; } [ "$STAGING_ADMIN_EMAIL" = "?" ] && { echo "STAGING_ADMIN_EMAIL required" >&2; exit 3; } [ "$STAGING_ADMIN_PASSWORD" = "?" ] && { echo "STAGING_ADMIN_PASSWORD required" >&2; exit 3; } command -v curl >/dev/null 2>&1 || { echo "curl required" >&2; exit 3; } command -v jq >/dev/null 2>&1 || { echo "jq required" >&2; exit 3; } command -v openssl >/dev/null 2>&1 || { echo "openssl required (password generation)" >&2; exit 3; } genpass() { # 32-char password from base64-encoded 24 bytes of entropy. URL-safe # so it can land in a JSON string without escaping. openssl rand -base64 24 | tr -d '\n=/+' | cut -c-32 } # 1. login as the staging admin so we can call the create-user endpoint. admin_login_resp=$(curl -ksS --max-time 15 \ -X POST -H 'Content-Type: application/json' \ -d "{\"email\":\"${STAGING_ADMIN_EMAIL}\",\"password\":\"${STAGING_ADMIN_PASSWORD}\",\"remember_me\":false}" \ "${STAGING_URL}/api/v1/auth/login") admin_token=$(echo "$admin_login_resp" | jq -r '.data.token.access_token // .token.access_token // ""') if [ -z "$admin_token" ] || [ "$admin_token" = "null" ]; then echo "ERROR: admin login failed" >&2 echo "$admin_login_resp" >&2 exit 1 fi provision() { # provision # Returns : password (stdout), nothing else. local role=$1 email_prefix=$2 local email="${email_prefix}@veza.fr" local password password=$(genpass) # Try creating ; if 409 (already exists), reset password instead. Both # paths return a valid (email, password) tuple at the end. local create_resp create_status create_resp=$(curl -ksS --max-time 15 \ -H "Authorization: Bearer ${admin_token}" \ -H 'Content-Type: application/json' \ -X POST \ -d "{\"email\":\"${email}\",\"password\":\"${password}\",\"username\":\"${email_prefix}\",\"role\":\"${role}\"}" \ -w '\nHTTP_CODE=%{http_code}' \ "${STAGING_URL}/api/v1/admin/users") create_status=$(echo "$create_resp" | grep -oE 'HTTP_CODE=[0-9]+' | tail -1 | cut -d= -f2) case "$create_status" in 200|201) ;; 409) # Account exists — reset password instead. curl -ksS --max-time 15 \ -H "Authorization: Bearer ${admin_token}" \ -H 'Content-Type: application/json' \ -X POST \ -d "{\"email\":\"${email}\",\"new_password\":\"${password}\"}" \ "${STAGING_URL}/api/v1/admin/users/reset-password" >/dev/null ;; *) echo "ERROR: provisioning ${role} failed with HTTP ${create_status}" >&2 echo "$create_resp" >&2 exit 1 ;; esac # Probe : login as the freshly-set account so we know the engagement # can use it. probe=$(curl -ksS --max-time 15 \ -X POST -H 'Content-Type: application/json' \ -d "{\"email\":\"${email}\",\"password\":\"${password}\",\"remember_me\":false}" \ "${STAGING_URL}/api/v1/auth/login") probe_token=$(echo "$probe" | jq -r '.data.token.access_token // .token.access_token // ""') if [ -z "$probe_token" ] || [ "$probe_token" = "null" ]; then echo "ERROR: ${role} login probe failed — provisioning broken" >&2 exit 1 fi printf '%s' "$password" } # 2. provision the three roles. Passwords stay in shell variables — no # echo, no log, no temp file. listener_pwd=$(provision "user" "pentest-2026-listener") creator_pwd=$(provision "creator" "pentest-2026-creator") admin_pwd=$(provision "admin" "pentest-2026-admin") # 3. emit 1Password JSON template. Each entry has the role + login URL # in Notes so the pentester knows which account does what. cat <&2 echo " 3 accounts provisioned + login-probed against ${STAGING_URL}" >&2 echo " next: pipe stdout to a file and run" >&2 echo " op item create --vault Pentest-2026 - < " >&2 echo " THEN rotate each entry with op item edit --generate-password=letters,digits,32" >&2 echo " at engagement end (this script does NOT auto-rotate)." >&2