informed-decision/tools/create_sitting_approvals.py
tegwick 12311d196c Sitting requester: allow approval:read, keep approve/consume refused
key-cape 82b471f prepares approval:read on informed-decision-sitting-requester
for present_existing intake reads (INFD-WP-0010). The registration doc, JSON
and test no longer forbid read (applied state stays create-only; read is
recorded as preparedScopes), and create_sitting_approvals checks only
approve and consume, so it keeps working once read is live.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 5502@bnt-lap001
Assistant-Session: 68b3b02f-5d0f-4e71-8421-796f62e50196
2026-11-01 04:02:50 +01:00

301 lines
12 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"
EXPECTED = 7
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) != EXPECTED:
raise ValueError(f"expected {EXPECTED} 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")
parser.add_argument("--sitting", help="batch date directory under docs/batches, e.g. 2026-09-21")
parser.add_argument("--receipt", help="receipt file name under docs/evidence; required with --sitting")
parser.add_argument("--expect", type=int, help="exact number of postable intents; required with --sitting")
args = parser.parse_args()
global INTENTS, RECEIPT, EXPECTED
if args.sitting:
if not args.receipt or not args.expect or "/" in args.sitting or "/" in args.receipt:
print("sitting_requires_receipt_and_expect", file=sys.stderr)
return 2
INTENTS = ROOT / "docs" / "batches" / args.sitting / "approval-create-intents.json"
RECEIPT = ROOT / "docs" / "evidence" / args.receipt
EXPECTED = args.expect
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:" + type(error).__name__
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
#: Scopes the sitting requester must never obtain. approval:read is allowed since
#: key-cape 82b471f (present_existing intake reads, INFD-WP-0010); approving and
#: consuming stay with the human client and a PEP.
EXCESS_SCOPES = ("approval:approve", "approval:consume")
def refuse_excess_scopes(exchange):
"""Require KeyCape to refuse every excess scope; approval:read may be granted or not."""
for scope in EXCESS_SCOPES:
if exchange(scope)[0] != 400:
raise Refused("excess_scope_not_refused")
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",
leeway=30,
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")
refuse_excess_scopes(exchange)
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())