key-cape/docs/sitting-requester-read-scope.md

172 lines
8.5 KiB
Markdown
Raw Normal View History

# 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.