From c3742e27d2321c31b01fa1b69d2b87f2afe5232b Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 15 Sep 2026 22:19:25 +0200 Subject: [PATCH] Land attended sitting create now that CCR-2026-0026/0027 is live. Platform verified create-only exchange; this shell cannot POST. create_sitting_approvals.py requires attended reader, skips c01, and refuses a non-loopback approval origin. Dry-run lists the seven memos. Assistant: grok Assistant-Session: 01a09dc1-b21e-77e1-919e-fcad2f82b267 --- docs/batches/2026-09-14/OPERATOR.md | 15 +- docs/batches/2026-09-14/sitting.json | 4 +- ...eycape-sitting-requester-registration.json | 6 +- .../keycape-sitting-requester-registration.md | 7 +- tests/test_create_sitting_approvals.py | 40 +++ tests/test_sitting_requester_registration.py | 5 +- tools/create_sitting_approvals.py | 247 ++++++++++++++++++ .../INFD-WP-0002-compact-signoff-batches.md | 10 +- 8 files changed, 318 insertions(+), 16 deletions(-) create mode 100644 tests/test_create_sitting_approvals.py create mode 100644 tools/create_sitting_approvals.py diff --git a/docs/batches/2026-09-14/OPERATOR.md b/docs/batches/2026-09-14/OPERATOR.md index 2849de8..8d893a2 100644 --- a/docs/batches/2026-09-14/OPERATOR.md +++ b/docs/batches/2026-09-14/OPERATOR.md @@ -61,9 +61,18 @@ Open in this order. One question each. No approve-all. 1. `uv run python tools/sitting_bind_preflight.py --origin https://decisions.coulomb.social` — `live_accept` must stay `open`. -2. Owning requester creates eight `human_control=true` objects, required_count 1, - no entries. Carry the native id and `binding.digest`; do not invent a digest. - Platform custody (unallocated CCR): `docs/sitting-requester-custody-request.md`. +2. Sitting requester is **live** (CCR-2026-0026/0027, exchange proof 2026-09-15). + Create seven objects from an **attended** reader session (skips `c01`): + + ```sh + kubectl -n approval-engine port-forward svc/approval-engine 18281:8080 + warden access informed-decision-sitting-requester-login --exec -- \ + env INFD_APPROVAL_ORIGIN=http://127.0.0.1:18281 \ + uv run python tools/create_sitting_approvals.py + ``` + + Dry-run (this shell): `uv run python tools/create_sitting_approvals.py --dry-run`. + Do not invent a digest. `c01` stays off this client until decided. 3. `uv run python tools/attach_compact_bindings.py --principal --receipt ` writes `bound/` copies. Unsigned drafts stay unsigned. 4. Flex Auth admits a **new** package pinning those eight `memo:` ids to the diff --git a/docs/batches/2026-09-14/sitting.json b/docs/batches/2026-09-14/sitting.json index 57f8e49..7e7f549 100644 --- a/docs/batches/2026-09-14/sitting.json +++ b/docs/batches/2026-09-14/sitting.json @@ -8,6 +8,6 @@ "infd-batch-2026-09-14-decisions" ], "memo_count": 8, - "bind_path": "INFD-WP-0001-T08 historically proven; live accept open 2026-09-14T22:16:07Z; sitting not admitted", - "note": "Unsigned drafts. Live accept reopened after audit-core rollout. Remaining: native approval receipts, live KeyCape subject, new Flex Auth package (not the T03 three-record mandate), then a human sitting. Operator packet: OPERATOR.md." + "bind_path": "live accept open; sitting-requester live; attended create not yet run", + "note": "Unsigned drafts. CCR-2026-0026/0027 applied and create-only exchange proof verified 2026-09-15. Next: attended tools/create_sitting_approvals.py (skips c01), then Flex Auth package, then human sitting." } diff --git a/docs/keycape-sitting-requester-registration.json b/docs/keycape-sitting-requester-registration.json index f839369..3475d0d 100644 --- a/docs/keycape-sitting-requester-registration.json +++ b/docs/keycape-sitting-requester-registration.json @@ -16,6 +16,8 @@ "informed-decision-sitting-requester" ], "tokenLifetime": "15m", - "status": "requested", - "applied": false + "status": "applied", + "applied": true, + "ccrs": ["CCR-2026-0026", "CCR-2026-0027"], + "exchange_proof": "railiance-platform/docs/evidence/2026-09-15-sitting-requester-exchange.json" } diff --git a/docs/keycape-sitting-requester-registration.md b/docs/keycape-sitting-requester-registration.md index b605a22..0b6035c 100644 --- a/docs/keycape-sitting-requester-registration.md +++ b/docs/keycape-sitting-requester-registration.md @@ -4,9 +4,10 @@ **For:** `key-cape` (same registration shape as `secrets-engine-requester`) **Copied to:** `approval-engine` (`docs/keycape-service-registrations.md` — “no requester identity has been settled for `approval:create`”) -**Status:** requested 2026-09-14. **Not registered. Not in OpenBao. No secret -is in this repository.** Custody request (no CCR id): -`docs/sitting-requester-custody-request.md`. +**Status:** **applied 2026-09-15** (CCR-2026-0026/0027, KeyCape row live, +create-only exchange proof verified). No secret is in this repository. +Sittings are still INFD-WP-0002; platform did not POST. Attended create: +`tools/create_sitting_approvals.py`. This is the missing presenter for compact Decision Memo sittings whose protected side effect is a **work-record update** (`INFD-WP-0002-T04`), not a diff --git a/tests/test_create_sitting_approvals.py b/tests/test_create_sitting_approvals.py new file mode 100644 index 0000000..c3285bf --- /dev/null +++ b/tests/test_create_sitting_approvals.py @@ -0,0 +1,40 @@ +import importlib.util +from pathlib import Path + +import pytest + +spec = importlib.util.spec_from_file_location( + "create_sitting_approvals", + Path(__file__).resolve().parents[1] / "tools" / "create_sitting_approvals.py", +) +create = importlib.util.module_from_spec(spec) +spec.loader.exec_module(create) + + +def test_dry_run_lists_seven_and_skips_c01(): + report = create.dry_run() + assert report["posted"] is False + assert report["skipped"] == ["infd-20260914-c01"] + assert len(report["memo_ids"]) == 7 + assert "infd-20260914-c01" not in report["memo_ids"] + assert report["memo_ids"][0] == "infd-20260914-c02" + assert report["memo_ids"][-1] == "infd-20260914-d04" + + +def test_require_attended_refuses_this_shell(): + with pytest.raises(ValueError, match="attended_reader_required"): + create.require_attended() + + +@pytest.mark.parametrize( + "origin,ok", + [ + ("http://127.0.0.1:18281", True), + ("http://approval-engine.approval-engine.svc.cluster.local:8080", True), + ("https://evil.example", False), + ("http://10.43.103.108:8080", False), + (None, False), + ], +) +def test_approval_origin_is_loopback_or_in_cluster_only(origin, ok): + assert create._approval_origin_ok(origin) is ok diff --git a/tests/test_sitting_requester_registration.py b/tests/test_sitting_requester_registration.py index 7d79ef1..279ec85 100644 --- a/tests/test_sitting_requester_registration.py +++ b/tests/test_sitting_requester_registration.py @@ -9,7 +9,7 @@ REG = json.loads((ROOT / "docs" / "keycape-sitting-requester-registration.json") INTENTS = json.loads((ROOT / "docs" / "batches" / "2026-09-14" / "approval-create-intents.json").read_text()) -def test_sitting_requester_is_create_only_and_unapplied(): +def test_sitting_requester_is_create_only_and_applied(): assert REG["clientId"] == "informed-decision-sitting-requester" assert REG["audience"] == "approval-engine" assert REG["allowedScopes"] == ["approval:create"] @@ -17,7 +17,8 @@ def test_sitting_requester_is_create_only_and_unapplied(): assert REG["clientType"] == "confidential" assert REG["serviceSubject"] == "informed-decision" assert REG["tenant"] == "tenant:platform" - assert REG["applied"] is False + assert REG["applied"] is True + assert REG["ccrs"] == ["CCR-2026-0026", "CCR-2026-0027"] assert "redirect" not in json.dumps(REG).lower() forbidden = {"approval:approve", "approval:consume", "approval:read", "openid"} assert forbidden.isdisjoint(REG["allowedScopes"]) diff --git a/tools/create_sitting_approvals.py b/tools/create_sitting_approvals.py new file mode 100644 index 0000000..50984d1 --- /dev/null +++ b/tools/create_sitting_approvals.py @@ -0,0 +1,247 @@ +"""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, {} + return status, json.loads(content) + + +def require_attended() -> None: + if Path.home().parent.name != ".warden-attended-login" or os.getenv("BAO_TOKEN") or os.getenv("VAULT_TOKEN"): + raise ValueError("attended_reader_required") + + +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 ValueError("approval_origin_required") + except ValueError as exc: + print(exc, file=sys.stderr) + return 2 + if RECEIPT.exists(): + raise ValueError("receipt_already_exists") + 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) + finally: + RECEIPT.write_text(json.dumps(receipt, indent=2) + "\n") + print(json.dumps({"status": receipt["status"], "phase": receipt["phase"], "count": len(receipt["requests"])})) + 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 ValueError("metadata_failed") + return json.loads(p.stdout) + + 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 ValueError("reader_identity_failed") + if capabilities(bao("token", "capabilities", "-format=json", KV)) != ["read"]: + raise ValueError("reader_scope_failed") + if not is_deny(bao("token", "capabilities", "-format=json", SIBLING)): + raise ValueError("reader_scope_failed") + if not is_deny(bao("token", "capabilities", "-format=json", PARENT)): + raise ValueError("reader_scope_failed") + helper = Path.home() / ".vault-token" + info = helper.lstat() + if not stat.S_ISREG(info.st_mode) or stat.S_IMODE(info.st_mode) != 0o600 or info.st_uid != os.getuid(): + raise ValueError("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 ValueError("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 ValueError("requester_exchange_failed") + token = tokens["access_token"] + status, jwks = http(ISSUER + "/jwks") + if status != 200: + raise ValueError("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 ValueError("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 ValueError("requester_claims_failed") + if int(claims["exp"]) - int(claims["iat"]) != 900: + raise ValueError("requester_claims_failed") + for scope in ("approval:approve", "approval:consume", "approval:read"): + if exchange(scope)[0] != 400: + raise ValueError("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 ValueError("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()) diff --git a/workplans/INFD-WP-0002-compact-signoff-batches.md b/workplans/INFD-WP-0002-compact-signoff-batches.md index 231da14..e5ac01a 100644 --- a/workplans/INFD-WP-0002-compact-signoff-batches.md +++ b/workplans/INFD-WP-0002-compact-signoff-batches.md @@ -152,10 +152,12 @@ no redirect). Intents for the eight bindings are in `docs/batches/2026-09-14/approval-create-intents.json` (`posted: false`; `c01` create-client undecided). No secret, no POST, no bind. -2026-09-14 23:00 UTC — **custody requested from railiance-platform, no CCR -id allocated.** `docs/sitting-requester-custody-request.md` asks for a new -KV path `platform/workloads/informed-decision/sitting-requester` (verifier + -attended reader), not a widening of CCR-2026-0024/0025. Task stays `wait`. +2026-09-15 — **requester live; sittings still wait on attended create + +human bind.** RPF-WP-0042 finished: CCR-2026-0026/0027 applied, exchange +proof verified, no sitting POST. `tools/create_sitting_approvals.py` +requires attended reader and posts seven intents (`c01` skipped). Flex +Auth package still waits on those native ids. This shell is not an +attended session. Task stays `wait`. ## Feed outcomes back to State Hub without hub-authoring