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
This commit is contained in:
parent
8c03eb85c0
commit
c3742e27d2
8 changed files with 318 additions and 16 deletions
|
|
@ -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 <exact-keycape-sub> --receipt <created.json>`
|
||||
writes `bound/` copies. Unsigned drafts stay unsigned.
|
||||
4. Flex Auth admits a **new** package pinning those eight `memo:` ids to the
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
40
tests/test_create_sitting_approvals.py
Normal file
40
tests/test_create_sitting_approvals.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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"])
|
||||
|
|
|
|||
247
tools/create_sitting_approvals.py
Normal file
247
tools/create_sitting_approvals.py
Normal file
|
|
@ -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())
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue