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