diff --git a/cmd/access-engine/runtime_rename_test.go b/cmd/access-engine/runtime_rename_test.go new file mode 100644 index 0000000..4b6722e --- /dev/null +++ b/cmd/access-engine/runtime_rename_test.go @@ -0,0 +1,131 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/netkingdom/access-engine/internal/callerauth" + "github.com/netkingdom/access-engine/internal/policy" + "github.com/netkingdom/access-engine/pkg/api" + "gopkg.in/yaml.v3" +) + +// The packages name their protected consumer, not the PDP. Authenticate both +// PDP audiences against exactly the installed mapping, then compare real engine +// outcomes and canonical request bindings with the unchanged package baseline. +func TestRuntimeRenamePinPackagesPreserveDecisionMaterial(t *testing.T) { + for _, pin := range []string{"ops-warden", "ops-warden-fetch", "secrets-engine", "user-engine", "tenant-engine", "informed-decision-lifecycle", "informed-decision-sitting", "informed-decision-t03"} { + t.Run(pin, func(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("..", "..", "values", pin+".yaml")) + if err != nil { + t.Fatal(err) + } + var values struct { + Args []string `yaml:"args"` + CallerAuth struct { + Binding string `yaml:"binding"` + Extra []string `yaml:"extraBindings"` + } `yaml:"callerAuth"` + } + if err := yaml.Unmarshal(raw, &values); err != nil { + t.Fatal(err) + } + bindings := map[string]string{} + for _, entry := range append([]string{values.CallerAuth.Binding}, values.CallerAuth.Extra...) { + system, principal, ok := strings.Cut(entry, "=") + if !ok { + t.Fatalf("bad mapping: %s", entry) + } + bindings[system] = principal + } + paths := map[string]string{} + for i := 0; i+1 < len(values.Args); i++ { + if values.Args[i] == "--registry" || values.Args[i] == "--policy" { + paths[values.Args[i]] = filepath.Join("..", "..", strings.TrimPrefix(values.Args[i+1], "/opt/flex-auth/")) + } + } + pkg, err := policy.LoadFile(paths["--policy"]) + if err != nil { + t.Fatal(err) + } + engine, err := buildEngine(context.Background(), paths["--registry"], paths["--policy"], "") + if err != nil { + t.Fatal(err) + } + if len(pkg.Fixtures) == 0 { + t.Fatal("package has no decision fixtures") + } + exercised := 0 + for _, fixture := range pkg.Fixtures { + principal, installed := bindings[fixture.Request.Resource.System] + if !installed { + continue + } + baseline, err := engine.Check(context.Background(), fixture.Request) + if err != nil { + t.Fatalf("baseline %s: %v", fixture.ID, err) + } + original := api.CanonicalDigest(fixture.Request) + for _, audience := range []string{"flex-auth", "access-engine"} { + a, err := callerauth.New(callerauth.ModeEnforce, fixedTokenReviewer{identity: callerauth.Identity{Username: principal, Audiences: []string{audience}}}, "flex-auth", bindings, nil) + if err != nil { + t.Fatal(err) + } + identity, err := a.Authorize(context.Background(), "Bearer test-token", []string{fixture.Request.Resource.System}) + if err != nil { + t.Fatal(err) + } + got, err := engine.Check(callerauth.WithRecord(context.Background(), a.Record(identity)), fixture.Request) + if err != nil { + t.Fatalf("%s/%s: %v", fixture.ID, audience, err) + } + if got.Effect != baseline.Effect || got.Reason != baseline.Reason || got.Binding.RequestDigest != baseline.Binding.RequestDigest { + t.Fatalf("%s/%s decision material changed", fixture.ID, audience) + } + if got.Provenance.Caller.Audience != audience { + t.Fatal("requested audience spelling lost") + } + if got.Resource.System != fixture.Request.Resource.System || got.Binding.Resource.System != fixture.Request.Resource.System { + t.Fatal("resource.system spelling changed") + } + if api.CanonicalDigest(fixture.Request) != original { + t.Fatal("original request silently rewritten") + } + } + exercised++ + } + if exercised == 0 { + t.Fatal("no installed-system fixtures exercised") + } + t.Logf("%d owner-installed package fixtures preserved with both audiences", exercised) + }) + } +} + +func TestRuntimeRenameReadbackRoutesPreserveCallerBoundary(t *testing.T) { + for _, audience := range []string{"flex-auth", "access-engine"} { + for _, principal := range []string{readbackPrincipal, "system:serviceaccount:other:caller"} { + a, err := callerauth.New(callerauth.ModeEnforce, fixedTokenReviewer{identity: callerauth.Identity{Username: principal, Audiences: []string{audience}}}, "flex-auth", map[string]string{readbackBinding: readbackPrincipal}, nil) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(newReadbackMux(newTestReadback(t), a)) + valid, _ := json.Marshal(subjectRequest("user:alice", true, "demo-app")) + got := postReadback(t, server.URL, string(valid)).StatusCode + server.Close() + want := http.StatusForbidden + if principal == readbackPrincipal { + want = http.StatusOK + } + if got != want { + t.Fatalf("%s/%s: status %d want %d", audience, principal, got, want) + } + } + } +} diff --git a/internal/callerauth/auth.go b/internal/callerauth/auth.go index dbd1b8f..f021008 100644 --- a/internal/callerauth/auth.go +++ b/internal/callerauth/auth.go @@ -130,7 +130,14 @@ func (a *Authenticator) Record(identity Identity) Record { return record } record.Principal = identity.Username - record.Audience = audience + // Stamp an audience actually returned by TokenReview, never merely the + // configured audience. Prefer the configured spelling when both matched. + for _, accepted := range runtimeAudiences(audience) { + if contains(identity.Audiences, accepted) { + record.Audience = accepted + break + } + } record.NotAfter = identity.NotAfter return record } @@ -153,7 +160,7 @@ func (a *Authenticator) authorize(ctx context.Context, authorization string, sys identity.NotAfter = exp } } - if strings.TrimSpace(identity.Username) == "" || !contains(identity.Audiences, a.audience) { + if strings.TrimSpace(identity.Username) == "" || !matchesRuntimeAudience(identity.Audiences, a.audience) { return Identity{}, ErrUnauthenticated } if len(systems) == 0 { @@ -176,3 +183,26 @@ func contains(values []string, wanted string) bool { } return false } + +// runtimeAudiences is the bounded FLEX-WP-0036 compatibility window. It +// changes only the PDP audience, never protected-system names or principals. +// A custom configured audience retains its exact original meaning. +func runtimeAudiences(configured string) []string { + switch configured { + case "flex-auth": + return []string{"flex-auth", "access-engine"} + case "access-engine": + return []string{"access-engine", "flex-auth"} + default: + return []string{configured} + } +} + +func matchesRuntimeAudience(reviewed []string, configured string) bool { + for _, accepted := range runtimeAudiences(configured) { + if contains(reviewed, accepted) { + return true + } + } + return false +} diff --git a/internal/callerauth/runtime_rename_test.go b/internal/callerauth/runtime_rename_test.go new file mode 100644 index 0000000..75ad64e --- /dev/null +++ b/internal/callerauth/runtime_rename_test.go @@ -0,0 +1,147 @@ +package callerauth + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestRuntimeRenameEveryInstalledPinBinding(t *testing.T) { + pins := []string{"ops-warden", "ops-warden-fetch", "secrets-engine", "user-engine", "tenant-engine", "informed-decision-lifecycle", "informed-decision-sitting", "informed-decision-t03", "application-readback"} + for _, pin := range pins { + t.Run(pin, func(t *testing.T) { + data, err := os.ReadFile(filepath.Join("..", "..", "values", pin+".yaml")) + if err != nil { + t.Fatal(err) + } + var value struct { + CallerAuth struct { + Binding string `yaml:"binding"` + Extra []string `yaml:"extraBindings"` + } `yaml:"callerAuth"` + Readback struct { + Caller string `yaml:"caller"` + } `yaml:"readback"` + } + if err := yaml.Unmarshal(data, &value); err != nil { + t.Fatal(err) + } + bindings := map[string]string{} + for _, entry := range append([]string{value.CallerAuth.Binding}, value.CallerAuth.Extra...) { + if entry == "" { + continue + } + system, principal, ok := strings.Cut(entry, "=") + if !ok { + t.Fatalf("bad binding: %s", entry) + } + bindings[system] = principal + } + // Readback's bounded authenticated caller is installed by serve-readback, + // rather than a callerAuth binding flag. + if value.Readback.Caller != "" { + bindings["application-readback"] = value.Readback.Caller + } + if len(bindings) == 0 { + t.Fatal("pin has no installed caller mapping") + } + for system, principal := range bindings { + for _, configured := range []string{"flex-auth", "access-engine"} { + for _, reviewed := range []string{"flex-auth", "access-engine"} { + a, err := New(ModeEnforce, fakeReviewer{identity: Identity{Username: principal, Audiences: []string{reviewed}}}, configured, bindings, nil) + if err != nil { + t.Fatal(err) + } + id, err := a.Authorize(context.Background(), "Bearer test-token", []string{system}) + if err != nil { + t.Fatal(err) + } + if got := a.Record(id).Audience; got != reviewed { + t.Fatalf("provenance %q != reviewed %q", got, reviewed) + } + for _, forbidden := range []string{"foreign-consumer", "flex-auth", "access-engine"} { + if _, err := a.Authorize(context.Background(), "Bearer test-token", []string{forbidden}); !errors.Is(err, ErrForbidden) { + t.Fatalf("%s accepted as %s: %v", system, forbidden, err) + } + } + wrong, _ := New(ModeEnforce, fakeReviewer{identity: Identity{Username: "other-principal", Audiences: []string{reviewed}}}, configured, bindings, nil) + if _, err := wrong.Authorize(context.Background(), "Bearer test-token", []string{system}); !errors.Is(err, ErrForbidden) { + t.Fatalf("wrong principal: %v", err) + } + } + } + } + }) + } +} + +func TestRuntimeRenameUsesActualTokenReviewAudience(t *testing.T) { + for _, configured := range []string{"flex-auth", "access-engine", "custom-pdp"} { + for _, returned := range []string{"flex-auth", "access-engine", "foreign", "custom-pdp", ""} { + t.Run(configured+"/"+returned, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + var request tokenReview + if err := json.NewDecoder(req.Body).Decode(&request); err != nil { + t.Error(err) + return + } + if !reflect.DeepEqual(request.Spec.Audiences, runtimeAudiences(configured)) { + t.Errorf("requested audiences: %v", request.Spec.Audiences) + } + response := tokenReview{} + response.Status.Authenticated = true + response.Status.User.Username = "principal" + if returned != "" { + response.Status.Audiences = []string{returned} + } + _ = json.NewEncoder(w).Encode(response) + })) + defer srv.Close() + file := filepath.Join(t.TempDir(), "reviewer") + if err := os.WriteFile(file, []byte("test-only-reviewer"), 0600); err != nil { + t.Fatal(err) + } + reviewer := &KubernetesTokenReviewer{endpoint: srv.URL, audience: configured, reviewerTokenFile: file, client: srv.Client()} + a, _ := New(ModeEnforce, reviewer, configured, map[string]string{"consumer": "principal"}, nil) + // The untrusted token claims the opposite: only authenticated TokenReview + // response audiences decide admission and provenance. + token := unsignedJWT(map[string]any{"aud": "foreign", "sub": "forged"}) + id, err := a.Authorize(context.Background(), "Bearer "+token, []string{"consumer"}) + want := contains(runtimeAudiences(configured), returned) + if want { + if err != nil { + t.Fatal(err) + } + if a.Record(id).Audience != returned { + t.Fatal("configured/untrusted audience leaked into provenance") + } + } else if !errors.Is(err, ErrUnauthenticated) { + t.Fatalf("foreign/missing audience accepted: %v", err) + } + }) + } + } +} + +func TestRuntimeRenameCustomAudienceAndInputBindingsStayExact(t *testing.T) { + bindings := map[string]string{"flex-auth": "principal"} + a, err := New(ModeEnforce, fakeReviewer{identity: Identity{Username: "principal", Audiences: []string{"custom-pdp"}}}, "custom-pdp", bindings, nil) + if err != nil { + t.Fatal(err) + } + if _, err := a.Authorize(context.Background(), "Bearer test", []string{"access-engine"}); !errors.Is(err, ErrForbidden) { + t.Fatalf("uninstalled alias binding accepted: %v", err) + } + if len(bindings) != 1 { + t.Fatal("input bindings mutated") + } +} diff --git a/internal/callerauth/tokenreview.go b/internal/callerauth/tokenreview.go index 4fad271..88efdcb 100644 --- a/internal/callerauth/tokenreview.go +++ b/internal/callerauth/tokenreview.go @@ -76,7 +76,7 @@ func (r *KubernetesTokenReviewer) Review(ctx context.Context, callerToken string Kind: "TokenReview", Spec: tokenReviewSpec{ Token: callerToken, - Audiences: []string{r.audience}, + Audiences: runtimeAudiences(r.audience), }, }) if err != nil { diff --git a/workplans/FLEX-WP-0036-runtime-rename-access-engine.md b/workplans/FLEX-WP-0036-runtime-rename-access-engine.md index ab962da..ee6942c 100644 --- a/workplans/FLEX-WP-0036-runtime-rename-access-engine.md +++ b/workplans/FLEX-WP-0036-runtime-rename-access-engine.md @@ -115,7 +115,7 @@ current source must now come from `./cmd/access-engine`. ```task id: FLEX-WP-0036-T03 -status: todo +status: progress priority: high depends_on: [FLEX-WP-0036-T01] state_hub_task_id: "487525e5-c89e-586e-8e9b-54ac6da0804e" @@ -126,8 +126,23 @@ and caller bindings accept both `resource.system` values. Decision records must still state which value the request carried, so nothing is rewritten silently. Add tests for both values on every pin's package. -Gate: tests; a new image rolled to the existing `flex-auth-*` pins with the -founder's go. +Gate: tests; a new image rolled to the existing `flex-auth-*` pins through +reviewed reversible blue execution under +`the-custodian/canon/standards/build-mode-change-authorization_v0.1.md`. +The ordered live prerequisite must be verified before rollout. + +2026-10-11 source preparation: bounded compatibility accepts both exact PDP +audiences in Kubernetes TokenReview and records an actually reviewed spelling. +Custom audiences, installed service principals and protected-consumer system +bindings retain their existing meanings. All nine current pins authenticate both +audiences; eight enforcement packages retain their fixture outcomes, canonical +request bindings and original `resource.system` spelling. The ninth pin is the +separate application-readback route, whose installed binding is +`application-readback`, not a protected-system grant. There is no current +PDP-self resource lane in these pin packages; the old inventory's +`flex-auth-policy-check` example is a policy-routing lane outside this secret +fetch package. No new self-lane or caller grant is created to fill that absence. +T03 remains progress until the new image and ordered live checks are proven. ## Rename the source