diff --git a/config/service-clients.example.yaml b/config/service-clients.example.yaml index a4b13e5..6f19dbd 100644 --- a/config/service-clients.example.yaml +++ b/config/service-clients.example.yaml @@ -119,11 +119,16 @@ clients: tokenLifetime: 15m # Compact sitting presenter; actor is informed-decision, not secrets-engine. + # approval:read added 2026-11-01 for present_existing intake (INFD-WP-0010): + # informed-decision GETs an approval another actor created before presenting it. + # Read only: never approval:consume or approval:approve. Same subject, audience + # and secret. See docs/sitting-requester-read-scope.md. - 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 diff --git a/docs/sitting-requester-read-scope.md b/docs/sitting-requester-read-scope.md new file mode 100644 index 0000000..15da16c --- /dev/null +++ b/docs/sitting-requester-read-scope.md @@ -0,0 +1,171 @@ +# 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= +ACC= + +# 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 --expected-resource-version +python3 keycape-client.py apply --client-file "$S/old.json" --operation remove \ + --case-acceptance "$ACC" --check-config "$KC" \ + --expected-uid --expected-resource-version +# 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 --expected-resource-version +# 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. diff --git a/docs/sitting-requester-registration.json b/docs/sitting-requester-registration.json index ce5f533..3d8535c 100644 --- a/docs/sitting-requester-registration.json +++ b/docs/sitting-requester-registration.json @@ -3,7 +3,8 @@ "displayName": "Informed Decision compact-sitting approval requester", "audience": "approval-engine", "allowedScopes": [ - "approval:create" + "approval:create", + "approval:read" ], "grantTypes": [ "client_credentials" diff --git a/src/cmd/keycape/clients_test.go b/src/cmd/keycape/clients_test.go index c2b6025..f3664d7 100644 --- a/src/cmd/keycape/clients_test.go +++ b/src/cmd/keycape/clients_test.go @@ -4,9 +4,12 @@ import ( "keycape/internal/config" "os" "path/filepath" + "reflect" "strings" "testing" "time" + + "gopkg.in/yaml.v3" ) func TestServiceRegistrationAudienceAndScopeIsolation(t *testing.T) { @@ -184,14 +187,31 @@ func TestSittingRequesterRegistrationShapeIsExact(t *testing.T) { if client.Tenant != "tenant:platform" || client.Audience != "approval-engine" { t.Errorf("tenant/audience = %q %q", client.Tenant, client.Audience) } - if len(client.AllowedScopes) != 1 || client.AllowedScopes[0] != "approval:create" { - t.Fatalf("scopes = %v; want only approval:create", client.AllowedScopes) + // create to file its own sittings; read (2026-11-01, INFD-WP-0010) to GET an + // existing approval before presenting it. Never consume or approve. + if !reflect.DeepEqual(client.AllowedScopes, []string{"approval:create", "approval:read"}) { + t.Fatalf("scopes = %v; want exactly approval:create, approval:read", client.AllowedScopes) } for _, scope := range client.AllowedScopes { - if scope == "approval:consume" || scope == "approval:approve" || scope == "approval:read" { + if scope == "approval:consume" || scope == "approval:approve" || scope == "openid" { t.Fatalf("excess sitting-requester scope %q", scope) } } + if len(client.Roles) != 1 || client.Roles[0] != "informed-decision-sitting-requester" || client.TokenLifetime != "15m" { + t.Errorf("roles/lifetime = %v %q", client.Roles, client.TokenLifetime) + } + // The machine-readable copy that the live apply reads must equal the source row. + raw, err := os.ReadFile("../../../docs/sitting-requester-registration.json") + if err != nil { + t.Fatal(err) + } + var published config.ClientConfig // JSON is YAML; decode by the struct's yaml tags + if err := yaml.Unmarshal(raw, &published); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(published, *client) { + t.Fatalf("docs/sitting-requester-registration.json drifted from the source row:\n%+v\n%+v", published, *client) + } if len(client.RedirectURIs) != 0 { t.Errorf("redirect URIs = %v; sitting-requester is not a browser client", client.RedirectURIs) } diff --git a/src/cmd/keycape/identityfacts_test.go b/src/cmd/keycape/identityfacts_test.go index ea4a4f7..3c5718d 100644 --- a/src/cmd/keycape/identityfacts_test.go +++ b/src/cmd/keycape/identityfacts_test.go @@ -63,7 +63,7 @@ func TestIdentityFactsCommittedServiceClients(t *testing.T) { "secrets-engine-approval": {"approval:read", "approval:consume"}, "secrets-engine-requester": {"approval:create"}, "approval-engine-operator": {"approval:create", "approval:read", "approval:approve", "approval:revoke", "approval:supersede", "approval:observe", "approval:emit"}, - "informed-decision-sitting-requester": {"approval:create"}, + "informed-decision-sitting-requester": {"approval:create", "approval:read"}, "secrets-engine-openbao": {"openbao:login"}, } seen := 0