172 lines
8.5 KiB
Markdown
172 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.
|