access-engine/docs/application-access-readback.md
tegwick 8f2ba75e4a
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
FLEX-WP-0035 T01-T03: application access readback pin
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
2026-10-09 20:02:31 +02:00

10 KiB

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.

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

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>}.

{
  "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:

{
  "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)

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

# --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.