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
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue