diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 3c5f595..ef60051 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -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 | diff --git a/cmd/flex-auth/main.go b/cmd/flex-auth/main.go index 3355872..ea90a06 100644 --- a/cmd/flex-auth/main.go +++ b/cmd/flex-auth/main.go @@ -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 [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 diff --git a/cmd/flex-auth/readback.go b/cmd/flex-auth/readback.go new file mode 100644 index 0000000..cabfe31 --- /dev/null +++ b/cmd/flex-auth/readback.go @@ -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 +} diff --git a/cmd/flex-auth/readback_test.go b/cmd/flex-auth/readback_test.go new file mode 100644 index 0000000..0d64543 --- /dev/null +++ b/cmd/flex-auth/readback_test.go @@ -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) + } + } +} diff --git a/cmd/flex-auth/testdata/readback/demo-app/policy_fixtures.yaml b/cmd/flex-auth/testdata/readback/demo-app/policy_fixtures.yaml new file mode 100644 index 0000000..f00e31c --- /dev/null +++ b/cmd/flex-auth/testdata/readback/demo-app/policy_fixtures.yaml @@ -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 diff --git a/cmd/flex-auth/testdata/readback/demo-app/policy_package.md b/cmd/flex-auth/testdata/readback/demo-app/policy_package.md new file mode 100644 index 0000000..6b9912b --- /dev/null +++ b/cmd/flex-auth/testdata/readback/demo-app/policy_package.md @@ -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": {}}} +} +``` diff --git a/cmd/flex-auth/testdata/readback/demo-app/registry_snapshot.json b/cmd/flex-auth/testdata/readback/demo-app/registry_snapshot.json new file mode 100644 index 0000000..a5204d0 --- /dev/null +++ b/cmd/flex-auth/testdata/readback/demo-app/registry_snapshot.json @@ -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 + } + } + } + ] +} diff --git a/cmd/flex-auth/testdata/readback/other-app/policy_fixtures.yaml b/cmd/flex-auth/testdata/readback/other-app/policy_fixtures.yaml new file mode 100644 index 0000000..48b18fa --- /dev/null +++ b/cmd/flex-auth/testdata/readback/other-app/policy_fixtures.yaml @@ -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 diff --git a/cmd/flex-auth/testdata/readback/other-app/policy_package.md b/cmd/flex-auth/testdata/readback/other-app/policy_package.md new file mode 100644 index 0000000..04d6a51 --- /dev/null +++ b/cmd/flex-auth/testdata/readback/other-app/policy_package.md @@ -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": {}}} +} +``` diff --git a/cmd/flex-auth/testdata/readback/other-app/registry_snapshot.json b/cmd/flex-auth/testdata/readback/other-app/registry_snapshot.json new file mode 100644 index 0000000..4f56e7e --- /dev/null +++ b/cmd/flex-auth/testdata/readback/other-app/registry_snapshot.json @@ -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 + } + } + } + ] +} diff --git a/decisions/decisions.md b/decisions/decisions.md index bea9514..207c037 100644 --- a/decisions/decisions.md +++ b/decisions/decisions.md @@ -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. diff --git a/docs/application-access-readback.md b/docs/application-access-readback.md index 59c8638..8048696 100644 --- a/docs/application-access-readback.md +++ b/docs/application-access-readback.md @@ -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": , "type": "application", "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//registry_snapshot.json \ + --package =examples//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`. diff --git a/internal/readback/readback.go b/internal/readback/readback.go new file mode 100644 index 0000000..292884e --- /dev/null +++ b/internal/readback/readback.go @@ -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 +} diff --git a/internal/schemaguard/items_test.go b/internal/schemaguard/items_test.go new file mode 100644 index 0000000..a715052 --- /dev/null +++ b/internal/schemaguard/items_test.go @@ -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) + } + } +} diff --git a/internal/schemaguard/schemaguard.go b/internal/schemaguard/schemaguard.go index 351ffc4..9bade8a 100644 --- a/internal/schemaguard/schemaguard.go +++ b/internal/schemaguard/schemaguard.go @@ -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)...) diff --git a/pkg/api/canonical.go b/pkg/api/canonical.go index fba023f..c2b3a47 100644 --- a/pkg/api/canonical.go +++ b/pkg/api/canonical.go @@ -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, }) } diff --git a/pkg/api/purpose_test.go b/pkg/api/purpose_test.go new file mode 100644 index 0000000..18d43d4 --- /dev/null +++ b/pkg/api/purpose_test.go @@ -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) + } +} diff --git a/schemas/decision_envelope.schema.json b/schemas/decision_envelope.schema.json index 2a76a50..9e112ce 100644 --- a/schemas/decision_envelope.schema.json +++ b/schemas/decision_envelope.schema.json @@ -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}$", diff --git a/schemas/protected_system_manifest.schema.json b/schemas/protected_system_manifest.schema.json index c2e3910..66d3ed9 100644 --- a/schemas/protected_system_manifest.schema.json +++ b/schemas/protected_system_manifest.schema.json @@ -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"} diff --git a/schemas/readback_request.schema.json b/schemas/readback_request.schema.json new file mode 100644 index 0000000..f729140 --- /dev/null +++ b/schemas/readback_request.schema.json @@ -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 + } + } + } + } +} diff --git a/schemas/readback_response.schema.json b/schemas/readback_response.schema.json new file mode 100644 index 0000000..5fc944e --- /dev/null +++ b/schemas/readback_response.schema.json @@ -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" + } + } + } + } +} diff --git a/schemas/readback_systems.schema.json b/schemas/readback_systems.schema.json new file mode 100644 index 0000000..c2cd6ce --- /dev/null +++ b/schemas/readback_systems.schema.json @@ -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 + } + } + } + } + } + } + } +} diff --git a/workplans/FLEX-WP-0035-per-identity-application-readback.md b/workplans/FLEX-WP-0035-per-identity-application-readback.md index d8f341b..f53a205 100644 --- a/workplans/FLEX-WP-0035-per-identity-application-readback.md +++ b/workplans/FLEX-WP-0035-per-identity-application-readback.md @@ -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`.