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:
parent
80ef8408ed
commit
12311d196c
5 changed files with 48 additions and 8 deletions
|
|
@ -5,6 +5,10 @@
|
|||
"allowedScopes": [
|
||||
"approval:create"
|
||||
],
|
||||
"preparedScopes": [
|
||||
"approval:read"
|
||||
],
|
||||
"preparedRef": "key-cape 82b471f (source only; founder-run registry change pending)",
|
||||
"grantTypes": [
|
||||
"client_credentials"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}))
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue