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

@ -43,7 +43,7 @@
| workplan | FLEX-WP-0032 | finished | — | workplans/FLEX-WP-0032-informed-decision-list-action.md |
| workplan | FLEX-WP-0033 | finished | — | workplans/FLEX-WP-0033-informed-decision-lifecycle.md |
| workplan | FLEX-WP-0034 | finished | — | workplans/FLEX-WP-0034-informed-decision-review-discovery.md |
| workplan | FLEX-WP-0035 | proposed | — | workplans/FLEX-WP-0035-per-identity-application-readback.md |
| workplan | FLEX-WP-0035 | active | — | workplans/FLEX-WP-0035-per-identity-application-readback.md |
| task | FLEX-WP-ADHOC-2026-10-03-T01 | done | — | workplans/ADHOC-2026-10-03.md |
| task | FLEX-WP-ADHOC-2026-10-03-T02 | wait | — | workplans/ADHOC-2026-10-03.md |
| task | FLEX-WP-0001-T001 | done | — | workplans/FLEX-WP-0001-repo-intent-and-architecture-baseline.md |
@ -201,7 +201,7 @@
| task | FLEX-WP-0033-T04 | done | — | workplans/FLEX-WP-0033-informed-decision-lifecycle.md |
| task | FLEX-WP-0034-T01 | done | — | workplans/FLEX-WP-0034-informed-decision-review-discovery.md |
| task | FLEX-WP-0034-T02 | done | — | workplans/FLEX-WP-0034-informed-decision-review-discovery.md |
| task | FLEX-WP-0035-T01 | todo | — | workplans/FLEX-WP-0035-per-identity-application-readback.md |
| task | FLEX-WP-0035-T01 | progress | — | workplans/FLEX-WP-0035-per-identity-application-readback.md |
| task | FLEX-WP-0035-T02 | todo | — | workplans/FLEX-WP-0035-per-identity-application-readback.md |
| task | FLEX-WP-0035-T03 | todo | — | workplans/FLEX-WP-0035-per-identity-application-readback.md |
| task | FLEX-WP-0035-T04 | wait | — | workplans/FLEX-WP-0035-per-identity-application-readback.md |

View file

@ -58,6 +58,8 @@ func run(args []string, stdout, stderr io.Writer) int {
return runExplain(args[1:], stdout, stderr)
case "serve":
return runServe(args[1:], stdout, stderr)
case "serve-readback":
return runServeReadback(args[1:], stdout, stderr)
case "help", "-h", "--help":
printUsage(stdout)
return 0
@ -456,6 +458,12 @@ func newServeMuxWithCallerAuth(engine *decisioncore.Engine, authenticator *calle
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if request.Purpose != "" {
// Only the readback pin issues purpose-bearing decisions
// (FLEX-DEC-2026-019); an enforcement pin never does.
http.Error(w, "purpose is not accepted on /v1/check", http.StatusBadRequest)
return
}
identity, err := authenticator.Authorize(r.Context(), r.Header.Get("Authorization"), []string{request.Resource.System})
if err != nil {
writeCallerAuthError(w, err)
@ -627,7 +635,7 @@ func fail(stderr io.Writer, err error) int {
func printUsage(w io.Writer) {
fmt.Fprintln(w, "usage: flex-auth <command> [options]")
fmt.Fprintln(w, "commands: version, validate, load-registry, test-policy, check, batch-check, list-allowed, explain, serve")
fmt.Fprintln(w, "commands: version, validate, load-registry, test-policy, check, batch-check, list-allowed, explain, serve, serve-readback")
}
type keyValueFlags []string

167
cmd/flex-auth/readback.go Normal file
View file

@ -0,0 +1,167 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"github.com/netkingdom/flex-auth/internal/audit"
"github.com/netkingdom/flex-auth/internal/callerauth"
"github.com/netkingdom/flex-auth/internal/policy"
"github.com/netkingdom/flex-auth/internal/readback"
"github.com/netkingdom/flex-auth/internal/registry"
)
// readbackBinding is the caller-binding key the readback routes authorize
// against. It is not a protected system, so a principal bound to it can never
// represent a system on any pin's /v1/check (FLEX-WP-0035-T02).
const readbackBinding = "application-readback"
type repeatedFlag []string
func (f *repeatedFlag) String() string { return strings.Join(*f, ",") }
func (f *repeatedFlag) Set(value string) error {
*f = append(*f, value)
return nil
}
// runServeReadback serves the advisory application-access readback. It mounts
// no /v1/check or /v1/batch_check: this pin cannot produce an enforcement
// decision (FLEX-DEC-2026-019).
func runServeReadback(args []string, _, stderr io.Writer) int {
fs := newFlagSet("serve-readback", stderr)
addr := fs.String("addr", "127.0.0.1:8080", "HTTP listen address")
logPath := fs.String("log", "", "optional JSONL decision log path")
callerAuthMode := fs.String("caller-auth-mode", "disabled", "disabled, warn, or enforce")
callerAudience := fs.String("caller-audience", "flex-auth", "required caller token audience")
callerKubernetesURL := fs.String("caller-kubernetes-url", "https://kubernetes.default.svc", "Kubernetes API base URL for TokenReview")
callerReviewerTokenFile := fs.String("caller-reviewer-token-file", "/var/run/secrets/flex-auth-reviewer/token", "projected Kubernetes API credential")
callerCAFile := fs.String("caller-ca-file", "/var/run/secrets/flex-auth-reviewer/ca.crt", "Kubernetes API CA bundle")
readbackCaller := fs.String("readback-caller", "", "authenticated Kubernetes principal admitted to the readback routes")
var registries repeatedFlag
fs.Var(&registries, "registry", "registry snapshot JSON file (repeatable; system ids must not repeat)")
var packages keyValueFlags
fs.Var(&packages, "package", "system=policy package Markdown file (repeatable)")
if err := fs.Parse(args); err != nil {
return 64
}
if len(registries) == 0 || len(packages) == 0 {
fmt.Fprintln(stderr, "serve-readback requires --registry and --package")
return 64
}
bindings := map[string]string{}
if *readbackCaller != "" {
bindings[readbackBinding] = *readbackCaller
}
service, err := buildReadback(context.Background(), registries, packages.StringMap())
if err != nil {
return fail(stderr, err)
}
if *logPath != "" {
service.SetDecisionLog(audit.NewJSONLDecisionLog(*logPath))
}
authenticator, err := buildCallerAuthenticator(
callerauth.Mode(*callerAuthMode),
*callerAudience,
*callerKubernetesURL,
*callerReviewerTokenFile,
*callerCAFile,
bindings,
stderr,
)
if err != nil {
return fail(stderr, err)
}
fmt.Fprintf(stderr, "flex-auth readback serving on http://%s\n", *addr)
if err := http.ListenAndServe(*addr, newReadbackMux(service, authenticator)); err != nil {
return fail(stderr, err)
}
return 0
}
func buildReadback(ctx context.Context, registryPaths []string, packagePaths map[string]string) (*readback.Service, error) {
merged := registry.NewStore()
seen := map[string]string{}
for _, path := range registryPaths {
store, err := registry.LoadFile(path)
if err != nil {
return nil, err
}
snapshot := store.Snapshot()
for _, system := range snapshot.Systems {
if first, dup := seen[system.ID]; dup {
return nil, fmt.Errorf("system %q appears in both %s and %s", system.ID, first, path)
}
seen[system.ID] = path
}
if err := merged.ApplySnapshot(snapshot); err != nil {
return nil, fmt.Errorf("merge %s: %w", path, err)
}
}
loaded := make(map[string]*policy.Package, len(packagePaths))
for system, path := range packagePaths {
pkg, err := policy.LoadAndValidateFile(ctx, path)
if err != nil {
return nil, err
}
loaded[system] = pkg
}
return readback.New(merged, loaded)
}
func newReadbackMux(service *readback.Service, authenticator *callerauth.Authenticator) *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("content-type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
})
authorize := func(w http.ResponseWriter, r *http.Request) (context.Context, bool) {
identity, err := authenticator.Authorize(r.Context(), r.Header.Get("Authorization"), []string{readbackBinding})
if err != nil {
writeCallerAuthError(w, err)
return nil, false
}
return callerauth.WithRecord(r.Context(), authenticator.Record(identity)), true
}
mux.HandleFunc("/v1/readback/application_access", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var request readback.Request
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields() // no action, resource or purpose can be smuggled in
if err := decoder.Decode(&request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
ctx, ok := authorize(w, r)
if !ok {
return
}
response, err := service.Evaluate(ctx, request)
if errors.Is(err, readback.ErrInvalidRequest) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
writeHTTP(w, response, err)
})
mux.HandleFunc("/v1/readback/systems", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if _, ok := authorize(w, r); !ok {
return
}
writeHTTP(w, service.Systems(), nil)
})
return mux
}

View file

@ -0,0 +1,300 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/netkingdom/flex-auth/internal/audit"
"github.com/netkingdom/flex-auth/internal/callerauth"
"github.com/netkingdom/flex-auth/internal/readback"
"github.com/netkingdom/flex-auth/internal/schemaguard"
"github.com/netkingdom/flex-auth/pkg/api"
)
// FLEX-WP-0035: application access readback.
const readbackPrincipal = "system:serviceaccount:user-engine:user-engine"
func readbackPath(parts ...string) string {
return filepath.Join(append([]string{"testdata", "readback"}, parts...)...)
}
func newTestReadback(t *testing.T) *readback.Service {
t.Helper()
service, err := buildReadback(context.Background(),
[]string{readbackPath("demo-app", "registry_snapshot.json"), readbackPath("other-app", "registry_snapshot.json")},
map[string]string{
"demo-app": readbackPath("demo-app", "policy_package.md"),
"other-app": readbackPath("other-app", "policy_package.md"),
})
if err != nil {
t.Fatalf("buildReadback: %v", err)
}
return service
}
func readbackAuthenticator(t *testing.T, principal string) *callerauth.Authenticator {
t.Helper()
authenticator, err := callerauth.New(callerauth.ModeEnforce, fixedTokenReviewer{identity: callerauth.Identity{
Username: principal,
Audiences: []string{"flex-auth"},
}}, "flex-auth", map[string]string{readbackBinding: readbackPrincipal}, nil)
if err != nil {
t.Fatal(err)
}
return authenticator
}
func subjectRequest(id string, mfa bool, systems ...string) readback.Request {
return readback.Request{
Tenant: "tenant:platform",
Subject: api.SubjectRef{ID: id, Type: "user", Attributes: map[string]any{
"groups": []any{"app-users"},
"assurance": map[string]any{"level": "aal2", "mfa": mfa},
}},
Systems: systems,
}
}
func TestReadbackRowsInRequestOrderAndNeverOmitted(t *testing.T) {
service := newTestReadback(t)
logPath := filepath.Join(t.TempDir(), "decisions.jsonl")
service.SetDecisionLog(audit.NewJSONLDecisionLog(logPath))
response, err := service.Evaluate(context.Background(), subjectRequest("user:alice", true, "other-app", "demo-app", "unknown-app"))
if err != nil {
t.Fatal(err)
}
if response.RegistrySnapshotDigest == "" || len(response.Results) != 3 {
t.Fatalf("response = %+v", response)
}
other, demo, unknown := response.Results[0], response.Results[1], response.Results[2]
// other-app's rule would allow; the package does not declare the action.
if other.System != "other-app" || other.Effect != api.DecisionEffectNotApplicable || other.Reason != readback.ReasonNotOffered || other.DecisionID != "" {
t.Fatalf("undeclared row = %+v", other)
}
if unknown.System != "unknown-app" || unknown.Effect != api.DecisionEffectNotApplicable {
t.Fatalf("unknown row = %+v", unknown)
}
if demo.Effect != api.DecisionEffectAllow || demo.Lifetime == nil || demo.Lifetime.ExpiresAt == "" || demo.PolicyPackageDigest == "" || demo.RevocationVisibility["claims"] != "immediate" {
t.Fatalf("allow row = %+v", demo)
}
// The recorded decision carries purpose readback inside its digests.
raw, err := os.ReadFile(logPath)
if err != nil {
t.Fatal(err)
}
var recorded api.DecisionEnvelope
if err := json.Unmarshal(bytes.TrimSpace(raw), &recorded); err != nil {
t.Fatalf("decision log: %v", err)
}
if recorded.ID != demo.DecisionID || recorded.Binding.Purpose != api.PurposeReadback || recorded.Binding.Action != readback.ApplicationAccess {
t.Fatalf("recorded binding = %+v", recorded.Binding)
}
enforcement := api.CheckRequest{Subject: recorded.Binding.Subject, Action: recorded.Binding.Action, Resource: recorded.Binding.Resource, Tenant: recorded.Binding.Tenant}
if api.RequestDigest(enforcement) == recorded.Binding.RequestDigest {
t.Fatal("readback request_digest equals the enforcement digest; a PEP could accept it")
}
}
func TestReadbackDeniesWithoutMFA(t *testing.T) {
response, err := newTestReadback(t).Evaluate(context.Background(), subjectRequest("user:bob", false, "demo-app"))
if err != nil {
t.Fatal(err)
}
if got := response.Results[0]; got.Effect != api.DecisionEffectDeny || got.Lifetime != nil && got.Lifetime.ExpiresAt != "" {
t.Fatalf("row = %+v", got)
}
}
func TestReadbackSystemsProjection(t *testing.T) {
systems := newTestReadback(t).Systems()
byID := map[string]readback.System{}
for _, system := range systems.Systems {
byID[system.ID] = system
}
demo, other := byID["demo-app"], byID["other-app"]
if !demo.Readback || demo.ClientBinding == nil || demo.ClientBinding.ClientID != "demo-app" || demo.Name != "Demo App" {
t.Fatalf("demo-app = %+v", demo)
}
if other.Readback || other.ClientBinding != nil {
t.Fatalf("other-app = %+v", other)
}
if systems.RegistrySnapshotDigest == "" {
t.Fatal("projection has no snapshot digest")
}
}
func TestReadbackRefusesDuplicateSystemAcrossRegistries(t *testing.T) {
path := readbackPath("demo-app", "registry_snapshot.json")
_, err := buildReadback(context.Background(), []string{path, path}, map[string]string{"demo-app": readbackPath("demo-app", "policy_package.md")})
if err == nil || !strings.Contains(err.Error(), "appears in both") {
t.Fatalf("err = %v", err)
}
}
func postReadback(t *testing.T, url string, body string) *http.Response {
t.Helper()
request, _ := http.NewRequest(http.MethodPost, url+"/v1/readback/application_access", strings.NewReader(body))
request.Header.Set("Authorization", "Bearer token")
response, err := http.DefaultClient.Do(request)
if err != nil {
t.Fatal(err)
}
response.Body.Close()
return response
}
func TestReadbackHTTPCallerAndRequestShape(t *testing.T) {
service := newTestReadback(t)
server := httptest.NewServer(newReadbackMux(service, readbackAuthenticator(t, readbackPrincipal)))
defer server.Close()
valid, _ := json.Marshal(subjectRequest("user:alice", true, "demo-app"))
if got := postReadback(t, server.URL, string(valid)).StatusCode; got != http.StatusOK {
t.Fatalf("admitted caller status = %d", got)
}
tooMany := make([]string, readback.MaxSystems+1)
for i := range tooMany {
tooMany[i] = fmt.Sprintf("app-%d", i)
}
oversized, _ := json.Marshal(subjectRequest("user:alice", true, tooMany...))
duplicated, _ := json.Marshal(subjectRequest("user:alice", true, "demo-app", "demo-app"))
for name, body := range map[string]string{
"smuggled action": `{"subject":{"id":"user:alice"},"systems":["demo-app"],"action":"admin"}`,
"smuggled purpose": `{"subject":{"id":"user:alice"},"systems":["demo-app"],"purpose":""}`,
"no subject": `{"systems":["demo-app"]}`,
"oversized": string(oversized),
"duplicated": string(duplicated),
} {
if got := postReadback(t, server.URL, body).StatusCode; got != http.StatusBadRequest {
t.Fatalf("%s: status = %d, want 400", name, got)
}
}
// The readback pin mounts no enforcement route.
for _, route := range []string{"/v1/check", "/v1/batch_check"} {
response, err := http.Post(server.URL+route, "application/json", strings.NewReader(`{}`))
if err != nil {
t.Fatal(err)
}
response.Body.Close()
if response.StatusCode != http.StatusNotFound {
t.Fatalf("%s on readback pin = %d, want 404", route, response.StatusCode)
}
}
wrong := httptest.NewServer(newReadbackMux(service, readbackAuthenticator(t, "system:serviceaccount:other:other")))
defer wrong.Close()
if got := postReadback(t, wrong.URL, string(valid)).StatusCode; got != http.StatusForbidden {
t.Fatalf("unbound caller status = %d, want 403", got)
}
}
// T02 gate: the readback principal gets 403 on an enforcement pin's /v1/check,
// and an enforcement pin refuses a purpose-bearing request.
func TestReadbackPrincipalCannotCheckOnEnforcementPin(t *testing.T) {
engine, err := buildEngine(context.Background(), opsPath("registry_snapshot.json"), opsPath("policy_package.md"), "")
if err != nil {
t.Fatal(err)
}
authenticator, err := callerauth.New(callerauth.ModeEnforce, fixedTokenReviewer{identity: callerauth.Identity{
Username: readbackPrincipal, Audiences: []string{"flex-auth"},
}}, "flex-auth", map[string]string{"ops-warden": "system:serviceaccount:ops-warden:ops-warden"}, nil)
if err != nil {
t.Fatal(err)
}
server := httptest.NewServer(newServeMuxWithCallerAuth(engine, authenticator))
defer server.Close()
body, err := os.ReadFile(opsPath("check_request_allow_adm.json"))
if err != nil {
t.Fatal(err)
}
request, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/check", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer token")
response, err := http.DefaultClient.Do(request)
if err != nil {
t.Fatal(err)
}
response.Body.Close()
if response.StatusCode != http.StatusForbidden {
t.Fatalf("readback principal on /v1/check = %d, want 403", response.StatusCode)
}
var withPurpose map[string]any
if err := json.Unmarshal(body, &withPurpose); err != nil {
t.Fatal(err)
}
withPurpose["purpose"] = api.PurposeReadback
marked, _ := json.Marshal(withPurpose)
open := httptest.NewServer(newServeMux(engine))
defer open.Close()
response, err = http.Post(open.URL+"/v1/check", "application/json", bytes.NewReader(marked))
if err != nil {
t.Fatal(err)
}
response.Body.Close()
if response.StatusCode != http.StatusBadRequest {
t.Fatalf("purpose on /v1/check = %d, want 400", response.StatusCode)
}
}
type failingRecorder struct{}
func (failingRecorder) Append(api.DecisionEnvelope) error { return errors.New("disk full") }
// FLEX-DEC-2026-018 holds on the readback: an allow whose record fails to
// commit is withheld (503, rendered "could not be checked"), never released.
func TestReadbackWithholdsUncommittedAllow(t *testing.T) {
service := newTestReadback(t)
service.SetDecisionLog(failingRecorder{})
server := httptest.NewServer(newReadbackMux(service, callerauth.Disabled()))
defer server.Close()
allow, _ := json.Marshal(subjectRequest("user:alice", true, "demo-app"))
if got := postReadback(t, server.URL, string(allow)).StatusCode; got != http.StatusServiceUnavailable {
t.Fatalf("uncommitted allow = %d, want 503", got)
}
deny, _ := json.Marshal(subjectRequest("user:bob", false, "demo-app"))
if got := postReadback(t, server.URL, string(deny)).StatusCode; got != http.StatusOK {
t.Fatalf("uncommitted deny = %d, want 200 (a restriction is never withheld)", got)
}
}
// T03 gate: the wire shapes match the published schemas.
func TestReadbackShapesMatchSchemas(t *testing.T) {
service := newTestReadback(t)
request := subjectRequest("user:alice", true, "demo-app", "other-app")
response, err := service.Evaluate(context.Background(), request)
if err != nil {
t.Fatal(err)
}
for schema, value := range map[string]any{
"readback_request.schema.json": request,
"readback_response.schema.json": response,
"readback_systems.schema.json": service.Systems(),
} {
s, err := schemaguard.Load(filepath.Join("..", "..", "schemas", schema))
if err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(value)
var doc any
if err := json.Unmarshal(raw, &doc); err != nil {
t.Fatal(err)
}
if problems := s.Validate(doc); len(problems) > 0 {
t.Fatalf("%s: %v\n%s", schema, problems, raw)
}
}
}

View file

@ -0,0 +1,24 @@
- id: fixture:demo-app-access-allow
request:
subject:
id: user:alice
attributes:
groups: [app-users]
assurance: {level: aal2, mfa: true}
action: application.access
resource: {id: demo-app, type: application, system: demo-app}
expect:
effect: allow
reason: app_user
- id: fixture:demo-app-access-deny-no-mfa
request:
subject:
id: user:bob
attributes:
groups: [app-users]
assurance: {level: aal1, mfa: false}
action: application.access
resource: {id: demo-app, type: application, system: demo-app}
expect:
effect: deny
reason: no_matching_rule

View file

@ -0,0 +1,53 @@
---
id: testdata.demo-app
name: readback test package demo-app
namespace: testdata:demo-app
version: v1
status: fixture
package: flexauth.testdata.demo_app
actions:
- application.access
- read
owner: team:platform-security
fixtures:
- policy_fixtures.yaml
caring:
profile: caring-0.4.0-rc2
enforce: false
canonical_roles: [Operator]
organization_relations: [ServiceProvider]
scopes:
- {level: Platform, id: platform:testdata, tenant: tenant:platform}
planes: [Identity]
capabilities: [Use]
exposure_modes: [Metadata]
conditions: [Logged]
restrictions: [PrivilegeEscalationBlocked]
---
# Readback test package (FLEX-WP-0035)
Test-only. Do not copy.
```rego
import future.keywords.if
import future.keywords.in
default decision := {"effect": "deny", "reason": "no_matching_rule"}
decision := {"effect": "allow", "reason": "app_user"} if {
input.action == "application.access"
"app-users" in input.subject.attributes.groups
input.subject.attributes.assurance.mfa == true
}
```
```rego test
package flexauth.testdata.demo_app_test
import future.keywords.if
import data.flexauth.testdata.demo_app
test_default_deny if {
demo_app.decision.effect == "deny" with input as {"action": "read", "subject": {"attributes": {}}}
}
```

View file

@ -0,0 +1,28 @@
{
"systems": [
{
"id": "demo-app",
"name": "Demo App",
"client_binding": {
"issuer": "https://keycape.example/realms/netkingdom",
"client_id": "demo-app"
}
}
],
"subjects": [
{
"id": "demo-app-profile",
"type": "Human",
"display_name": "Readback contract profile (declares caller-supplied keys)",
"groups": [
"app-users"
],
"claims": {
"assurance": {
"level": "aal2",
"mfa": true
}
}
}
]
}

View file

@ -0,0 +1,14 @@
# The rule would allow application.access, but this package does not declare
# the action. The readback must answer not_applicable regardless (T01 §2).
- id: fixture:other-app-undeclared-rule-would-allow
request:
subject:
id: user:alice
attributes:
groups: [app-users]
assurance: {level: aal2, mfa: true}
action: application.access
resource: {id: other-app, type: application, system: other-app}
expect:
effect: allow
reason: app_user

View file

@ -0,0 +1,52 @@
---
id: testdata.other-app
name: readback test package other-app
namespace: testdata:other-app
version: v1
status: fixture
package: flexauth.testdata.other_app
actions:
- read
owner: team:platform-security
fixtures:
- policy_fixtures.yaml
caring:
profile: caring-0.4.0-rc2
enforce: false
canonical_roles: [Operator]
organization_relations: [ServiceProvider]
scopes:
- {level: Platform, id: platform:testdata, tenant: tenant:platform}
planes: [Identity]
capabilities: [Use]
exposure_modes: [Metadata]
conditions: [Logged]
restrictions: [PrivilegeEscalationBlocked]
---
# Readback test package (FLEX-WP-0035)
Test-only. Do not copy.
```rego
import future.keywords.if
import future.keywords.in
default decision := {"effect": "deny", "reason": "no_matching_rule"}
decision := {"effect": "allow", "reason": "app_user"} if {
input.action == "application.access"
"app-users" in input.subject.attributes.groups
input.subject.attributes.assurance.mfa == true
}
```
```rego test
package flexauth.testdata.other_app_test
import future.keywords.if
import data.flexauth.testdata.other_app
test_default_deny if {
other_app.decision.effect == "deny" with input as {"action": "read", "subject": {"attributes": {}}}
}
```

View file

@ -0,0 +1,24 @@
{
"systems": [
{
"id": "other-app",
"name": "Other App"
}
],
"subjects": [
{
"id": "other-app-profile",
"type": "Human",
"display_name": "Readback contract profile (declares caller-supplied keys)",
"groups": [
"app-users"
],
"claims": {
"assurance": {
"level": "aal2",
"mfa": true
}
}
}
]
}

View file

@ -2084,8 +2084,8 @@ flex-auth suppressing a record together with its own count.
## FLEX-DEC-2026-019 — Application access readback is a per-package opt-in action, advisory by binding, served from a dedicated pin
**Date:** 2026-10-09
**Status:** proposed (pending user-engine review)
**Workplan:** `FLEX-WP-0035-T01`
**Status:** accepted 2026-10-09 (user-engine review, message `f54df082`)
**Workplan:** `FLEX-WP-0035-T01`; implemented in T02/T03 (`serve-readback`)
**Contract:** `docs/application-access-readback.md`
**Question.** How does access-engine tell user-engine which applications a
@ -2110,3 +2110,7 @@ without letting registry content decide an allow?
`lifetime.expires_at`. Readback decisions are recorded as their own
non-load-bearing `readback` event class under `FLEX-DEC-2026-018`.
5. **Grants stay inside access-engine.** No user-domain grant surface.
6. **Rules read realm-level claims only** (review amendment). Forwarded claims
were issued for user-engine's client, so an `application.access` rule that
reads audience, authorized party or client-scoped roles answers for the
wrong client. This is a package-review checklist item.

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

View file

@ -0,0 +1,199 @@
// Package readback answers which applications a subject may begin to use.
//
// A readback decision is advisory. It is evaluated by each participating
// system's own versioned package, recorded like any decision, and carries
// purpose "readback" inside every request digest, so no PEP can accept it as an
// enforcement decision. Only the action ApplicationAccess is ever evaluated
// (FLEX-DEC-2026-019, docs/application-access-readback.md).
package readback
import (
"context"
"errors"
"fmt"
"slices"
"strings"
decisioncore "github.com/netkingdom/flex-auth/internal/decision"
"github.com/netkingdom/flex-auth/internal/policy"
"github.com/netkingdom/flex-auth/internal/registry"
"github.com/netkingdom/flex-auth/pkg/api"
)
// ApplicationAccess is the only action the readback evaluates. A package takes
// part by declaring it; the rules are the package's own.
const ApplicationAccess = "application.access"
// ResourceType is the resource type of the application a readback names. The
// resource id is the protected-system id.
const ResourceType = "application"
// MaxSystems bounds one request (user-engine review, 2026-10-09).
const MaxSystems = 64
// ReasonNotOffered is the reason on a not_applicable row for a system the pin
// does not know or whose package does not declare ApplicationAccess.
const ReasonNotOffered = "application_access_not_declared"
// RevocationVisibility restates the per-input-class deadlines published in
// docs/decision-input-freshness.md for the classes the readback pin joins. It
// commits to nothing beyond them. The pin is standalone, so no directory
// class is joined.
var RevocationVisibility = map[string]string{
"claims": "immediate",
"registry": "reload_or_lifetime",
"policy_package": "reload_or_lifetime",
}
// ErrInvalidRequest marks a request refused before evaluation.
var ErrInvalidRequest = errors.New("invalid readback request")
// Request names a subject, the claims user-engine verified for it, and the
// systems to read back. Subject and Tenant have the /v1/check shape.
type Request struct {
Tenant string `json:"tenant,omitempty"`
Subject api.SubjectRef `json:"subject"`
Context map[string]any `json:"context,omitempty"`
Systems []string `json:"systems"`
}
// Result is one row, in request order. A system is never omitted.
type Result struct {
System string `json:"system"`
Effect api.DecisionEffect `json:"effect"`
Reason string `json:"reason,omitempty"`
DecisionID string `json:"decision_id,omitempty"`
Lifetime *api.DecisionLifetime `json:"lifetime,omitempty"`
RevocationVisibility map[string]string `json:"revocation_visibility,omitempty"`
PolicyPackage string `json:"policy_package,omitempty"`
PolicyPackageDigest string `json:"policy_package_digest,omitempty"`
}
// Response is the whole readback answer.
type Response struct {
RegistrySnapshotDigest string `json:"registry_snapshot_digest"`
Results []Result `json:"results"`
}
// System is one row of the registry projection.
type System struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
ClientBinding *api.ClientBinding `json:"client_binding,omitempty"`
Readback bool `json:"readback"`
}
// Systems is the read-only registry projection.
type Systems struct {
RegistrySnapshotDigest string `json:"registry_snapshot_digest"`
Systems []System `json:"systems"`
}
// Service evaluates readbacks over one merged registry and one package per
// participating system.
type Service struct {
store *registry.Store
engines map[string]*decisioncore.Engine
declared map[string]bool
}
// New builds one engine per system over the shared store. Every system named
// in packages must exist in the store.
func New(store *registry.Store, packages map[string]*policy.Package) (*Service, error) {
known := map[string]bool{}
for _, system := range store.Snapshot().Systems {
known[system.ID] = true
}
engines := make(map[string]*decisioncore.Engine, len(packages))
declared := make(map[string]bool, len(packages))
for system, pkg := range packages {
if !known[system] {
return nil, fmt.Errorf("readback package for %q names a system absent from the registry", system)
}
engine, err := decisioncore.NewEngine(store, pkg)
if err != nil {
return nil, fmt.Errorf("readback package for %q: %w", system, err)
}
engines[system] = engine
declared[system] = slices.Contains(pkg.Metadata.Actions, ApplicationAccess)
}
return &Service{store: store, engines: engines, declared: declared}, nil
}
// SetDecisionLog records every engine's decisions to log.
func (s *Service) SetDecisionLog(log decisioncore.DecisionRecorder) {
for _, engine := range s.engines {
engine.SetDecisionLog(log)
}
}
// Evaluate answers one row per requested system. Any evaluation or record
// error fails the whole request: the consumer renders every row unavailable,
// and a withheld allow (FLEX-DEC-2026-018) is never released.
func (s *Service) Evaluate(ctx context.Context, request Request) (Response, error) {
if err := validate(request); err != nil {
return Response{}, err
}
response := Response{RegistrySnapshotDigest: s.store.Digest(), Results: make([]Result, 0, len(request.Systems))}
for _, system := range request.Systems {
engine, ok := s.engines[system]
if !ok || !s.declared[system] {
response.Results = append(response.Results, Result{System: system, Effect: api.DecisionEffectNotApplicable, Reason: ReasonNotOffered})
continue
}
decision, err := engine.Check(ctx, api.CheckRequest{
Tenant: request.Tenant,
Subject: request.Subject,
Action: ApplicationAccess,
Resource: api.ResourceRef{ID: system, Type: ResourceType, System: system},
Context: request.Context,
Purpose: api.PurposeReadback,
})
if err != nil {
return Response{}, fmt.Errorf("readback %q: %w", system, err)
}
response.Results = append(response.Results, Result{
System: system,
Effect: decision.Effect,
Reason: decision.Reason,
DecisionID: decision.ID,
Lifetime: decision.Lifetime,
RevocationVisibility: RevocationVisibility,
PolicyPackage: decision.Provenance.PolicyPackage,
PolicyPackageDigest: decision.Provenance.PolicyPackageDigest,
})
}
return response, nil
}
// Systems projects the merged registry. It authorizes nothing.
func (s *Service) Systems() Systems {
snapshot := s.store.Snapshot()
out := Systems{RegistrySnapshotDigest: s.store.Digest(), Systems: make([]System, 0, len(snapshot.Systems))}
for _, system := range snapshot.Systems {
out.Systems = append(out.Systems, System{
ID: system.ID,
Name: system.Name,
ClientBinding: system.ClientBinding,
Readback: s.declared[system.ID],
})
}
return out
}
func validate(request Request) error {
if strings.TrimSpace(request.Subject.ID) == "" {
return fmt.Errorf("%w: subject.id is required", ErrInvalidRequest)
}
if len(request.Systems) == 0 || len(request.Systems) > MaxSystems {
return fmt.Errorf("%w: systems must name 1 to %d systems", ErrInvalidRequest, MaxSystems)
}
seen := make(map[string]bool, len(request.Systems))
for _, system := range request.Systems {
if strings.TrimSpace(system) == "" || seen[system] {
return fmt.Errorf("%w: systems must be non-empty and unique", ErrInvalidRequest)
}
seen[system] = true
}
return nil
}

View file

@ -0,0 +1,15 @@
package schemaguard
import "testing"
func TestArrayLengthBounds(t *testing.T) {
s := &Schema{root: map[string]any{"type": "array", "minItems": float64(1), "maxItems": float64(2)}, sibling: map[string]map[string]any{}}
for _, tc := range []struct {
doc []any
fail bool
}{{[]any{}, true}, {[]any{"a"}, false}, {[]any{"a", "b"}, false}, {[]any{"a", "b", "c"}, true}} {
if got := len(s.Validate(tc.doc)) > 0; got != tc.fail {
t.Fatalf("%v: failed=%v, want %v", tc.doc, got, tc.fail)
}
}
}

View file

@ -37,7 +37,7 @@ var known = map[string]bool{
"pattern": true, "items": true, "minLength": true, "format": true,
"if": true, "then": true, "allOf": true,
"uniqueItems": true, "minimum": true, "maximum": true, "not": true,
"minItems": true,
"minItems": true, "maxItems": true,
}
// Schema is a parsed JSON Schema document.
@ -219,6 +219,9 @@ func (s *Schema) checkIn(doc0 map[string]any, path string, node map[string]any,
if min, has := node["minItems"].(float64); has && len(arr) < int(min) {
probs = append(probs, fmt.Sprintf("%s: %d items, below minItems %d", at(path), len(arr), int(min)))
}
if max, has := node["maxItems"].(float64); has && len(arr) > int(max) {
probs = append(probs, fmt.Sprintf("%s: %d items, above maxItems %d", at(path), len(arr), int(max)))
}
if items, has := node["items"].(map[string]any); has {
for i, v := range arr {
probs = append(probs, s.checkIn(doc0, fmt.Sprintf("%s[%d]", path, i), items, v)...)

View file

@ -15,12 +15,21 @@ type ProtectedSystemManifest struct {
ID string `json:"id" yaml:"id"`
Name string `json:"name,omitempty" yaml:"name,omitempty"`
Description string `json:"description,omitempty" yaml:"description,omitempty"`
ClientBinding *ClientBinding `json:"client_binding,omitempty" yaml:"client_binding,omitempty"`
ResourceTypes []ResourceType `json:"resource_types,omitempty" yaml:"resource_types,omitempty"`
Actions []ActionDefinition `json:"actions,omitempty" yaml:"actions,omitempty"`
CaringProfiles []string `json:"caring_profiles,omitempty" yaml:"caring_profiles,omitempty"`
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
}
// ClientBinding names the key-cape client a protected system's users sign in
// through. It is descriptive only: the readback registry projection lists it,
// and it never decides an allow (FLEX-DEC-2026-019).
type ClientBinding struct {
Issuer string `json:"issuer" yaml:"issuer"`
ClientID string `json:"client_id" yaml:"client_id"`
}
// ResourceType describes a resource namespace entry owned by a protected system.
type ResourceType struct {
Name string `json:"name" yaml:"name"`
@ -168,8 +177,18 @@ type CheckRequest struct {
Context map[string]any `json:"context,omitempty" yaml:"context,omitempty"`
CaringContext *CaringAccessDescriptor `json:"caring_context,omitempty" yaml:"caring_context,omitempty"`
PolicyVersion string `json:"policy_version,omitempty" yaml:"policy_version,omitempty"`
// Purpose is empty on every enforcement request. The readback pin sets it
// to PurposeReadback, and it is hashed into every request digest, so an
// advisory readback decision can never pass a PEP's §6.4.2 replay test for
// an enforcement request (FLEX-DEC-2026-019). /v1/check and /v1/batch_check
// refuse a request that carries it.
Purpose string `json:"purpose,omitempty" yaml:"purpose,omitempty"`
}
// PurposeReadback marks an advisory application-access readback decision.
const PurposeReadback = "readback"
// BatchCheckRequest evaluates one subject/action against multiple resources.
type BatchCheckRequest struct {
ID string `json:"id,omitempty" yaml:"id,omitempty"`
@ -305,12 +324,15 @@ type DecisionLifetime struct {
// a decision. It lets a consumer verify structured action, target, actor, and
// context fields without parsing reason or diagnostic prose.
type DecisionBinding struct {
Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"`
Subject SubjectRef `json:"subject" yaml:"subject"`
Action string `json:"action" yaml:"action"`
Resource ResourceRef `json:"resource" yaml:"resource"`
Context map[string]any `json:"context,omitempty" yaml:"context,omitempty"`
RequestDigest string `json:"request_digest" yaml:"request_digest"`
Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"`
Subject SubjectRef `json:"subject" yaml:"subject"`
Action string `json:"action" yaml:"action"`
Resource ResourceRef `json:"resource" yaml:"resource"`
Context map[string]any `json:"context,omitempty" yaml:"context,omitempty"`
// Purpose is "readback" on an advisory readback decision and absent on an
// enforcement decision (FLEX-DEC-2026-019).
Purpose string `json:"purpose,omitempty" yaml:"purpose,omitempty"`
RequestDigest string `json:"request_digest" yaml:"request_digest"`
// ApprovalBindingDigest is RequestDigest computed with the approval claim
// removed from context. It exists to break a circularity: an approval's
@ -358,6 +380,7 @@ type requestDigestMaterial struct {
Action string `json:"action"`
Resource ResourceRef `json:"resource"`
Context map[string]any `json:"context,omitempty"`
Purpose string `json:"purpose,omitempty"`
}
// NewDecisionBinding returns a stable structured binding for the exact request
@ -377,6 +400,7 @@ func NewDecisionBinding(request CheckRequest) *DecisionBinding {
Action: request.Action,
Resource: request.Resource,
Context: contextCopy,
Purpose: request.Purpose,
RequestDigest: RequestDigest(request),
}
if _, carries := request.Context[ApprovalContextKey]; carries {
@ -427,11 +451,13 @@ func ApprovalBindingDigest(request CheckRequest) string {
Action: request.Action,
Resource: request.Resource,
Context: stripped,
Purpose: request.Purpose,
})
}
// RequestDigest is the mechanical §6.4.2 replay test: SHA-256 over canonical
// JSON of tenant, subject, action, resource, and context.
// JSON of tenant, subject, action, resource, context, and purpose. Purpose is
// omitted when empty, so enforcement digests are unchanged by its existence.
func RequestDigest(request CheckRequest) string {
return CanonicalDigest(requestDigestMaterial{
Tenant: request.Tenant,
@ -439,6 +465,7 @@ func RequestDigest(request CheckRequest) string {
Action: request.Action,
Resource: request.Resource,
Context: request.Context,
Purpose: request.Purpose,
})
}

25
pkg/api/purpose_test.go Normal file
View file

@ -0,0 +1,25 @@
package api
import "testing"
// FLEX-DEC-2026-019: purpose is hashed when set and absent when empty, so an
// enforcement digest is unchanged and a readback digest can never equal one.
func TestPurposeEntersDigestsOnlyWhenSet(t *testing.T) {
request := CheckRequest{
Subject: SubjectRef{ID: "user:alice"},
Action: "application.access",
Resource: ResourceRef{ID: "demo-app", System: "demo-app"},
}
legacy := CanonicalDigest(requestDigestMaterial{Subject: request.Subject, Action: request.Action, Resource: request.Resource})
if RequestDigest(request) != legacy {
t.Fatal("an empty purpose changed the enforcement digest")
}
readback := request
readback.Purpose = PurposeReadback
if RequestDigest(readback) == RequestDigest(request) || ApprovalBindingDigest(readback) == ApprovalBindingDigest(request) {
t.Fatal("purpose did not enter the digests")
}
if binding := NewDecisionBinding(readback); binding.Purpose != PurposeReadback {
t.Fatalf("binding purpose = %q", binding.Purpose)
}
}

View file

@ -125,6 +125,10 @@
"type": "object",
"additionalProperties": true
},
"purpose": {
"const": "readback",
"description": "Present only on an advisory readback decision (FLEX-DEC-2026-019); hashed into every digest."
},
"request_digest": {
"type": "string",
"pattern": "^sha256:[0-9a-f]{64}$",

View file

@ -9,6 +9,16 @@
"id": {"type": "string", "minLength": 1},
"name": {"type": "string", "minLength": 1},
"description": {"type": "string"},
"client_binding": {
"type": "object",
"additionalProperties": false,
"required": ["issuer", "client_id"],
"description": "key-cape client this system's users sign in through; descriptive only, never decides an allow (FLEX-DEC-2026-019).",
"properties": {
"issuer": {"type": "string", "minLength": 1},
"client_id": {"type": "string", "minLength": 1}
}
},
"resource_types": {
"type": "array",
"items": {"$ref": "#/$defs/resource_type"}

View file

@ -0,0 +1,63 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://flex-auth.netkingdom/schemas/readback_request.schema.json",
"title": "ReadbackRequest",
"description": "POST /v1/readback/application_access (FLEX-DEC-2026-019). There is no action, resource or purpose: the pin evaluates application.access only and sets purpose itself.",
"type": "object",
"additionalProperties": false,
"required": [
"subject",
"systems"
],
"properties": {
"tenant": {
"type": "string",
"minLength": 1
},
"subject": {
"$ref": "#/$defs/subject_ref"
},
"context": {
"type": "object",
"additionalProperties": true
},
"systems": {
"type": "array",
"minItems": 1,
"maxItems": 64,
"uniqueItems": true,
"items": {
"type": "string",
"minLength": 1
}
}
},
"$defs": {
"subject_ref": {
"type": "object",
"additionalProperties": false,
"required": [
"id"
],
"description": "Same shape as check_request subject_ref; user-engine sends what FlexAuthHTTPAdapter sends to /v1/check (FLEX-WP-0035 review).",
"properties": {
"id": {
"type": "string",
"minLength": 1
},
"type": {
"type": "string",
"minLength": 1
},
"tenant": {
"type": "string",
"minLength": 1
},
"attributes": {
"type": "object",
"additionalProperties": true
}
}
}
}
}

View file

@ -0,0 +1,86 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://flex-auth.netkingdom/schemas/readback_response.schema.json",
"title": "ReadbackResponse",
"description": "Advisory: no row is an enforcement decision. One row per requested system, in request order; none omitted.",
"type": "object",
"additionalProperties": false,
"required": [
"registry_snapshot_digest",
"results"
],
"properties": {
"registry_snapshot_digest": {
"type": "string",
"pattern": "^sha256:[0-9a-f]{64}$"
},
"results": {
"type": "array",
"items": {
"$ref": "#/$defs/result"
}
}
},
"$defs": {
"result": {
"type": "object",
"additionalProperties": false,
"required": [
"system",
"effect"
],
"properties": {
"system": {
"type": "string",
"minLength": 1
},
"effect": {
"enum": [
"allow",
"deny",
"not_applicable"
]
},
"reason": {
"type": "string"
},
"decision_id": {
"type": "string"
},
"lifetime": {
"type": "object",
"required": [
"kind",
"expires_at"
],
"properties": {
"kind": {
"type": "string"
},
"ttl": {
"type": "string"
},
"not_before": {
"type": "string"
},
"expires_at": {
"type": "string"
}
}
},
"revocation_visibility": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"policy_package": {
"type": "string"
},
"policy_package_digest": {
"type": "string"
}
}
}
}
}

View file

@ -0,0 +1,59 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://flex-auth.netkingdom/schemas/readback_systems.schema.json",
"title": "ReadbackSystems",
"description": "GET /v1/readback/systems: read-only registry projection. It authorizes nothing.",
"type": "object",
"additionalProperties": false,
"required": [
"registry_snapshot_digest",
"systems"
],
"properties": {
"registry_snapshot_digest": {
"type": "string",
"pattern": "^sha256:[0-9a-f]{64}$"
},
"systems": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"readback"
],
"properties": {
"id": {
"type": "string",
"minLength": 1
},
"name": {
"type": "string"
},
"readback": {
"type": "boolean"
},
"client_binding": {
"type": "object",
"additionalProperties": false,
"required": [
"issuer",
"client_id"
],
"properties": {
"issuer": {
"type": "string",
"minLength": 1
},
"client_id": {
"type": "string",
"minLength": 1
}
}
}
}
}
}
}
}

View file

@ -39,7 +39,7 @@ record.
```task
id: FLEX-WP-0035-T01
status: progress
status: done
priority: high
state_hub_task_id: "0585a684-d462-5caa-937f-b1730efddbd8"
```
@ -56,6 +56,11 @@ a PEP decision by another system.
Gate: written contract in `docs/` plus an ADR or decision record; reviewed with
user-engine before code.
2026-10-09: user-engine accepted the contract (message `f54df082`) with three
amendments, now folded in: the `/v1/check` subject shape with `tenant`, the
rendering table, and a separate `USER_ENGINE_READBACK_TOKEN_FILE`. Also added: a
realm-level-claims-only rule. `FLEX-DEC-2026-019` is accepted.
2026-10-09: activated by the founder. Draft contract
`docs/application-access-readback.md` and `FLEX-DEC-2026-019` (proposed). Key
finding: each pin loads one consumer's package, so no existing pin can answer
@ -68,7 +73,7 @@ user-engine with four review questions. The gate closes on their reply.
```task
id: FLEX-WP-0035-T02
status: todo
status: done
priority: high
depends_on: [FLEX-WP-0035-T01]
state_hub_task_id: "eb0b6a91-7750-553b-b87f-c4262eb9df07"
@ -82,11 +87,22 @@ accept, and its decisions must say so in the binding.
Gate: tests prove a readback-scoped token gets 403 on `/v1/check` for a foreign
system and is admitted only on the readback route.
2026-10-09 done. `flex-auth serve-readback` is a separate subcommand that mounts
no `/v1/check` or `/v1/batch_check` (404 there). `--readback-caller` is bound
under the non-system key `application-readback`. `CheckRequest.purpose` is
hashed into `request_digest`, `submitted_request_digest` and
`approval_binding_digest`, but only when set, so enforcement digests are
unchanged. `/v1/check` refuses a request that carries it. Tests:
`cmd/flex-auth/readback_test.go`
(`TestReadbackPrincipalCannotCheckOnEnforcementPin`,
`TestReadbackHTTPCallerAndRequestShape`, `TestReadbackWithholdsUncommittedAllow`)
and `pkg/api/purpose_test.go`.
## Readable protected-system registry
```task
id: FLEX-WP-0035-T03
status: todo
status: done
priority: medium
depends_on: [FLEX-WP-0035-T01]
state_hub_task_id: "5dbb3280-8490-58a5-a0bb-f3883e568f58"
@ -99,17 +115,44 @@ inside access-engine; user-domain admins get no grant surface from this work.
Gate: endpoint plus schema, admitted only for the readback caller, tests.
## Deploy to the user-engine pin
2026-10-09 done. `GET /v1/readback/systems`; optional `client_binding` on
`protected_system_manifest`. Schemas `readback_request`, `readback_response`
and `readback_systems` are checked against live service output
(`TestReadbackShapesMatchSchemas`). schemaguard gained `maxItems` for this.
## informed-decision declares `application.access`
```task
id: FLEX-WP-0035-T05
status: todo
priority: high
```
informed-decision is the first participant. Its `informed-decision.lifecycle`
package reads `authentication_audience`, which the readback must not use
(contract §3). Add an `application.access` rule over realm-level claims only,
as a new package version with fixtures. Coordinate the version bump with
informed-decision, because its own pin must run the same digest. Add
`client_binding` to its registry entry.
Gate: package valid with allow/deny fixtures, informed-decision agrees, and
the rule reads no audience, authorized-party or client-scoped claim.
## Deploy the readback pin
```task
id: FLEX-WP-0035-T04
status: wait
needs_human: true
depends_on: [FLEX-WP-0035-T02, FLEX-WP-0035-T03]
depends_on: [FLEX-WP-0035-T05]
blocking_reason: "Live pin change on railiance01 needs founder go."
priority: medium
state_hub_task_id: "82a8a7e6-54e9-5262-a271-24ce2f161362"
```
Roll the readback route and its caller binding onto `flex-auth-user-engine`,
then hand user-engine the evidence that unblocks `USER-WP-0026-T03`.
Add `values/flex-auth-application-readback.yaml` (or a deploy manifest) for a
dedicated `flex-auth-application-readback` pin, plus a test asserting that its
package paths match each participant's own pin values (digest-equality, §4).
Add the user-engine ingress rule, then get the founder's go for the rollout.
The pin ships with the image that contains `serve-readback`. Hand user-engine
the evidence that unblocks `USER-WP-0026-T03`.