access-engine/cmd/flex-auth/readback.go
tegwick 8f2ba75e4a
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Container Image / build-and-push (push) Successful in 58s
FLEX-WP-0035 T01-T03: application access readback pin
Contract accepted by user-engine; FLEX-DEC-2026-019 accepted.
- serve-readback: dedicated pin, no enforcement routes, own caller binding
- CheckRequest.purpose hashed into every digest when set; /v1/check refuses it
- POST /v1/readback/application_access and GET /v1/readback/systems
- optional client_binding on protected systems; readback schemas
- schemaguard implements maxItems
- T05 added: informed-decision needs an application.access rule over
  realm-level claims (its lifecycle rules read authentication_audience)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 609675@bnt-lap001
Assistant-Session: 4987a36e-eb38-4b10-803c-15a29cd78212
2026-10-09 20:02:31 +02:00

167 lines
5.7 KiB
Go

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
}