FLEX-WP-0035 T01-T03: application access readback pin
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Container Image / build-and-push (push) Successful in 58s

Contract accepted by user-engine; FLEX-DEC-2026-019 accepted.
- serve-readback: dedicated pin, no enforcement routes, own caller binding
- CheckRequest.purpose hashed into every digest when set; /v1/check refuses it
- POST /v1/readback/application_access and GET /v1/readback/systems
- optional client_binding on protected systems; readback schemas
- schemaguard implements maxItems
- T05 added: informed-decision needs an application.access rule over
  realm-level claims (its lifecycle rules read authentication_audience)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 609675@bnt-lap001
Assistant-Session: 4987a36e-eb38-4b10-803c-15a29cd78212
This commit is contained in:
tegwick 2026-10-09 20:02:31 +02:00
parent b6d6bff85e
commit 8f2ba75e4a
23 changed files with 1314 additions and 44 deletions

View file

@ -1,6 +1,6 @@
# Application access readback
Status: proposed, for review with user-engine
Status: accepted 2026-10-09 (user-engine review, message `f54df082`); implemented in `serve-readback`
Date: 2026-10-09
Workplan: `FLEX-WP-0035-T01`
Decision: `FLEX-DEC-2026-019`
@ -62,6 +62,15 @@ PEP:
signed-in person, taken from its key-cape session. It is only as current as
those claims. The target application's PEP receives its own claims on its
own path.
- **`application.access` rules may read only realm-level IAM Profile claims.**
The claims user-engine forwards were issued for user-engine's own client
(`aud`/`azp` = user-engine). A rule that reads the audience, the authorized
party, or client-scoped roles would answer for the wrong client. This is a
package-review checklist item for every package that declares the action.
It applies today: `informed-decision.lifecycle` reads
`input.subject.attributes.authentication_audience`, so its existing rules
cannot be reused as-is for `application.access`. That package needs a
separate rule over realm-level claims (`FLEX-WP-0035-T05`).
## 4. Where it runs: a dedicated readback pin
@ -91,10 +100,29 @@ multi-package mode keyed by `resource.system`, restricted to
POST /v1/readback/application_access
```
Schemas: `schemas/readback_request.schema.json`,
`schemas/readback_response.schema.json`.
`subject` and `tenant` have exactly the `/v1/check` shape. user-engine sends
what `FlexAuthHTTPAdapter` already sends there: IAM Profile v0.3 normalized,
with `assurance` as the normalized form of `acr`/`amr`. There is no `action`,
`resource` or `purpose` field. The decoder rejects unknown fields, the pin
evaluates only `application.access`, and it sets `purpose` itself. Each row is
evaluated against the resource
`{"id": <system>, "type": "application", "system": <system>}`.
```json
{
"subject": {"issuer": "https://key-cape…/realms/…", "sub": "…", "type": "user"},
"context": {"groups": ["…"], "acr": "…", "amr": ["pwd", "otp"], "auth_time": 0},
"tenant": "tenant:platform",
"subject": {
"id": "…", "type": "human", "tenant": "tenant:platform",
"attributes": {
"issuer": "https://key-cape…/realms/…",
"roles": [], "tenant_roles": [], "groups": ["…"], "scopes": [],
"assurance": {"level": "aal2", "mfa": true},
"auth_time": 0
}
},
"systems": ["informed-decision", "markitect"]
}
```
@ -119,28 +147,43 @@ Response, one entry per requested system and in request order:
"policy_package": "informed-decision.lifecycle",
"policy_package_digest": "sha256:…"
},
{"system": "markitect", "effect": "not_applicable", "reason": "no application.access rule"}
{"system": "markitect", "effect": "not_applicable", "reason": "application_access_not_declared"}
]
}
```
- `effect` is one of `allow`, `deny`, `not_applicable`. A system the pin does
not know answers `not_applicable`; it is never omitted. A whole-request
failure is a non-2xx response, and user-engine renders every row
`unavailable`.
not know answers `not_applicable`; it is never omitted. Any evaluation or
record failure fails the whole request with a non-2xx response, and
user-engine renders every row `unavailable`.
- Requests carry 1 to 64 unique systems. Anything else is a 400.
- `revocation_visibility` restates per input class the deadlines published
in `docs/decision-input-freshness.md`. It contains nothing new. The
readback commits to no more than §9.7 already does.
- user-engine must not display an allow past `lifetime.expires_at`, and must
re-read rather than extend.
Rendering (user-engine, agreed 2026-10-09). No row is ever inferred as allow:
| Outcome | Rendered |
| --- | --- |
| `allow`, before `expires_at` | available |
| `deny` | not available |
| `not_applicable` | not available: "not offered here" |
| non-2xx, or `allow` past `expires_at` | not available: "could not be checked" |
## 6. Records
Readback decisions are recorded like any decision. They form their own event
class, `readback`, declared in `cadence.yaml` as not load-bearing: a readback
`allow` authorizes nothing. Under `FLEX-DEC-2026-018`, a failed commit still
withholds an allow (503, rendered `unavailable`) and still releases
`deny`/`not_applicable`.
Readback decisions are recorded like any decision. Under `FLEX-DEC-2026-018`,
an allow whose record fails to commit is withheld (503, rendered "could not
be checked"), and `deny`/`not_applicable` are still released. Both cases are
tested.
user-engine reads on account-page view only. It reuses each row until
`expires_at`, per session and subject, and drops the cache on logout or
identity switch, which bounds record volume. Declaring a `readback` event class
in `cadence.yaml` waits on the emission work (`FLEX-WP-0031`). Until then,
`serve-readback` takes only `--log`, like today's production pins.
## 7. Registry projection (T03)
@ -163,16 +206,35 @@ Grants and revocations stay inside access-engine, in packages and registry
content under their normal review. This work gives user-domain admins no grant
or revoke surface.
## 9. Open questions for user-engine review
## 9. Review outcome (user-engine, 2026-10-09)
1. **Claim set.** Is forwarding the session's verified claims (groups, `acr`,
`amr`, `auth_time`) acceptable to you? Or do you want the readback to
resolve groups itself? Resolving them itself would add a directory
dependency.
2. **First participants.** The proposal is informed-decision first: a
human-facing web app with KeyCape AAL2 rules already in its packages. Which
applications do you need in your first release?
3. **Batch size.** The ceiling is 64 systems per request. Is that enough?
4. **Record volume.** Each page view produces N decision records. If that is
too much, the alternative is a shorter TTL plus client-side reuse up to
`expires_at`, not unrecorded decisions.
1. **Claims.** user-engine forwards verified claims in the `/v1/check` subject
shape. There is no directory dependency. The realm-level-only rule is in
§3.
2. **First participant.** informed-decision. user-engine requests only systems
that have a readback-pin registration with `client_binding`, a key-cape
client, and a user-engine application record.
3. **Batch size.** 64 is enough.
4. **Record volume.** Account-page reads with reuse until `expires_at` (§6).
5. **Caller token.** user-engine reads the readback token from its own file,
`USER_ENGINE_READBACK_TOKEN_FILE`, separate from
`USER_ENGINE_FLEX_AUTH_TOKEN_FILE`, with the same rotation pattern. OpenBao
custody stays operator-owned.
## 10. Running it
```bash
# --registry and --package repeat once per participating system;
# system ids must not repeat across registries.
flex-auth serve-readback \
--registry examples/<system>/registry_snapshot.json \
--package <system>=examples/<system>/policy_package.md \
--caller-auth-mode enforce \
--readback-caller system:serviceaccount:user-engine:user-engine
```
The pin mounts `/healthz`, `POST /v1/readback/application_access` and
`GET /v1/readback/systems`, and no enforcement route. `--readback-caller` is
bound under the key `application-readback`. That key is not a protected
system, so the principal can never represent a system on any pin's
`/v1/check`. Tests: `cmd/flex-auth/readback_test.go`.