#!/usr/bin/env bash
# scripts/keystone-policy-drift.sh [MODEL]
#
# READ-ONLY drift detector between the repo's keystone policy artifacts and
# the LIVE effective keystone policy (hardening register H4; DOCFIX-098).
# Governing decisions: D-051 (domain-manager overlay + policyd-override
# mechanism), D-064 (list_users/list_projects/list_groups + helper alignment),
# D-065 (create_trust base-policy defect; the fix rides the overlay), D-073
# (list_trusts tightening; repo-ahead-of-live is a recognized staged state).
#
# Sections (each independent; worst exit wins, FAIL beats HELD):
# P1 local (no juju): repo overlay parses as a flat rule map;
# policies/overrides.zip member byte-matches policies/domain-manager-
# policy.yaml (same content compare as provider-bundle-check item 7)
# P2 juju: keystone leader discovered DYNAMICALLY from
# `juju status --format=json` (never hardcoded); workload message shows
# healthy "PO:" and not "PO (broken):" -- read from status JSON messages
# (DOCFIX-087: juju 3.6 line format omits workload messages entirely)
# P3 live base /etc/keystone/policy.json (charm-rendered, root-owned):
# valid JSON; the D-064 trio + helpers match the recorded values
# VERBATIM; cloud_admin compared STRUCTURALLY (it embeds the live admin
# domain/project IDs -- 32-hex IDs are wildcarded, never compared
# against hardcoded IDs); the D-065/D-073 trust-family premise still
# holds (create_trust legacy template; the five read rules "" -- the
# base stays broken/empty by design, the OVERLAY carries the fixes);
# rule count vs the recorded 2026-07-06 baseline of 224 (WARN only --
# a count change means the charm re-rendered the base; re-baseline)
# P4 live overlay /etc/keystone/policy.d/domain-manager-policy.yaml vs
# policies/domain-manager-policy.yaml: PARSED rule sets via
# scripts/keystone_policy_compare.py. The charm strips comments in the
# policyd round-trip, so file sizes/bytes NEVER match -- a byte compare
# would always false-fail. Per-rule diffs are printed on mismatch, and
# "live == repo minus exactly the staged D-073 delta" gets its own
# clean verdict distinct from arbitrary drift.
#
# All live reads go over `juju ssh` stdout pipes -- nothing is staged on the
# unit or under /tmp (snap confinement). Structured captures take stdout
# ONLY; stderr goes to a file and is surfaced on failure (DOCFIX-094).
# Mutates NOTHING. Evidence only: remediation (attach-resource, config
# changes) stays a gated human step per appendix-C C.3.
#
# NOTE: /etc/keystone/keystone.policy.yaml on the unit is the inert
# fully-commented defaults template -- deliberately NOT read here.
#
# Usage: bash scripts/keystone-policy-drift.sh [MODEL] (default: openstack)
# Exit: 0 PASS (including the staged-D-073 annotated pass)
# 1 DRIFT (any FAIL: rule drift, PO broken, zip/yaml mismatch)
# 2 HELD (juju/leader/unit unreachable -- evidence incomplete) or a
# precondition missing (tool absent, repo artifact missing).
# ASCII + LF.
set -uo pipefail
shopt -s inherit_errexit 2>/dev/null || true
IFS=$'\n\t'
MODEL="openstack"
for a in "$@"; do
case "$a" in
-*) echo "usage: bash scripts/keystone-policy-drift.sh [MODEL]" >&2; exit 2 ;;
*) MODEL="$a" ;;
esac
done
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO="$(cd "$SCRIPT_DIR/.." && pwd)"
RYAML="$REPO/policies/domain-manager-policy.yaml"
RZIP="$REPO/policies/overrides.zip"
CMP="$SCRIPT_DIR/keystone_policy_compare.py"
command -v juju >/dev/null 2>&1 || { echo "PRECOND: juju not found" >&2; exit 2; }
command -v jq >/dev/null 2>&1 || { echo "PRECOND: jq required" >&2; exit 2; }
command -v python3 >/dev/null 2>&1 || { echo "PRECOND: python3 required" >&2; exit 2; }
python3 -c 'import yaml' >/dev/null 2>&1 || { echo "PRECOND: python3 yaml module required" >&2; exit 2; }
[ -s "$RYAML" ] || { echo "PRECOND: $RYAML missing" >&2; exit 2; }
[ -s "$RZIP" ] || { echo "PRECOND: $RZIP missing" >&2; exit 2; }
[ -s "$CMP" ] || { echo "PRECOND: $CMP missing" >&2; exit 2; }
TMPD="$(mktemp -d)"; trap 'rm -rf "$TMPD"' EXIT
WORST=0; STAGED=0
ok() { echo " [ok] $*"; }
fail() { echo " [FAIL] $*"; WORST=1; }
warn() { echo " [WARN] $*"; [ "$WORST" -ne 1 ] && WORST=2; }
held() { echo " [HELD] $*"; [ "$WORST" -ne 1 ] && WORST=2; }
# Live reads: stdout ONLY into the capture; stderr to a file (DOCFIX-094).
Jout() { juju "$@" </dev/null 2>"$TMPD/err"; }
jerr() { head -1 "$TMPD/err" 2>/dev/null || true; }
echo "================ P1: local artifacts (repo-only, no juju) ================"
POUT=$(python3 "$CMP" parse "$RYAML" 2>"$TMPD/err"); PRC=$?
if [ "$PRC" -eq 0 ]; then ok "repo overlay parses ($POUT)"
else fail "repo overlay unparseable: $POUT $(jerr)"; fi
ZOUT=$(python3 "$CMP" zipcheck "$RZIP" "$RYAML" 2>"$TMPD/err"); ZRC=$?
case "$ZRC" in
0) ok "$ZOUT" ;;
1) fail "$ZOUT" ;;
*) fail "zip unreadable: $ZOUT $(jerr)" ;;
esac
echo "================ P2: keystone status + leader (model $MODEL) ================"
LEADER=""
ST=$(Jout status -m "$MODEL" keystone --format=json); SRC=$?
if [ "$SRC" -ne 0 ] || ! jq -e '.applications.keystone' >/dev/null 2>&1 <<<"$ST"; then
held "cannot read juju status for keystone in model '$MODEL' -- P2-P4 held: $(jerr)"
else
# DOCFIX-087: PO marker lives in the workload MESSAGES; read them from JSON.
KMSG=$(jq -r '(.applications.keystone // {}) as $k
| ([($k."application-status".message // "")]
+ [(($k.units // {}) | to_entries[] | (.value."workload-status".message // ""))])
| join(" | ")' <<<"$ST" 2>/dev/null || true)
if grep -q 'PO (broken)' <<<"$KMSG"; then
fail "keystone shows PO (broken) -- override attached but unparseable (atomic discard; D-051 hard warning)"
elif grep -q 'PO:' <<<"$KMSG"; then
ok "keystone workload shows healthy PO: (override loaded; behavioral G3 remains the real acceptance)"
else
fail "keystone shows NO policy-override marker (override not attached? DOCFIX-071)"
fi
LEADER=$(jq -r '[(.applications.keystone.units // {}) | to_entries[]
| select(.value.leader == true) | .key][0] // ""' <<<"$ST" 2>/dev/null || true)
if [[ "$LEADER" =~ ^keystone/[0-9]+$ ]]; then
ok "keystone leader discovered: $LEADER"
else
held "no keystone leader in status (election in progress?) -- P3/P4 held"
LEADER=""
fi
fi
if [ -n "$LEADER" ]; then
echo "================ P3: live base policy.json (via $LEADER) ================"
BASE=$(Jout ssh -m "$MODEL" "$LEADER" -- sudo cat /etc/keystone/policy.json); BRC=$?
BASE=$(tr -d '\r' <<<"$BASE")
if [ "$BRC" -ne 0 ] || [ -z "$BASE" ]; then
held "cannot read live policy.json over juju ssh: $(jerr)"
elif ! jq -e 'type == "object"' >/dev/null 2>&1 <<<"$BASE"; then
fail "live policy.json is NOT valid JSON (charm render broken?): $(head -c 120 <<<"$BASE")"
else
base_rule() { jq -r --arg k "$1" '.[$k] // "__ABSENT__"' <<<"$BASE"; }
chk() { # chk <rule> <want-verbatim>
local k="$1" want="$2" got
got=$(base_rule "$k")
if [ "$got" = "$want" ]; then ok "base $k matches recorded value"
else fail "base $k drifted: live='$got' want='$want'"; fi
}
TRIO="rule:cloud_admin or rule:admin_and_matching_domain_id"
chk "identity:list_users" "$TRIO" # D-064 (explicit in live policy.json)
chk "identity:list_projects" "$TRIO" # D-064
chk "identity:list_groups" "$TRIO" # D-064
chk "admin_required" "role:Admin"
chk "admin_and_matching_domain_id" "rule:admin_required and domain_id:%(domain_id)s"
chk "default" "rule:admin_required"
CA=$(base_rule "cloud_admin")
if [ "$CA" = "__ABSENT__" ] || [ -z "$CA" ]; then
fail "base cloud_admin missing/empty"
else
# cloud_admin embeds the LIVE admin domain/project IDs: wildcard every
# 32-hex ID and check the structure -- never compare hardcoded IDs.
CAN=$(sed -E 's/[0-9a-f]{32}/<ID>/g' <<<"$CA")
if grep -q 'rule:admin_required' <<<"$CAN" \
&& grep -q 'domain_id:<ID>' <<<"$CAN" \
&& grep -q 'project_id:<ID>' <<<"$CAN"; then
ok "base cloud_admin structurally sound (IDs wildcarded): $CAN"
else
fail "base cloud_admin structure unexpected (IDs wildcarded): $CAN"
fi
fi
# D-065/D-073 premise: the base trust family stays legacy/empty; the
# OVERLAY carries the fixes. A base change here invalidates the overlay's
# evidence-gated scope and must be re-ruled, so it is drift.
chk "identity:create_trust" "user_id:%(trust.trustor_user_id)s" # D-065 (base defect; overlay fixes)
chk "identity:list_trusts" ""
chk "identity:get_trust" ""
chk "identity:delete_trust" ""
chk "identity:list_roles_for_trust" ""
chk "identity:get_role_for_trust" ""
N=$(jq 'length' <<<"$BASE" 2>/dev/null || echo 0)
if [ "$N" -eq 224 ]; then ok "base rule count $N (matches the 2026-07-06 baseline)"
else warn "base rule count $N != recorded 224 -- charm re-rendered the base; re-baseline this check"; fi
fi
echo "================ P4: live overlay vs repo (parsed rules) ================"
LOVL=$(Jout ssh -m "$MODEL" "$LEADER" -- sudo cat /etc/keystone/policy.d/domain-manager-policy.yaml); ORC=$?
LOVL=$(tr -d '\r' <<<"$LOVL")
if [ "$ORC" -ne 0 ] || [ -z "$LOVL" ]; then
if grep -qi 'no such file' "$TMPD/err" 2>/dev/null; then
fail "live overlay MISSING at /etc/keystone/policy.d/domain-manager-policy.yaml (override never landed? DOCFIX-071)"
else
held "cannot read live overlay over juju ssh: $(jerr)"
fi
else
printf '%s\n' "$LOVL" > "$TMPD/live-overlay.yaml"
COUT=$(python3 "$CMP" compare "$RYAML" "$TMPD/live-overlay.yaml" 2>"$TMPD/err"); CRC=$?
case "$CRC" in
0) ok "live overlay == repo overlay ($COUT)" ;;
3) STAGED=1
ok "repo ahead of live by EXACTLY the staged D-073 delta (identity:list_trusts); live apply pending (gated attach window, appendix-C C.3)" ;;
1) fail "live overlay DIFFERS from repo overlay (parsed rules):"
sed 's/^/ /' <<<"$COUT" ;;
*) fail "overlay comparison failed: $COUT $(jerr)" ;;
esac
fi
fi
echo
case "$WORST" in
0) if [ "$STAGED" -eq 1 ]; then
echo "KEYSTONE-POLICY-DRIFT: PASS (D-073 staged: repo ahead of live by exactly identity:list_trusts; apply via the gated attach window)"
else
echo "KEYSTONE-POLICY-DRIFT: PASS (repo and live keystone policy aligned)"
fi
exit 0 ;;
2) echo "KEYSTONE-POLICY-DRIFT: HELD -- evidence incomplete (see [HELD]/[WARN] lines); re-run when the live side is reachable"
exit 2 ;;
*) echo "KEYSTONE-POLICY-DRIFT: DRIFT -- see [FAIL] lines; remediation is a gated human step (appendix-C C.3), never automatic"
exit 1 ;;
esac