2026-10-09 19:11:55 +02:00
|
|
|
# Application access readback
|
|
|
|
|
|
2026-10-09 20:02:31 +02:00
|
|
|
Status: accepted 2026-10-09 (user-engine review, message `f54df082`); implemented in `serve-readback`
|
2026-10-09 19:11:55 +02:00
|
|
|
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.
|
2026-10-09 20:02:31 +02:00
|
|
|
- **`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`).
|
2026-10-09 19:11:55 +02:00
|
|
|
|
|
|
|
|
## 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.
|
|
|
|
|
|
|
|
|
|
Code needed: `serve` today takes one `--policy`. The readback pin needs a
|
|
|
|
|
multi-package mode keyed by `resource.system`, restricted to
|
|
|
|
|
`application.access`.
|
|
|
|
|
|
|
|
|
|
## 5. Request and response
|
|
|
|
|
|
|
|
|
|
```http
|
|
|
|
|
POST /v1/readback/application_access
|
|
|
|
|
```
|
|
|
|
|
|
2026-10-09 20:02:31 +02:00
|
|
|
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>}`.
|
|
|
|
|
|
2026-10-09 19:11:55 +02:00
|
|
|
```json
|
|
|
|
|
{
|
2026-10-09 20:02:31 +02:00
|
|
|
"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
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-10-09 19:11:55 +02:00
|
|
|
"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:…"
|
|
|
|
|
},
|
2026-10-09 20:02:31 +02:00
|
|
|
{"system": "markitect", "effect": "not_applicable", "reason": "application_access_not_declared"}
|
2026-10-09 19:11:55 +02:00
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
- `effect` is one of `allow`, `deny`, `not_applicable`. A system the pin does
|
2026-10-09 20:02:31 +02:00
|
|
|
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.
|
2026-10-09 19:11:55 +02:00
|
|
|
- `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.
|
|
|
|
|
|
2026-10-09 20:02:31 +02:00
|
|
|
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" |
|
|
|
|
|
|
2026-10-09 19:11:55 +02:00
|
|
|
## 6. Records
|
|
|
|
|
|
2026-10-09 20:02:31 +02:00
|
|
|
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.
|
2026-10-09 19:11:55 +02:00
|
|
|
|
|
|
|
|
## 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.
|
|
|
|
|
|
2026-10-09 20:02:31 +02:00
|
|
|
## 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`.
|