FLEX-WP-0036: binary renamed to access-engine; flex-auth is a stub naming the rename (FLEX-DEC-2026-020)
- cmd/flex-auth -> cmd/access-engine; usage and serving lines say access-engine - cmd/flex-auth: prints the rename and exits 2, with a test - image: entrypoint /usr/local/bin/access-engine, flex-auth stub alongside; live pins pass only args, so their next roll is unaffected - Makefile, CI, verify-posture, tenancy.yaml, live docs updated; workplans, decisions and ADRs left as written - runtime names (module, audience, k8s, image package, /opt paths) unchanged Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 735967@bnt-lap001 Assistant-Session: 5971967f-77f8-4ced-9e02-c6ef67040ffc
This commit is contained in:
parent
c284344925
commit
0ced2995ee
36 changed files with 1268 additions and 1178 deletions
345
cmd/access-engine/readback_test.go
Normal file
345
cmd/access-engine/readback_test.go
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FLEX-WP-0035-T05: the shipped catalogue and informed-decision lifecycle v2
|
||||
// answer the readback the way user-engine will ask it.
|
||||
func TestReadbackInformedDecisionCatalogue(t *testing.T) {
|
||||
examples := filepath.Join("..", "..", "examples")
|
||||
service, err := buildReadback(context.Background(),
|
||||
[]string{
|
||||
filepath.Join(examples, "application-readback", "registry_snapshot.json"),
|
||||
filepath.Join(examples, "informed-decision-lifecycle", "registry.json"),
|
||||
},
|
||||
map[string]string{"informed-decision": filepath.Join(examples, "informed-decision-lifecycle", "policy.md")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
subject := func(groups ...any) readback.Request {
|
||||
return readback.Request{
|
||||
Tenant: "tenant:platform",
|
||||
Subject: api.SubjectRef{ID: "human-one", Type: "human", Tenant: "tenant:platform", Attributes: map[string]any{
|
||||
"issuer": "https://kc.coulomb.social", "roles": []any{}, "tenant_roles": []any{}, "scopes": []any{}, "groups": groups,
|
||||
"assurance": map[string]any{"level": "aal2", "mfa": true, "source": "key-cape", "methods": []any{"pwd", "otp"}, "at": time.Now().Unix()},
|
||||
}},
|
||||
Systems: []string{"informed-decision"},
|
||||
}
|
||||
}
|
||||
for name, tc := range map[string]struct {
|
||||
request readback.Request
|
||||
effect api.DecisionEffect
|
||||
}{
|
||||
"admin": {subject("net-kingdom-admins"), api.DecisionEffectAllow},
|
||||
"non-admin": {subject("staff"), api.DecisionEffectDeny},
|
||||
} {
|
||||
response, err := service.Evaluate(context.Background(), tc.request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := response.Results[0]; got.Effect != tc.effect || got.PolicyPackage != "informed-decision.lifecycle" {
|
||||
t.Fatalf("%s: %+v", name, got)
|
||||
}
|
||||
}
|
||||
systems := service.Systems().Systems
|
||||
if len(systems) != 1 || !systems[0].Readback || systems[0].ClientBinding == nil || systems[0].ClientBinding.ClientID != "informed-decision-approver" {
|
||||
t.Fatalf("catalogue = %+v", systems)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue