Newer
Older
openstack-caracal-dc-dc / scripts / g3-domain-manager-probe.sh
#!/usr/bin/env bash
# scripts/g3-domain-manager-probe.sh -- behavioral gate G3 (phase-03 Step 3.4 stage 2).
#
# WHAT IT PROVES. The SCS Domain Manager persona (D-051/D-064, delivered by the keystone
# policyd-override) works AND is properly bounded -- the appendix-C C.4 procedure, made a
# NAMED EXECUTABLE CHECK so it can close the gate (GA-R6) and re-run per DC (Stage 5 is
# per-DC; dc1's Step 7 needs the identical probe). Before this script the probe existed only
# as a manual runbook walk, so Step 3.4 had no gradable close (hard rule 4: a gap is a
# finding -- this fills it).
#
# THREE GROUPS, exactly as C.4 states them:
#   PASS  -- a manager, scoped to its OWN domain, CAN: create a user, create a project,
#            grant member + load-balancer_member on a project in its own domain.
#   DENY  -- the same manager CANNOT: grant an unmanaged role (manager/admin -> anti-escalation)
#            even within its own domain; read another domain (cross-domain isolation).
#   UNAFF -- cloud_admin (the operator admin) retains full authority (domain list sees all).
# Only when all three hold is the persona accepted (C.4: "Only when all three groups hold").
#
# WHY THE PASS GROUP IS ALSO THE OVERRIDE-ACTIVE CHECK. Without the override, role:manager has
# no special powers (default policy), so a manager creating a user/project would be REFUSED --
# the PASS group failing is exactly the "PO: shows but the policy is not effective" signal that
# a status-only check (D-051: the charm validates YAML only) cannot catch. So this replaces a
# PO:-only pass, per Step 3.4's own gate ("Do NOT proceed to phase-04 on a PO:-only pass").
#
# RUN-LOCATION. FROM THE DC's cloud client -- the DC rack per D-138 (juju/openstack dial the
# cloud at L3 from inside the DC), NOT voffice1. Needs ~/admin-openrc (Step 3.2) and its
# OS_CACERT (the vault root CA). Reads admin-openrc for OS_AUTH_URL + OS_CACERT; never prints
# a secret; the throwaway manager password is generated, used, and never echoed.
#
# SAFETY. All fixtures live under a UNIQUE, dated, clearly-labelled throwaway domain
# (g3-probe-<UTC stamp>). A trap tears the domain and its contents down on ANY exit, so a
# failure mid-probe leaves nothing behind. The ONLY identities it touches are ones it created;
# it never modifies admin_domain / service_domain / Default or any real tenant.
#
# EXIT: 0 all three groups hold | 11 a PASS case failed (persona cannot do what it must --
#       override likely not effective) | 12 a DENY case LEAKED (anti-escalation or cross-domain
#       -- CRITICAL, security boundary broken) | 13 the cloud-admin unaffected check failed |
#       14 precondition (no admin-openrc, auth fails, or a required role is absent).
# Harness: tests/g3-domain-manager-probe/run-tests.sh (offline; fakes openstack). ASCII + LF.
set -uo pipefail

OPENSTACK="${OPENSTACK:-openstack}"          # overridable so the harness can inject a fake
OPENRC="${G3_OPENRC:-$HOME/admin-openrc}"
STAMP="$(date -u +%Y%m%d-%H%M%S)"
DOM="g3-probe-${STAMP}"                        # throwaway domain, unique + dated
MGR="g3-mgr"; USR="g3-user"; PROJ="g3-proj"
PASS=0; FAIL=0
DOM_ID=""                                      # set once the domain is created (drives teardown)

say(){ printf '%s\n' "$*"; }
ok(){  PASS=$((PASS+1)); say "  OK:   $*"; }
bad(){ FAIL=$((FAIL+1)); say "  FAIL: $*"; }

# ---- auth envs -------------------------------------------------------------
# admin_env: the operator admin exactly as Step 3.2 wrote it.
admin_env(){ for v in $(env | awk -F= '/^OS_/{print $1}'); do unset "$v"; done; . "$OPENRC"; }
# mgr_env: the throwaway domain-manager, DOMAIN-scoped (a manager operates on its domain, not a
# project). Reuse the admin-openrc endpoint + CA; override identity only. Password via stdin-free
# env (never on a command line, never printed).
AUTH_URL=""; CACERT=""
mgr_env(){ for v in $(env | awk -F= '/^OS_/{print $1}'); do unset "$v"; done
  export OS_AUTH_URL="$AUTH_URL" OS_IDENTITY_API_VERSION=3
  [ -n "$CACERT" ] && export OS_CACERT="$CACERT"
  export OS_USERNAME="$MGR" OS_USER_DOMAIN_NAME="$DOM" OS_PASSWORD="$MGR_PW"
  export OS_DOMAIN_NAME="$DOM"; }                # domain scope

os(){ "$OPENSTACK" "$@" </dev/null 2>&1; }       # </dev/null: never block on a prompt

# ---- teardown (runs on ANY exit once the domain exists) --------------------
teardown(){
  [ -n "$DOM_ID" ] || return 0
  admin_env
  say "=== teardown (throwaway domain $DOM) ==="
  os project delete "$PROJ" --domain "$DOM_ID" >/dev/null 2>&1 || true
  os user    delete "$USR"  --domain "$DOM_ID" >/dev/null 2>&1 || true
  os user    delete "$MGR"  --domain "$DOM_ID" >/dev/null 2>&1 || true
  os domain  set --disable "$DOM_ID"           >/dev/null 2>&1 || true
  os domain  delete "$DOM_ID"                  >/dev/null 2>&1 || true
  say "  teardown attempted (best-effort; verify with 'openstack domain list')"
}
trap teardown EXIT

# ---- preconditions ---------------------------------------------------------
[ -s "$OPENRC" ] || { say "PRECOND: no admin-openrc at $OPENRC"; exit 14; }
AUTH_URL="$(awk -F= '/^[[:space:]]*export[[:space:]]+OS_AUTH_URL=/{print $2}' "$OPENRC" | tr -d '"'"'"' ' | tail -1)"
CACERT="$(awk -F=   '/^[[:space:]]*export[[:space:]]+OS_CACERT=/{print $2}'   "$OPENRC" | tr -d '"'"'"' ' | tail -1)"
CACERT="${CACERT/#\~/$HOME}"
[ -n "$AUTH_URL" ] || { say "PRECOND: OS_AUTH_URL not found in $OPENRC"; exit 14; }

admin_env
if ! os token issue -f value -c expires >/dev/null 2>&1; then
  say "PRECOND: admin-openrc does not authenticate (scoped token failed)"; exit 14; fi
ROLES="$(os role list -f value -c Name)"
for r in manager member load-balancer_member; do
  printf '%s\n' "$ROLES" | grep -qx "$r" || { say "PRECOND: role '$r' absent -- persona cannot be tested"; exit 14; }
done
# the unmanaged role we will attempt to escalate to: prefer 'admin', else the measured 'Admin'
ESC_ROLE="manager"   # granting 'manager' is escalation AND is never in is_domain_managed_role

say "=== G3 domain-manager persona probe (throwaway domain: $DOM) ==="

# ---- setup (admin): domain + manager account ------------------------------
DOM_ID="$(os domain create "$DOM" -f value -c id)"
case "$DOM_ID" in *[!0-9a-fA-F]*|'') say "PRECOND: domain create failed: $DOM_ID"; exit 14;; esac
MGR_PW="$(openssl rand -base64 18 2>/dev/null | tr -d '/+=' | cut -c1-20)"
[ -n "$MGR_PW" ] || { say "PRECOND: could not generate a manager password"; exit 14; }
MGR_ID="$(os user create --domain "$DOM_ID" --password "$MGR_PW" "$MGR" -f value -c id)"
case "$MGR_ID" in *[!0-9a-fA-F]*|'') say "PRECOND: manager user create failed: $MGR_ID"; exit 14;; esac
os role add --domain "$DOM_ID" --user "$MGR_ID" manager >/dev/null 2>&1 \
  || { say "PRECOND: could not grant manager on the domain"; exit 14; }

# ---- PASS group (as the manager, in its OWN domain) -----------------------
say "--- PASS group (manager, own domain) ---"
mgr_env
if os user create --domain "$DOM_ID" "$USR" -f value -c id | grep -qE '^[0-9a-fA-F]+$'; then
  ok "manager created a user in its own domain"; else bad "manager could NOT create a user in its own domain"; fi
if os project create --domain "$DOM_ID" "$PROJ" -f value -c id | grep -qE '^[0-9a-fA-F]+$'; then
  ok "manager created a project in its own domain"; else bad "manager could NOT create a project in its own domain"; fi
for role in member load-balancer_member; do
  if os role add --project "$PROJ" --project-domain "$DOM_ID" --user "$USR" --user-domain "$DOM_ID" "$role" >/dev/null 2>&1; then
    ok "manager granted '$role' on a project in its own domain"; else bad "manager could NOT grant '$role' in its own domain"; fi
done

# ---- DENY group (the manager must be refused) -----------------------------
say "--- DENY group (manager, must be refused) ---"
# D1 anti-escalation: grant an UNMANAGED role (manager) even within own domain -> must fail.
if os role add --project "$PROJ" --project-domain "$DOM_ID" --user "$USR" --user-domain "$DOM_ID" "$ESC_ROLE" >/dev/null 2>&1; then
  bad "ANTI-ESCALATION LEAK: manager granted unmanaged role '$ESC_ROLE'"; FAIL=$((FAIL+9)); else ok "anti-escalation held: grant of '$ESC_ROLE' refused"; fi
# D2 cross-domain: read users of a DIFFERENT domain (admin_domain) -> must fail.
if os user list --domain admin_domain -f value -c ID | grep -qE '^[0-9a-fA-F]+'; then
  bad "CROSS-DOMAIN LEAK: manager read users of admin_domain"; FAIL=$((FAIL+9)); else ok "cross-domain isolation held: read of admin_domain refused"; fi

# ---- UNAFF group (cloud-admin retains full authority) ---------------------
say "--- UNAFF group (cloud-admin) ---"
admin_env
NDOM="$(os domain list -f value -c ID | grep -cE '^[0-9a-fA-F]+')"
if [ "${NDOM:-0}" -ge 3 ]; then ok "cloud-admin sees all domains ($NDOM >= 3)"; else bad "cloud-admin domain list unexpectedly small ($NDOM)"; fi

# ---- verdict --------------------------------------------------------------
say "=== verdict: $PASS ok / $FAIL fail ==="
# classify the failure: a leaked DENY (recorded as +9) is CRITICAL (12); a plain PASS/UNAFF
# miss is 11/13. teardown runs via the EXIT trap regardless.
if [ "$FAIL" -eq 0 ]; then say "G3 PASS -- persona works and is bounded"; exit 0; fi
if [ "$FAIL" -ge 9 ]; then say "G3 FAIL -- a security boundary LEAKED"; exit 12; fi
say "G3 FAIL -- the persona could not perform a required action (override likely not effective)"; exit 11