diff --git a/docs/keycape-sitting-requester-registration.json b/docs/keycape-sitting-requester-registration.json index 3475d0d..82cb164 100644 --- a/docs/keycape-sitting-requester-registration.json +++ b/docs/keycape-sitting-requester-registration.json @@ -5,6 +5,10 @@ "allowedScopes": [ "approval:create" ], + "preparedScopes": [ + "approval:read" + ], + "preparedRef": "key-cape 82b471f (source only; founder-run registry change pending)", "grantTypes": [ "client_credentials" ], diff --git a/docs/keycape-sitting-requester-registration.md b/docs/keycape-sitting-requester-registration.md index 0b6035c..f8f923d 100644 --- a/docs/keycape-sitting-requester-registration.md +++ b/docs/keycape-sitting-requester-registration.md @@ -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 diff --git a/tests/test_create_sitting_approvals.py b/tests/test_create_sitting_approvals.py index c6a49f3..0f37ba3 100644 --- a/tests/test_create_sitting_approvals.py +++ b/tests/test_create_sitting_approvals.py @@ -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})) diff --git a/tests/test_sitting_requester_registration.py b/tests/test_sitting_requester_registration.py index 50777fd..b48324b 100644 --- a/tests/test_sitting_requester_registration.py +++ b/tests/test_sitting_requester_registration.py @@ -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"] diff --git a/tools/create_sitting_approvals.py b/tools/create_sitting_approvals.py index f61d6a4..a9dea71 100644 --- a/tools/create_sitting_approvals.py +++ b/tools/create_sitting_approvals.py @@ -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)