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