access-engine/docs/application-access-readback.md
tegwick b5da745063
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Container Image / build-and-push (push) Successful in 1m5s
FLEX-WP-0035-T05: informed-decision lifecycle v2 declares application.access
- application.access allows only with purpose=readback and the authoring
  human bar; no workload path, no audience or client-scoped claims
- v1 operations unchanged; pin.json moves to v2; live pin stays v1 until T04
- examples/application-readback: readback catalogue with informed-decision's
  key-cape client binding; integration test over the shipped files

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
2026-10-09 20:10:50 +02:00

246 lines
11 KiB
Markdown

# Application access readback
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`
Consumer: user-engine (`USER-WP-0026-T03`, `USER-WP-0028-T02`)
user-engine wants to show a signed-in person which applications they can use.
This document defines what access-engine answers to that request, what the
answer means, and what it must never be used for.
## 1. Constraints from the current runtime
1. **One pin evaluates one consumer.** Each `flex-auth-<consumer>` Deployment
loads one `--registry` snapshot and one `--policy` package. The
`flex-auth-user-engine` pin holds no rules for any other application. It
cannot answer for them, and widening it to do so would mix packages.
2. **Caller auth is per system.** `/v1/batch_check` authorizes the caller
token against every `resource.system` in the batch. A caller bound to
`user-engine` gets 403 for any other system. That is correct, and it stays.
3. **There is no common action.** Each package has its own action
vocabulary. "May use the application" has no meaning until a package says
what it means.
4. **Registry content is not in decision provenance** until G1
(`FLEX-WP-0019`). Under the 2026-08-19 boundary, anything that decides an
allow belongs in a versioned policy package, not in the registry.
5. **No key-cape client binding exists** in `protected_system_manifest`.
## 2. The readback action: `application.access`, opted into per package
A system takes part in the readback by declaring the action
`application.access` in its own policy package and giving it rules there.
- The rules are the system's own. They are reviewed, versioned and tested like
any other action, and the decision records `policy_package_digest`.
- A system whose package does not declare the action answers
`not_applicable`. user-engine renders that as `unavailable`.
- **No registry-level mapping.** Mapping "application access" onto an existing
action in the registry was considered and rejected. Under constraint 4 it
would let content outside provenance decide an allow.
`application.access` means: this subject, holding these claims, may begin to
use the application. It grants nothing inside the application. Every action
the application performs is still checked by that application's PEP against
its own pin.
## 3. The answer is advisory and cannot be transferred
A readback decision is a real flex-auth decision. It is evaluated, recorded
and attributable, but it cannot be accepted as an enforcement decision by any
PEP:
- The binding carries `purpose: "readback"`. The field is part of the hashed
request, so `request_digest` and `submitted_request_digest` can never equal
those of an enforcement request for the same subject and resource. Under
§6.4.2 a PEP's digest comparison therefore fails.
- The readback route evaluates only `application.access`. Any other action is
refused with 400 before evaluation. The readback surface cannot produce a
decision about a real operation.
- The decision is evaluated over the claims that user-engine presents for the
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
The readback runs on a dedicated pin, `flex-auth-application-readback`. It
loads the participating systems' packages at **the same package digests**
their own pins run, plus a merged registry snapshot.
- Drift control: each decision states the `policy_package_digest` of the
package it used. A test compares every package digest in the readback pin's
values against that consumer's own pin values, and fails on any mismatch.
- The only admitted caller is a readback binding (T02), for example
`application-readback=system:serviceaccount:user-engine:user-engine`. That
binding authorizes the readback route only. It never authorizes `/v1/check`
or `/v1/batch_check` on any pin.
- Rejected alternative: fan out from user-engine, or from a readback service,
to each consumer pin. That needs a caller binding and an ingress rule on
every consumer pin, which is exactly the widening constraint 2 forbids. It
also makes per-page latency the sum of N pins.
`serve-readback` implements this multi-package mode, keyed by
`resource.system` and restricted to `application.access`. The pin's own
catalogue registry is `examples/application-readback/registry_snapshot.json`.
It lists participants and their `client_binding`, and its README names each
participant's package and registry.
First participant: `informed-decision`, through `informed-decision.lifecycle`
v2 (`FLEX-WP-0035-T05`). Its `application.access` rule reuses the authoring
`human` bar, requires `purpose == "readback"`, and has no workload path.
## 5. Request and response
```http
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
{
"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"]
}
```
Response, one entry per requested system and in request order:
```json
{
"registry_snapshot_digest": "sha256:…",
"results": [
{
"system": "informed-decision",
"effect": "allow",
"decision_id": "decision:…",
"lifetime": {"expires_at": "…"},
"revocation_visibility": {
"claims": "immediate",
"registry": "reload_or_lifetime",
"policy_package": "reload_or_lifetime",
"directory": "next_check"
},
"policy_package": "informed-decision.lifecycle",
"policy_package_digest": "sha256:…"
},
{"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. 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. 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)
```http
GET /v1/readback/systems
```
The response lists every system in the merged snapshot: `id`, `name`,
`client_binding` (`{issuer, client_id}` or absent), `readback` (whether its
package declares `application.access`), and the `registry_snapshot_digest`.
This needs one new optional field on `protected_system_manifest`:
`client_binding`. A system without one is still listed, and user-engine's rule
then excludes it from the workload catalogue. The projection authorizes
nothing. It describes what the readback pin knows.
## 8. Grants
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. Review outcome (user-engine, 2026-10-09)
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`.