#!/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 is owned by <c>-svc
# - 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 (first real run): `keypair list --user` (nova microversion) and
# `application credential list --user` under the admin identity are mocked in
# the harness -- confirm both on the real cloud before trusting their PASS.
# 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
if is_id "$SUID"; then
if jcap "$TMPD/ac.json" application credential list --user "$SUID"; 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 owned by ${CLIENT}-svc"
else
bad "app cred ${CLIENT}-svc-cred missing on ${CLIENT}-svc"
fi
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