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
This commit is contained in:
tegwick 2026-11-01 04:02:50 +01:00
parent 80ef8408ed
commit 12311d196c
5 changed files with 48 additions and 8 deletions

View file

@ -5,6 +5,10 @@
"allowedScopes": [
"approval:create"
],
"preparedScopes": [
"approval:read"
],
"preparedRef": "key-cape 82b471f (source only; founder-run registry change pending)",
"grantTypes": [
"client_credentials"
],

View file

@ -31,7 +31,7 @@ This client names the presenter for *this* sitting.
| Grant | `client_credentials` |
| Client type | confidential |
| Audience | `approval-engine` (resource server, never the OAuth client id) |
| Scopes | `approval:create` **only** |
| Scopes | `approval:create`, plus `approval:read` once key-cape `82b471f` is applied; never approve or consume |
| `serviceSubject` / token `sub` | `informed-decision` |
| `tenant` | `tenant:platform` (exact) |
| `principal_type` | `service` |
@ -47,7 +47,11 @@ Machine-readable copy: `keycape-sitting-requester-registration.json`.
- **No** `approval:approve`. That stays on the human PKCE client.
- **No** `approval:consume`. Consumption belongs to a PEP. This sitting's
T04 path is repo files + `fix-consistency`.
- **No** `approval:read` on this client. The human already has read.
- `approval:read` is **allowed** (changed 2026-11-01). present_existing intake (INFD-WP-0010) GETs an
approval another actor created before presenting it. Prepared in key-cape `82b471f` (source only); the live
registry still holds `approval:create` alone until the founder-run change. `allowedScopes` in the JSON copy
records the applied state; `preparedScopes` records the pending one. Approve and consume stay refused, and
`tools/create_sitting_approvals.py` checks only those two.
- Do **not** reuse `approval-engine-operator` (cancelled bundle, too wide).
- Do **not** reuse `secrets-engine-requester` for unrelated acts.
- Do **not** register this client until `key-cape` owns the row and

View file

@ -74,3 +74,21 @@ def test_failed_receipt_does_not_block_retry(tmp_path, monkeypatch):
)
def test_approval_origin_is_loopback_or_in_cluster_only(origin, ok):
assert create._approval_origin_ok(origin) is ok
def _exchange(granted):
def exchange(scope):
return (200, {}) if scope in granted else (400, {})
return exchange
@pytest.mark.parametrize("granted", [set(), {"approval:read"}])
def test_excess_scope_check_allows_read(granted):
create.refuse_excess_scopes(_exchange(granted))
assert "approval:read" not in create.EXCESS_SCOPES
@pytest.mark.parametrize("scope", ["approval:approve", "approval:consume"])
def test_excess_scope_check_refuses_approve_and_consume(scope):
with pytest.raises(create.Refused, match="excess_scope_not_refused"):
create.refuse_excess_scopes(_exchange({"approval:read", scope}))

View file

@ -9,10 +9,12 @@ 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_applied():
def test_sitting_requester_never_approves_or_consumes_and_is_applied():
assert REG["clientId"] == "informed-decision-sitting-requester"
assert REG["audience"] == "approval-engine"
assert REG["allowedScopes"] == ["approval:create"]
# approval:read is prepared in key-cape 82b471f (source only) for present_existing reads.
assert REG["allowedScopes"] in (["approval:create"], ["approval:create", "approval:read"])
assert REG["preparedScopes"] == ["approval:read"]
assert REG["grantTypes"] == ["client_credentials"]
assert REG["clientType"] == "confidential"
assert REG["serviceSubject"] == "informed-decision"
@ -20,8 +22,9 @@ def test_sitting_requester_is_create_only_and_applied():
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"}
forbidden = {"approval:approve", "approval:consume", "openid"}
assert forbidden.isdisjoint(REG["allowedScopes"])
assert forbidden.isdisjoint(REG["preparedScopes"])
assert not REG["secretRef"].startswith("s.") and ":" in REG["secretRef"]

View file

@ -165,6 +165,19 @@ def main() -> int:
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
@ -252,9 +265,7 @@ def _create(rows, origin, receipt):
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")
refuse_excess_scopes(exchange)
del secret
receipt["phase"] = "requester_verified"
now = datetime.now(timezone.utc)