From 50367d0d781784191246d16a4553e4a181d7c273 Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 10 Sep 2026 08:06:10 +0200 Subject: [PATCH] Write the key-cape client registration contract (T07 progress) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything except the host is now fixed: client_id informed-decision-approver, callback path /auth/callback, authorization-code + S256 PKCE public client, scopes [openid, approval:read, approval:approve], the expected token shape, and the assurance shape cited from key-cape's contract rather than restated so it cannot drift. Section 5 declares the tenant provenance rather than assuming it. Nothing populates domain.User.Tenant for approver users, so declaring tenant:platform reaches the token by the gap route by construction rather than by accident. GH-DEC-2026-013 was explicit that a tenant the directory does not carry must not be declared unless we are prepared to say so in the record — so it is said, with the two consequences: the claim is stored with its provenance, and it is never used as the act-scope. Deliberately NOT submitted. The origin needs a DNS A record, an Ingress manifest and a certificate, which is work in railiance-apps rather than here. decide.coulomb.social is proposed and consistent with the estate's existing Traefik/TLS-per-host pattern, but proposing a plausible hostname is not the same as owning one, and submitting a redirect for a host that does not resolve is the exact failure approval-engine avoided by refusing to invent these strings. T07 stays progress with the deployment dependency named and owned elsewhere. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01V3W1dQG7GFFM9d94jFx7iR Assistant: claude-code Assistant-Model: opus Assistant-Process: 1565372@bnt-lap001 Assistant-Session: 16bb2f25-b34c-49ef-8e94-5fec3567a568 --- docs/keycape-client-registration.md | 168 ++++++++++++++++++ ...ounding-specs-and-approver-ui-ownership.md | 24 ++- 2 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 docs/keycape-client-registration.md diff --git a/docs/keycape-client-registration.md b/docs/keycape-client-registration.md new file mode 100644 index 0000000..95625bf --- /dev/null +++ b/docs/keycape-client-registration.md @@ -0,0 +1,168 @@ +# OIDC browser-client registration — informed-decision approver surface + +**Workplan task:** `INFD-WP-0001-T07` +**For:** `key-cape` (`KEY-WP-0013-T02`, `KEY-WP-0013-T05`) +**Status:** **draft — not yet submitted.** One input outstanding: the deployed +origin (§2). Everything else is fixed and stable. +**Rulings:** `GH-DEC-2026-012`, `GH-DEC-2026-013` +**Contracts cited:** `key-cape/docs/approval-engine-auth-contract.md`, +`key-cape/docs/tenant-claim-contract.md`, +`approval-engine/docs/approver-surface-requirements.md` + +--- + +## 1. What this closes + +On 2026-09-08 `key-cape` asked `approval-engine` for the human approver client's +`client_id` and full callback URI. `approval-engine` correctly declined to +invent them: it is a bearer-token resource server with no Ingress, no redirect +endpoint, and no browser client. It recorded that the two strings belong to +whichever browser-facing approver UI presents `approval:approve` tokens to it. + +That component is this repository. These are the two strings. + +--- + +## 2. The two strings + +| Field | Value | +| --- | --- | +| `client_id` | `informed-decision-approver` | +| Redirect URI | `https:///auth/callback` | + +**`client_id` is fixed and stable.** It will not change. + +**The origin is the one outstanding input.** Redirects match exactly at +`/authorize`, so a near-miss fails closed — which is precisely why +`approval-engine` refused to guess and why this document is not yet submitted. + +- **Proposed:** `decide.coulomb.social`, giving + `https://decide.coulomb.social/auth/callback`. +- **Consistent with** the estate's existing pattern (`forgejo.coulomb.social`, + `hub.coulomb.social`, `kc.coulomb.social`), Traefik `ingressClassName`, TLS + secret per host. +- **Requires, in `railiance-apps` rather than here:** a DNS A record for + `decide.coulomb.social`, an Ingress manifest, and a certificate. + +**This document is submitted to `key-cape` when that origin resolves — not +before.** The path component `/auth/callback` is fixed now so only the host +remains variable. + +--- + +## 3. Client shape + +| Property | Value | +| --- | --- | +| Grant | Authorization code | +| PKCE | Required, `S256` | +| Client type | Public | +| Redirect matching | Exact | +| Scopes | `openid`, `approval:read`, `approval:approve` | +| `mfaRequired` | `true` | + +**`approval:read` is required and was missing from the original specification.** +Without it this surface could submit an approval entry it was never able to +display — raised by `approval-engine`, confirmed by `key-cape`, now in their +published contract. + +**`approval:consume` is never requested.** `approval-engine` refuses it for human +principals, and consumption belongs to the PEP that causes the side effect +(`GH-DEC-2026-003`). + +--- + +## 4. Expected access token + +Verified by `approval-engine` (RS256 against the issuer's `/jwks`): + +| Claim | Value | +| --- | --- | +| `aud` | `approval-engine` — the resource server, never the OAuth client id | +| `tenant` | `tenant:platform` — **exact** string equality, no alias or prefix handling | +| `principal_type` | `human` | +| `sub` | non-empty; becomes the approver identity in the entry | +| `scope` | non-empty, containing the exact scope for the route | +| `assurance` | a non-null JSON object — shape below | +| `iss`, `exp`, `iat` | verified, 30s leeway | + +The **access** token is the evidence. `id_token` is never presented to +`approval-engine` and appears nowhere in that codebase. + +### `assurance` + +The shape is **`key-cape`'s**, defined in their +`docs/approval-engine-auth-contract.md` — `level` (`aal1|aal2`), `methods`, +`mfa`, `source`, `at`. **Cited here rather than restated, so it cannot drift.** + +Two properties matter to this surface and are recorded rather than assumed: + +- `at` is the **authentication** time, not the token mint time. `key-cape` fixed + a defect here on 2026-09-09; the two differ by hours when a browser session is + reused, and `approval-engine` stores this object verbatim as the only + downstream record that MFA happened. Freshness is judged by comparing `at`, + never by assuming it. +- `key-cape` emits `assurance` on every human token and never empty. This + surface nevertheless requires the object to carry authentication facts rather + than relying on it always arriving populated — `approval-engine` *accepts* an + empty object, so an empty one would produce an entry evidencing nothing. + +--- + +## 5. Tenant provenance — declared, not assumed + +**This registration declares a tenant the directory record does not carry, and +says so here rather than letting it read as a directory-asserted fact.** + +`GH-DEC-2026-013` ruled directory-sourced tenancy the terminal state and granted +`key-cape`'s registration-bound shape (`329e48f`) as a **declared bounded gap** — +admissible because its distinguishing case fails closed: registration and +directory disagreeing refuses issuance with `403 tenant_binding` rather than +relabelling. + +Nothing populates `domain.User.Tenant` for approver users. So a registration +declaring `tenant:platform` reaches the token by the **gap route by +construction**, not by accident. Gate House was explicit: do not declare a tenant +the directory does not carry unless prepared to say so in the record. + +Consequences accepted here: + +1. **This is transitional.** When the directory carries tenants, the same + `key-cape` code stops supplying and starts enforcing agreement, with no second + migration. This registration is expected to survive that unchanged. +2. **The claim is stored with its provenance.** `key-cape` emits `tenant` as a + bare string, so a consumer cannot distinguish a directory-asserted tenant from + a registration-supplied one. Until the claim carries its own provenance + (`GH-DEC-2026-013` §5, second field deliberately undesigned), this surface + records which route the value arrived by. See `PR-09`. +3. **The tenant claim is never used as the act-scope.** `binding.target` is the + act-scope and is committed inside `view_hash`. Two different facts — which + scope this act enters, and which tenant this person belongs to — must not + share one field here just because they share one upstream. See + `EvidenceModel.md` §8c and `PR-08`. + +--- + +## 6. What this surface does with the token + +- `GET /v1/approvals/{id}` and `/claim` — `approval:read`, to render. +- `POST /v1/approvals/{id}/entries` — `approval:approve`, to record a binding. +- **Never** `/consume`. +- **Never** any path containing `check` or ending `/authorize` — they 404 by + design, and there is no "may this person approve" surface to call. + +A `200` from `approval-engine` is not entitlement to view. A decision is +obtained from `access-engine` before an approval is rendered to a person +(`PR-05`) — noting that decision **attributability** is a declared open gap +(`GH-DEC-2026-010`, `FLEX-WP-0024`) and this path is not described as validated +while it stands. + +--- + +## 7. Outstanding + +| # | Item | Owner | +| --- | --- | --- | +| 1 | DNS A record + Ingress + TLS for the chosen origin | `railiance-apps` / deployment | +| 2 | Submit this document to `key-cape`, closing `KEY-WP-0013-T02` | this repo, once 1 lands | +| 3 | Prove a token issued against the registration is accepted by `approval-engine` | `T08` | diff --git a/workplans/INFD-WP-0001-founding-specs-and-approver-ui-ownership.md b/workplans/INFD-WP-0001-founding-specs-and-approver-ui-ownership.md index db3c4c9..aac8d68 100644 --- a/workplans/INFD-WP-0001-founding-specs-and-approver-ui-ownership.md +++ b/workplans/INFD-WP-0001-founding-specs-and-approver-ui-ownership.md @@ -326,7 +326,7 @@ deployed. ```task id: INFD-WP-0001-T07 -status: todo +status: progress priority: high state_hub_task_id: "38a83a76-f152-55bf-8a9a-6132fd6d9642" ``` @@ -366,6 +366,28 @@ The one remaining input is the **deployed origin**. Redirects match exactly, so `client_id` and the callback URI must name a real origin — the reason not to publish is now solely that, and no longer the tenant. +2026-09-10: `docs/keycape-client-registration.md` written and everything except +the host is fixed — `client_id` `informed-decision-approver`, callback path +`/auth/callback`, authorization-code + S256 PKCE public client, scopes +`[openid, approval:read, approval:approve]`, the expected token shape, and the +`assurance` shape cited from `key-cape` rather than restated so it cannot drift. + +§5 declares the tenant provenance rather than assuming it. Nothing populates +`domain.User.Tenant` for approver users, so declaring `tenant:platform` reaches +the token by the **gap route by construction**, not by accident. +`GH-DEC-2026-013` was explicit that a tenant the directory does not carry must +not be declared unless we are prepared to say so in the record. We are, and it +is said there, along with the two consequences: the claim is stored with its +provenance, and it is never used as the act-scope. + +**Not submitted, and the task stays `progress`.** The origin +(`decide.coulomb.social` proposed, consistent with the estate's existing +Traefik/TLS-per-host pattern) needs a DNS A record, an Ingress manifest and a +certificate — work in `railiance-apps`, not here. Submitting a redirect for a +host that does not resolve is the exact failure `approval-engine` avoided by +refusing to invent these strings in the first place, and choosing a plausible +hostname is not the same as owning one. + Superseded context: 2026-09-09: blocked on `INFD-IN-0002`. `key-cape` found that a human access token cannot carry `tenant:platform` today — the tenant claim resolves from a directory record no adapter populates, so every human token falls back to