Auth returned clean at 20:27Z but the child left a preflight receipt with no error. Named failures, refuse public bao.coulomb.social, and overwrite a failed receipt. Wrapper is executable for attended-exec. Assistant: grok Assistant-Session: 01a09dc1-b21e-77e1-919e-fcad2f82b267
277 lines
11 KiB
Python
277 lines
11 KiB
Python
"""Create compact-sitting approval objects (INFD-WP-0002-T03).
|
|
|
|
Attended reader only. Does not bind, consume, or print CLIENT_SECRET.
|
|
Skips infd-20260914-c01 until its create_client is decided.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import stat
|
|
import sys
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
from urllib.error import HTTPError
|
|
from urllib.parse import urlencode
|
|
from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
INTENTS = ROOT / "docs" / "batches" / "2026-09-14" / "approval-create-intents.json"
|
|
RECEIPT = ROOT / "docs" / "evidence" / "2026-09-15-sitting-approval-creates.json"
|
|
POLICY = "workload-kv-read-informed-decision-sitting-requester-client"
|
|
KV = "platform/data/workloads/informed-decision/sitting-requester"
|
|
SIBLING = "platform/data/workloads/secrets-engine/approval-requester"
|
|
PARENT = "platform/metadata/workloads/informed-decision"
|
|
ISSUER = "https://kc.coulomb.social"
|
|
CLIENT_ID = "informed-decision-sitting-requester"
|
|
|
|
|
|
class NoRedirect(HTTPRedirectHandler):
|
|
def redirect_request(self, *args, **kwargs):
|
|
return None
|
|
|
|
|
|
def http(url, *, body=None, headers=None):
|
|
req = Request(url, data=body, headers=headers or {})
|
|
try:
|
|
with build_opener(ProxyHandler({}), NoRedirect()).open(req, timeout=20) as response:
|
|
content = response.read(1048577)
|
|
status = response.status
|
|
except HTTPError as error:
|
|
status = error.code
|
|
content = error.read(1048577)
|
|
error.close()
|
|
if len(content) > 1048576:
|
|
raise ValueError("response_too_large")
|
|
if not content:
|
|
return status, {}
|
|
try:
|
|
return status, json.loads(content)
|
|
except json.JSONDecodeError as exc:
|
|
raise ValueError("non_json_http_response") from exc
|
|
|
|
|
|
class Refused(ValueError):
|
|
pass
|
|
|
|
|
|
def require_attended() -> None:
|
|
if Path.home().parent.name != ".warden-attended-login" or os.getenv("BAO_TOKEN") or os.getenv("VAULT_TOKEN"):
|
|
raise Refused("attended_reader_required")
|
|
addr = os.getenv("BAO_ADDR") or os.getenv("VAULT_ADDR") or ""
|
|
if "bao.coulomb.social" in addr:
|
|
raise Refused("public_bao_retracted")
|
|
|
|
|
|
def existing_receipt_blocks() -> bool:
|
|
if not RECEIPT.exists():
|
|
return False
|
|
try:
|
|
existing = json.loads(RECEIPT.read_text())
|
|
except Exception:
|
|
return True
|
|
return existing.get("status") == "created"
|
|
|
|
|
|
def postable_intents(data: dict) -> list[dict]:
|
|
rows = [row for row in data.get("intents") or [] if row.get("create_client") == CLIENT_ID]
|
|
if len(rows) != 7:
|
|
raise ValueError("expected seven postable sitting intents")
|
|
for row in rows:
|
|
binding = row["binding"]
|
|
if binding.get("actor") != "informed-decision":
|
|
raise ValueError("binding.actor must be informed-decision")
|
|
if set(binding) != {"action", "actor", "principal", "purpose", "target"}:
|
|
raise ValueError("binding must be the five native fields")
|
|
return rows
|
|
|
|
|
|
def _approval_origin_ok(origin: str | None) -> bool:
|
|
if not origin:
|
|
return False
|
|
if origin.startswith("http://127.0.0.1:") and origin.count("/") == 2:
|
|
return True
|
|
return origin in {
|
|
"http://approval-engine.approval-engine.svc.cluster.local:8080",
|
|
"http://approval-engine.approval-engine.svc:8080",
|
|
}
|
|
|
|
|
|
def dry_run() -> dict:
|
|
data = json.loads(INTENTS.read_text())
|
|
rows = postable_intents(data)
|
|
return {
|
|
"kind": "informed-decision-sitting-create-dry-run",
|
|
"posted": False,
|
|
"memo_ids": [row["memo_id"] for row in rows],
|
|
"skipped": [row["memo_id"] for row in data["intents"] if row.get("create_client") != CLIENT_ID],
|
|
"requester_client": CLIENT_ID,
|
|
"human_control": True,
|
|
"pdp_path": False,
|
|
}
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--dry-run", action="store_true", help="list postable memos; no credentials, no POST")
|
|
parser.add_argument("--approval-origin", help="approval-engine origin; or INFD_APPROVAL_ORIGIN")
|
|
args = parser.parse_args()
|
|
if args.dry_run:
|
|
print(json.dumps(dry_run(), indent=2, ensure_ascii=False))
|
|
return 0
|
|
try:
|
|
require_attended()
|
|
origin = args.approval_origin or os.environ.get("INFD_APPROVAL_ORIGIN")
|
|
if not _approval_origin_ok(origin):
|
|
raise Refused("approval_origin_required")
|
|
if existing_receipt_blocks():
|
|
raise Refused("receipt_already_exists")
|
|
except ValueError as exc:
|
|
print(exc, file=sys.stderr)
|
|
return 2
|
|
data = json.loads(INTENTS.read_text())
|
|
rows = postable_intents(data)
|
|
receipt = {
|
|
"observed_at": datetime.now(timezone.utc).isoformat(),
|
|
"status": "failed",
|
|
"phase": "preflight",
|
|
"requests": [],
|
|
"credential_values_emitted": False,
|
|
"human_entries_created": False,
|
|
"skipped": [row["memo_id"] for row in data["intents"] if row.get("create_client") != CLIENT_ID],
|
|
}
|
|
try:
|
|
_create(rows, origin.rstrip("/"), receipt)
|
|
except Exception as error:
|
|
receipt["failure"] = str(error) if isinstance(error, Refused) else "contained_operation_failed"
|
|
print(receipt["failure"], file=sys.stderr)
|
|
return 2
|
|
finally:
|
|
RECEIPT.write_text(json.dumps(receipt, indent=2) + "\n")
|
|
print(json.dumps({"status": receipt["status"], "phase": receipt["phase"], "count": len(receipt["requests"]), "failure": receipt.get("failure")}))
|
|
return 0 if receipt["status"] == "created" else 2
|
|
|
|
|
|
def _create(rows, origin, receipt):
|
|
import jwt
|
|
import subprocess
|
|
|
|
def bao(*args):
|
|
p = subprocess.run(["bao", *args], capture_output=True, text=True, timeout=20)
|
|
if p.returncode:
|
|
raise Refused("metadata_failed")
|
|
try:
|
|
return json.loads(p.stdout)
|
|
except json.JSONDecodeError as exc:
|
|
raise Refused("metadata_failed") from exc
|
|
|
|
def capabilities(result):
|
|
if isinstance(result, dict):
|
|
result = result.get("data", result).get("capabilities", result)
|
|
return result if isinstance(result, list) else []
|
|
|
|
def is_deny(result):
|
|
caps = set(capabilities(result))
|
|
return caps <= {"deny"} or not caps.intersection({"read", "create", "update", "delete", "list", "patch", "sudo"})
|
|
|
|
lookup = bao("token", "lookup", "-format=json")["data"]
|
|
policies = set(lookup.get("policies", [])) | set(lookup.get("identity_policies", []))
|
|
if POLICY not in policies or policies - {POLICY, "default"} or not lookup.get("entity_id") or not 0 < int(lookup.get("ttl") or 0) <= 900:
|
|
raise Refused("reader_identity_failed")
|
|
if capabilities(bao("token", "capabilities", "-format=json", KV)) != ["read"]:
|
|
raise Refused("reader_scope_failed")
|
|
if not is_deny(bao("token", "capabilities", "-format=json", SIBLING)):
|
|
raise Refused("reader_scope_failed")
|
|
if not is_deny(bao("token", "capabilities", "-format=json", PARENT)):
|
|
raise Refused("reader_scope_failed")
|
|
helper = Path.home() / ".vault-token"
|
|
try:
|
|
info = helper.lstat()
|
|
except FileNotFoundError as exc:
|
|
raise Refused("private_helper_required") from exc
|
|
if not stat.S_ISREG(info.st_mode) or stat.S_IMODE(info.st_mode) != 0o600 or info.st_uid != os.getuid():
|
|
raise Refused("private_helper_required")
|
|
payload = bao("read", "-format=json", KV)
|
|
body = payload.get("data", payload)
|
|
inner = body.get("data", body)
|
|
secret = inner.get("CLIENT_SECRET") if isinstance(inner, dict) else None
|
|
if (body.get("metadata") or {}).get("version") != 1 or not secret:
|
|
raise Refused("requester_delivery_failed")
|
|
|
|
def exchange(scope, credential=secret):
|
|
auth = __import__("base64").b64encode((CLIENT_ID + ":" + credential).encode()).decode()
|
|
return http(
|
|
ISSUER + "/token",
|
|
body=urlencode({"grant_type": "client_credentials", "scope": scope}).encode(),
|
|
headers={"Authorization": "Basic " + auth, "Content-Type": "application/x-www-form-urlencoded"},
|
|
)
|
|
|
|
status, tokens = exchange("approval:create")
|
|
if status != 200 or "access_token" not in tokens:
|
|
raise Refused("requester_exchange_failed")
|
|
token = tokens["access_token"]
|
|
status, jwks = http(ISSUER + "/jwks")
|
|
if status != 200:
|
|
raise Refused("jwks_failed")
|
|
header = jwt.get_unverified_header(token)
|
|
keys = [key for key in jwks["keys"] if key["kid"] == header.get("kid")]
|
|
if header.get("alg") != "RS256" or len(keys) != 1:
|
|
raise Refused("signing_key_failed")
|
|
claims = jwt.decode(
|
|
token,
|
|
jwt.PyJWK.from_dict(keys[0]).key,
|
|
algorithms=["RS256"],
|
|
issuer=ISSUER,
|
|
audience="approval-engine",
|
|
options={"strict_aud": True, "require": ["sub", "iat", "exp", "iss", "aud"]},
|
|
)
|
|
roles = claims.get("roles")
|
|
if isinstance(roles, str):
|
|
roles = [roles]
|
|
expected = {
|
|
"sub": "informed-decision",
|
|
"tenant": "tenant:platform",
|
|
"principal_type": "service",
|
|
"scope": "approval:create",
|
|
}
|
|
if any(claims.get(k) != v for k, v in expected.items()) or roles != ["informed-decision-sitting-requester"]:
|
|
raise Refused("requester_claims_failed")
|
|
if int(claims["exp"]) - int(claims["iat"]) != 900:
|
|
raise Refused("requester_claims_failed")
|
|
for scope in ("approval:approve", "approval:consume", "approval:read"):
|
|
if exchange(scope)[0] != 400:
|
|
raise Refused("excess_scope_not_refused")
|
|
del secret
|
|
receipt["phase"] = "requester_verified"
|
|
now = datetime.now(timezone.utc)
|
|
for row in rows:
|
|
body = {
|
|
"binding": row["binding"],
|
|
"validity": {"not_before": now.isoformat(), "expires_at": (now + timedelta(hours=24)).isoformat()},
|
|
"required_count": 1,
|
|
"human_control": True,
|
|
"pdp_path": False,
|
|
}
|
|
receipt["phase"] = "create_attempt:" + row["memo_id"]
|
|
RECEIPT.write_text(json.dumps(receipt, indent=2) + "\n")
|
|
status, result = http(
|
|
origin + "/v1/approvals",
|
|
body=json.dumps(body).encode(),
|
|
headers={"Authorization": "Bearer " + token, "Content-Type": "application/json"},
|
|
)
|
|
binding = (result or {}).get("binding") or {}
|
|
if (
|
|
status != 201
|
|
or result.get("status") != "requested"
|
|
or result.get("entries")
|
|
or binding.get("human_control") is not True
|
|
or not isinstance(binding.get("digest"), str)
|
|
):
|
|
raise Refused("request_creation_requires_reconciliation")
|
|
receipt["requests"].append({"memo_id": row["memo_id"], "approval": result})
|
|
receipt.update(status="created", phase="seven_unapproved_requests_created")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|