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

171 lines
8.5 KiB
Markdown

# 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`:
```diff
"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):
```json
{
"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):
```bash
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.