#!/usr/bin/env bash
# scripts/tenant-assert.sh <client> -- read-only post-onboard topology verifier.
# Implements H1 of docs/tenant-onboarding-contract.md section 6 (DOCFIX-091;
# adopted 2026-07-06 as a pre-v1-close item). Verifies a tenant's identity
# topology matches the onboarding contract EXACTLY (D-066 triad, D-051 manager
# persona):
#   - domain <c> exists
#   - <c>-domain-admin has EXACTLY ONE assignment: manager on the domain
#   - <c>-cluster and <c>-svc each hold EXACTLY {member, load-balancer_member}
#     on <c>-prod and NOTHING else
#   - the admin role appears NOWHERE on the triad (explicit check)
#   - keypair <c>-key is owned by <c>-cluster (the stage-6 trustor requirement)
#   - app cred <c>-svc-cred exists and AUTHENTICATES (owner-context probe via
#     the local handover file; admin-side list is always 403 -- see below)
#   - local handover files (if the handover dir exists here) are present, 0600
# Mutates NOTHING. Evidence only; remediation stays a gated human step.
# Also serves as: offboard preflight; periodic all-tenants drift sweep member.
# VERIFY-LIVE: cleared 2026-07-06 (first real run, foil1). `keypair list
# --user` confirmed under admin. Admin-side `application credential list
# --user` measured ALWAYS-403: the legacy base-policy admin_or_owner rule
# keys on target.token attrs the app-cred API never populates, so it degrades
# to owner-only cross-domain -- the check now authenticates AS the handover
# app cred instead (stronger: proves the cred exists AND works).
# Usage: bash scripts/tenant-assert.sh <client>
# Exit: 0 PASS | 1 HOLD (>=1 assert failed) | 2 precondition missing.
# ASCII + LF.
set -uo pipefail
IFS=$'\n\t'

CLIENT="${1:-}"
[ -n "$CLIENT" ] || { echo "usage: bash scripts/tenant-assert.sh <client>" >&2; exit 2; }
command -v jq >/dev/null 2>&1 || { echo "PRECOND: jq required" >&2; exit 2; }
[ -s "$HOME/admin-openrc" ] || { echo "PRECOND: ~/admin-openrc missing" >&2; exit 2; }
for v in $(env | awk -F= '/^OS_/{print $1}'); do unset "$v"; done
# shellcheck disable=SC1090
source "$HOME/admin-openrc"

TMPD="$(mktemp -d)"; trap 'rm -rf "$TMPD"' EXIT
FAILS=0
ok(){  echo "  ok:   $*"; }
bad(){ echo "  FAIL: $*"; FAILS=$((FAILS+1)); }
is_id(){ [[ "${1:-}" =~ ^[0-9a-f]{32}$ ]]; }

vcap(){ # vcap <openstack args...> -- single-value capture; stderr to file, never merged
  openstack "$@" -f value </dev/null 2>"$TMPD/err" || true
}
jcap(){ # jcap <file> <openstack args...> -- stdout-only JSON capture (house stderr rule)
  local f="$1"; shift
  openstack "$@" -f json </dev/null >"$f" 2>"$TMPD/err" || true
  if ! jq -e . "$f" >/dev/null 2>&1; then
    bad "openstack $* -- bad/empty JSON; stderr was:"; sed 's/^/        /' "$TMPD/err"
    return 1
  fi
}

echo "== tenant-assert: $CLIENT (read-only) =="

# ---- cloud-level role ids (stable identities, not names-in-rows) ----
R_MANAGER="$(vcap role show manager -c id)"
R_MEMBER="$(vcap role show member -c id)"
R_LB="$(vcap role show load-balancer_member -c id)"
R_ADMIN="$(vcap role show admin -c id)"
for pair in "manager:$R_MANAGER" "member:$R_MEMBER" "load-balancer_member:$R_LB" "admin:$R_ADMIN"; do
  is_id "${pair#*:}" || bad "cloud role '${pair%%:*}' unresolved (cloud-level problem)"
done
[ "$FAILS" -eq 0 ] || { echo "TENANT-ASSERT $CLIENT: HOLD ($FAILS failed -- roles unresolved)"; exit 1; }

# ---- domain / users / project ----
DOM="$(vcap domain show "$CLIENT" -c id)"
if ! is_id "$DOM"; then
  bad "domain '$CLIENT' not found"
  echo "TENANT-ASSERT $CLIENT: HOLD ($FAILS failed -- no domain, nothing further checkable)"
  exit 1
fi
ok "domain $CLIENT ($DOM)"

MUID="$(vcap user show "${CLIENT}-domain-admin" --domain "$DOM" -c id)"
CUID="$(vcap user show "${CLIENT}-cluster"      --domain "$DOM" -c id)"
SUID="$(vcap user show "${CLIENT}-svc"          --domain "$DOM" -c id)"
is_id "$MUID" && ok "user ${CLIENT}-domain-admin" || bad "user ${CLIENT}-domain-admin missing"
is_id "$CUID" && ok "user ${CLIENT}-cluster"      || bad "user ${CLIENT}-cluster missing"
is_id "$SUID" && ok "user ${CLIENT}-svc"          || bad "user ${CLIENT}-svc missing"

PID="$(vcap project show "${CLIENT}-prod" --domain "$DOM" -c id)"
is_id "$PID" && ok "project ${CLIENT}-prod ($PID)" || bad "project ${CLIENT}-prod missing"

# ---- exact assignment sets (IDs, whole-output validation) ----
assert_exact_pair(){ # <label> <uid> -- exactly {member,lb} on PID, nothing else
  local label="$1" uid="$2"
  local f="$TMPD/$label.json"
  is_id "$uid" || return 0   # missing-user already FAILed above
  jcap "$f" role assignment list --user "$uid" || return 0
  if jq -e --arg m "$R_MEMBER" --arg l "$R_LB" --arg p "$PID" \
      '(length == 2)
       and (([.[].Role] | sort) == ([$m,$l] | sort))
       and ([.[].Project] | all(. == $p))' "$f" >/dev/null; then
    ok "$label: exactly {member, load-balancer_member} on ${CLIENT}-prod"
  else
    bad "$label: assignments deviate from contract (want EXACTLY member+load-balancer_member on ${CLIENT}-prod):"
    jq -r '.[] | "        role=\(.Role) project=\(.Project // "") domain=\(.Domain // "")"' "$f"
  fi
  if jq -e --arg a "$R_ADMIN" '[.[].Role] | index($a) != null' "$f" >/dev/null; then
    bad "$label: HOLDS ADMIN -- escalation, treat as an incident"
  fi
}

if is_id "$MUID"; then
  if jcap "$TMPD/mgr.json" role assignment list --user "$MUID"; then
    if jq -e --arg r "$R_MANAGER" --arg d "$DOM" \
        '(length == 1) and (.[0].Role == $r) and (.[0].Domain == $d)
         and ((.[0].Project // "") == "")' "$TMPD/mgr.json" >/dev/null; then
      ok "manager: exactly one assignment (manager on domain)"
    else
      bad "manager: assignments deviate (want EXACTLY manager on the domain, nothing else):"
      jq -r '.[] | "        role=\(.Role) project=\(.Project // "") domain=\(.Domain // "")"' "$TMPD/mgr.json"
    fi
    if jq -e --arg a "$R_ADMIN" '[.[].Role] | index($a) != null' "$TMPD/mgr.json" >/dev/null; then
      bad "manager: HOLDS ADMIN -- escalation, treat as an incident"
    fi
  fi
fi
assert_exact_pair "cluster" "$CUID"
assert_exact_pair "svc"     "$SUID"

# ---- ownership: keypair (-cluster) and app cred (-svc) ----
if is_id "$CUID"; then
  if jcap "$TMPD/kp.json" keypair list --user "$CUID"; then
    if jq -e --arg k "${CLIENT}-key" '[.[] | select(.Name == $k)] | length >= 1' "$TMPD/kp.json" >/dev/null; then
      ok "keypair ${CLIENT}-key owned by ${CLIENT}-cluster"
    else
      bad "keypair ${CLIENT}-key NOT owned by ${CLIENT}-cluster (stage-6 will 400 -- the keypair trap)"
    fi
  fi
fi
# owner-context probe: extract id/secret into env only (never printed), auth
# as the app cred, list own creds. Endpoint discovered from the live catalog.
ACF="$HOME/tenant-${CLIENT}/${CLIENT}-svc-appcred.txt"
if is_id "$SUID"; then
  if [ -s "$ACF" ]; then
    AURL="$(vcap endpoint list --service identity --interface public -c URL | head -n1)"
    if [[ "$AURL" =~ ^https?:// ]]; then
      (
        CAC="${OS_CACERT:-$HOME/vault-init/vault-ca-root.pem}"
        for v in $(env | awk -F= '/^OS_/{print $1}'); do unset "$v"; done
        export OS_AUTH_TYPE=v3applicationcredential OS_AUTH_URL="$AURL" \
               OS_IDENTITY_API_VERSION=3 OS_CACERT="$CAC"
        OS_APPLICATION_CREDENTIAL_ID="$(awk -F'"' '/^id=/{print $2}' "$ACF")"
        OS_APPLICATION_CREDENTIAL_SECRET="$(awk -F'"' '/^secret=/{print $2}' "$ACF")"
        export OS_APPLICATION_CREDENTIAL_ID OS_APPLICATION_CREDENTIAL_SECRET
        openstack application credential list -f json </dev/null 2>"$TMPD/err"
      ) >"$TMPD/ac.json"
      if jq -e . "$TMPD/ac.json" >/dev/null 2>&1; then
        if jq -e --arg n "${CLIENT}-svc-cred" '[.[] | select(.Name == $n)] | length == 1' "$TMPD/ac.json" >/dev/null; then
          ok "app cred ${CLIENT}-svc-cred live (owner-context auth OK)"
        else
          bad "app cred ${CLIENT}-svc-cred missing on ${CLIENT}-svc (owner-context list)"
        fi
      else
        bad "owner-context app cred probe failed (auth or list); stderr was:"
        sed 's/^/        /' "$TMPD/err"
      fi
    else
      bad "identity public endpoint unresolved -- cannot run owner-context app cred probe"
    fi
  else
    echo "  skip: no local appcred file $ACF (owner-context check skipped; SKIP does not lower the verdict)"
  fi
fi

# ---- local handover files (existence + mode ONLY; content is never read) ----
OUTD="$HOME/tenant-${CLIENT}"
if [ -d "$OUTD" ]; then
  for f in "${CLIENT}-domain-admin-cred.txt" "${CLIENT}-cluster-cred.txt" \
           "${CLIENT}-svc-cred.txt" "${CLIENT}-svc-appcred.txt" "${CLIENT}-key.pem"; do
    P="$OUTD/$f"
    if [ -s "$P" ]; then
      MODE="$(stat -c %a "$P" 2>/dev/null || echo '?')"
      [ "$MODE" = "600" ] && ok "handover $f (0600)" || bad "handover $f mode $MODE (want 600)"
    else
      bad "handover $f missing/empty in $OUTD"
    fi
  done
else
  echo "  skip: no local handover dir $OUTD (files check skipped; SKIP does not lower the verdict)"
fi

echo
if [ "$FAILS" -eq 0 ]; then
  echo "TENANT-ASSERT $CLIENT: PASS (topology matches the onboarding contract)"
  exit 0
else
  echo "TENANT-ASSERT $CLIENT: HOLD ($FAILS failed)"
  exit 1
fi
