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
This commit is contained in:
parent
b6d6bff85e
commit
8f2ba75e4a
23 changed files with 1314 additions and 44 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
167
cmd/flex-auth/readback.go
Normal 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(®istries, "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
|
||||
}
|
||||
300
cmd/flex-auth/readback_test.go
Normal file
300
cmd/flex-auth/readback_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
24
cmd/flex-auth/testdata/readback/demo-app/policy_fixtures.yaml
vendored
Normal file
24
cmd/flex-auth/testdata/readback/demo-app/policy_fixtures.yaml
vendored
Normal 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
|
||||
53
cmd/flex-auth/testdata/readback/demo-app/policy_package.md
vendored
Normal file
53
cmd/flex-auth/testdata/readback/demo-app/policy_package.md
vendored
Normal 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": {}}}
|
||||
}
|
||||
```
|
||||
28
cmd/flex-auth/testdata/readback/demo-app/registry_snapshot.json
vendored
Normal file
28
cmd/flex-auth/testdata/readback/demo-app/registry_snapshot.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
14
cmd/flex-auth/testdata/readback/other-app/policy_fixtures.yaml
vendored
Normal file
14
cmd/flex-auth/testdata/readback/other-app/policy_fixtures.yaml
vendored
Normal 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
|
||||
52
cmd/flex-auth/testdata/readback/other-app/policy_package.md
vendored
Normal file
52
cmd/flex-auth/testdata/readback/other-app/policy_package.md
vendored
Normal 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": {}}}
|
||||
}
|
||||
```
|
||||
24
cmd/flex-auth/testdata/readback/other-app/registry_snapshot.json
vendored
Normal file
24
cmd/flex-auth/testdata/readback/other-app/registry_snapshot.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
199
internal/readback/readback.go
Normal file
199
internal/readback/readback.go
Normal 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
|
||||
}
|
||||
15
internal/schemaguard/items_test.go
Normal file
15
internal/schemaguard/items_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)...)
|
||||
|
|
|
|||
|
|
@ -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
25
pkg/api/purpose_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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}$",
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
63
schemas/readback_request.schema.json
Normal file
63
schemas/readback_request.schema.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
86
schemas/readback_response.schema.json
Normal file
86
schemas/readback_response.schema.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
59
schemas/readback_systems.schema.json
Normal file
59
schemas/readback_systems.schema.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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`.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue