key-cape/docs/sitting-requester-read-scope.md
tegwick 82b471fc36
Some checks failed
Authentication acceptance / acceptance (push) Failing after 56s
Authentication acceptance / provider-contract (push) Successful in 13s
Build and Publish Container Image / build-and-push (push) Successful in 26s
Add approval:read to informed-decision-sitting-requester (source only)
informed-decision's present_existing intake (INFD-WP-0010, 0e66a33) must GET
an existing approval before presenting it. This grants read only: no consume,
no approve, and the audience, subject, tenant and secret stay the same. The
tests pin the exact scope pair and keep the published JSON equal to the
source row.

The live registry is not changed. docs/sitting-requester-read-scope.md has the
record diff and the founder-run keycape-client.py remove/add/restart steps,
and lists the railiance-platform and informed-decision copies that still pin
create-only.

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
2026-11-01 00:34:07 +01:00

8.5 KiB

informed-decision-sitting-requester: add approval:read

Prepared 2026-11-01 by key-cape, the owner of the registration source. The request came from informed-decision through the net-kingdom coordinator, with the founder's approval ("Ask both now"). Requesting source: informed-decision 0e66a33, INFD-WP-0010, docs/decision-services-alignment-answer.md, blocker 2 and open question 4.

State: the source change is committed in key-cape. The live registry is not changed. Until the founder runs the live steps below, informed-decision's present_existing intake keeps failing closed with 503 approval_unreadable. Every other submitter is unaffected.

The change

Only one field changes, allowedScopes:

   "audience": "approval-engine",
   "allowedScopes": [
-    "approval:create"
+    "approval:create",
+    "approval:read"
   ],

Nothing else in the record changes: client id, audience, subject, tenant, role, lifetime, grant, client type and secretRef. Source: config/service-clients.example.yaml. Machine-readable copy: docs/sitting-requester-registration.json. A test keeps the two equal (TestSittingRequesterRegistrationShapeIsExact).

The full live record after the change (non-secret; secretRef is a custody name, not a value):

{
  "clientId": "informed-decision-sitting-requester",
  "displayName": "Informed Decision compact-sitting approval requester",
  "audience": "approval-engine",
  "allowedScopes": ["approval:create", "approval:read"],
  "grantTypes": ["client_credentials"],
  "clientType": "confidential",
  "secretRef": "env:KEYCAPE_INFORMED_DECISION_SITTING_REQUESTER_CLIENT_SECRET",
  "serviceSubject": "informed-decision",
  "tenant": "tenant:platform",
  "roles": ["informed-decision-sitting-requester"],
  "tokenLifetime": "15m"
}

The record before the change is the same file at key-cape 655d8d3 (git show 655d8d3:docs/sitting-requester-registration.json). That is the row railiance-platform provisioned on 2026-09-15 (CCR-2026-0026/0027).

Why this shape

  • Read only, never consume or approve. present_existing must GET /v1/approvals/{id} to check the approval before presenting it. That call needs approval:read and nothing else. Consumption still belongs to a PEP. Approval still belongs to the human PKCE client informed-decision-approver. The tests refuse approval:consume, approval:approve and openid on this client.
  • Same client, not a second read-only client. The read belongs to the same actor, informed-decision, as the create. It authenticates with the same custody secret. A second client would add a second secret, a second CCR pair and a second ESO binding, and it would separate no actor. Each token still carries one scope: informed-decision's RequesterTokenProvider asks for exactly approval:create or exactly approval:read, and it refuses a token whose scope claim differs from the one it asked for.
  • Audience approval-engine, subject informed-decision, tenant tenant:platform: all unchanged. approval-engine serves a GET by scope and exact store tenant. The approval being presented was filed in tenant:platform by another actor (for example service:nk-bind-executor), so a read under this tenant is enough. A read does not let the presenter create in another actor's name, because binding.actor == sub still applies to create.
  • Residual widening. KeyCape's client_credentials grant gives every allowed scope when a caller sends no scope parameter (src/internal/server/oidc/token.go). After this change, a holder of the secret who sends no scope gets a create+read token. That is still no consume and no approve, and the secret has only two readers: KeyCape through ESO and the informed-decision workload.

Who applies it live, and how

The live registry is the sso/keycape-config Secret. KeyCape reads it only at startup. Changes to that Secret are founder-run. net-kingdom's contained helper sso-mfa/k8s/keycape/keycape-client.py makes them: it changes only this client's lines, compares and swaps on uid and resourceVersion, runs KeyCape's check-config on the live and proposed registry, and prints no values. That helper treats an update as remove, then add. Each step needs an acceptance record.

What governs it. There is no acknowledged case type for this client. oidc-service-client 0.1 is still proposed. This client also would not fit it: the type requires service:-prefixed subjects, and this subject is the bare informed-decision, which approval-engine's create rule pins. So the acceptance record is the founder's explicit decision for this scope change, which is the same founder approval this request was raised under. A hub decision id is the natural record. Custody does not change: same secret, same env binding on the Deployment, same ESO object. So no new CCR is needed, and CCR-2026-0026/0027 stand as they are.

Steps (founder, attended, admin kubeconfig as for the other keycape-client runs; run from the net-kingdom checkout):

cd ~/net-kingdom/sso-mfa/k8s/keycape
S=$(mktemp -d)
git -C ~/key-cape show 655d8d3:docs/sitting-requester-registration.json > "$S/old.json"
cp ~/key-cape/docs/sitting-requester-registration.json "$S/new.json"
KC=<verified keycape binary with check-config, built from key-cape at or after this commit>
ACC=<hub decision id recording the founder's acceptance of this scope change>

# 1. inspect: old row must be present and identical (change_needed true for remove)
python3 keycape-client.py inspect --client-file "$S/old.json" --operation remove --check-config "$KC"
# 2. remove the old row (uid / resourceVersion from step 1's "before")
python3 keycape-client.py dry-run --client-file "$S/old.json" --operation remove \
  --expected-uid <uid> --expected-resource-version <rv>
python3 keycape-client.py apply --client-file "$S/old.json" --operation remove \
  --case-acceptance "$ACC" --check-config "$KC" \
  --expected-uid <uid> --expected-resource-version <rv>
# 3. add the new row (fresh resourceVersion from step 2's "after")
python3 keycape-client.py inspect --client-file "$S/new.json" --check-config "$KC"
python3 keycape-client.py apply --client-file "$S/new.json" \
  --case-acceptance "$ACC" --check-config "$KC" \
  --expected-uid <uid> --expected-resource-version <rv2>
# 4. one restart of the single Recreate replica, then readiness
#    (restart-readiness.py pattern, NK-WP-0048-T12); sign-in pauses briefly for all clients

Steps 2 and 3 happen with no restart in between. KeyCape reads the registry only at startup, so a running pod never sees the gap. Run both steps before the one restart.

Verify (non-secret, from the informed-decision workload or an attended exchange with the custody secret):

  • a client_credentials exchange with scope=approval:read gives 200, with scope exactly approval:read, sub informed-decision, aud approval-engine and tenant tenant:platform;
  • scope=approval:create behaves as before;
  • approval:consume and approval:approve each give 400 invalid_scope;
  • one present-existing intake no longer gives 503 approval_unreadable. It can still be refused for requester_mismatch until access-engine's policy is changed (blocker 1 in the requesting document, not KeyCape's).

Rollback: run the same two applies the other way round (remove new.json, add old.json), then restart.

Owner follow-ups outside key-cape (not edited here)

These copies of the old contract pin create-only. Each owner must update its copy in step with the live apply.

  • railiance-platform: tests/test_sitting_requester_provisioning.py asserts ['approval:create'] on both key-cape files, so it fails against this commit. scripts/provision-sitting-requester.py contract() also requires create-only. It is a one-shot provisioner that refuses existing custody, so it should not run again. Its test needs to accept the read scope and keep refusing consume and approve.
  • informed-decision: docs/keycape-sitting-requester-registration.{md,json} and tests/test_sitting_requester_registration.py list approval:read as forbidden. tools/create_sitting_approvals.py checks that approval:read is refused (excess_scope_not_refused), so after the live apply that tool stops until the check is narrowed to consume and approve.
  • net-kingdom: tools/service-identity/declarations/keycape.yaml and docs/service-identity-inventory.md record this client's scopes. KeyCape's identity-facts export reports the new pair once the source is re-read.