#!/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