Bind service task HTTP input to authenticated caller before evaluation
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Container Image / build-and-push (push) Failing after 1m1s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a140c4-354a-7823-8f0d-8f717397f786
This commit is contained in:
tegwick 2026-10-16 16:25:55 +02:00
parent 919ce25a37
commit 4f6946bbb3
8 changed files with 590 additions and 9 deletions

View file

@ -22,6 +22,7 @@ import (
"github.com/netkingdom/access-engine/internal/emission"
"github.com/netkingdom/access-engine/internal/policy"
"github.com/netkingdom/access-engine/internal/registry"
"github.com/netkingdom/access-engine/internal/taskexecution"
"github.com/netkingdom/access-engine/pkg/api"
)
@ -397,7 +398,11 @@ func runServe(args []string, stdout, stderr io.Writer) int {
return fail(stderr, err)
}
mux := newServeMuxWithCallerAuth(engine, authenticator)
serviceInputs, err := buildServicePilotInputs(engine, callerauth.Mode(*callerAuthMode), *callerAudience, *callerKubernetesURL, *callerReviewerTokenFile, *callerCAFile, callerBindings.StringMap())
if err != nil {
return fail(stderr, err)
}
mux := newServeMuxWithServicePilotInputs(engine, authenticator, serviceInputs)
if outbox != nil {
mountEmission(mux, outbox)
}
@ -448,6 +453,10 @@ func newServeMux(engine *decisioncore.Engine) *http.ServeMux {
}
func newServeMuxWithCallerAuth(engine *decisioncore.Engine, authenticator *callerauth.Authenticator) *http.ServeMux {
return newServeMuxWithServicePilotInputs(engine, authenticator, nil)
}
func newServeMuxWithServicePilotInputs(engine *decisioncore.Engine, authenticator *callerauth.Authenticator, serviceInputs *taskexecution.ServicePilotInputs) *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("content-type", "application/json")
@ -458,6 +467,25 @@ func newServeMuxWithCallerAuth(engine *decisioncore.Engine, authenticator *calle
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if engine.IsServicePilot() {
if serviceInputs == nil {
http.Error(w, "service input unavailable", http.StatusServiceUnavailable)
return
}
request, err := taskexecution.DecodeServiceRequest(r.Body)
if err != nil || request.Purpose != "" {
http.Error(w, "invalid service task request", http.StatusBadRequest)
return
}
input, err := serviceInputs.Prepare(r.Context(), r.Header.Get("Authorization"), request, time.Now().UTC())
if err != nil {
http.Error(w, "service task input refused", http.StatusForbidden)
return
}
decision, err := engine.CheckServicePilot(r.Context(), input)
writeHTTP(w, decision, err)
return
}
var request api.CheckRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
@ -478,6 +506,10 @@ func newServeMuxWithCallerAuth(engine *decisioncore.Engine, authenticator *calle
writeHTTP(w, decision, err)
})
mux.HandleFunc("/v1/batch_check", func(w http.ResponseWriter, r *http.Request) {
if engine.IsServicePilot() {
http.Error(w, "service task batch unavailable", http.StatusForbidden)
return
}
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
@ -502,6 +534,22 @@ func newServeMuxWithCallerAuth(engine *decisioncore.Engine, authenticator *calle
return mux
}
// Only the existing exact service pilot package selects this source path.
// This is not an eligibility toggle; successful preparation still only DENYs.
func buildServicePilotInputs(engine *decisioncore.Engine, mode callerauth.Mode, audience, kubernetesURL, reviewerTokenFile, caFile string, bindings map[string]string) (*taskexecution.ServicePilotInputs, error) {
if !engine.IsServicePilot() {
return nil, nil
}
if mode != callerauth.ModeEnforce || audience != "flex-auth" || len(bindings) != 1 || bindings["secrets-engine"] != taskexecution.ServicePilotPrincipal {
return nil, fmt.Errorf("service pilot requires exact enforced retained caller mapping")
}
reviewer, err := callerauth.NewKubernetesTokenReviewer(kubernetesURL, audience, reviewerTokenFile, caFile)
if err != nil {
return nil, err
}
return taskexecution.NewServicePilotInputs(reviewer)
}
func buildCallerAuthenticator(mode callerauth.Mode, audience, kubernetesURL, reviewerTokenFile, caFile string, bindings map[string]string, stderr io.Writer) (*callerauth.Authenticator, error) {
if mode == callerauth.ModeDisabled {
return callerauth.New(mode, nil, "", nil, nil)
@ -615,7 +663,7 @@ func mountEmission(mux *http.ServeMux, outbox *emission.Outbox) {
func writeHTTP(w http.ResponseWriter, value any, err error) {
w.Header().Set("content-type", "application/json")
if errors.Is(err, decisioncore.ErrRecordNotCommitted) {
if errors.Is(err, decisioncore.ErrRecordNotCommitted) || errors.Is(err, decisioncore.ErrServicePilotUnavailable) {
// Unavailable evaluation is not an authorization verdict (GH-DEC-2026-024).
http.Error(w, err.Error(), http.StatusServiceUnavailable)
return

View file

@ -0,0 +1,376 @@
package main
import (
"bytes"
"context"
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"encoding/pem"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/netkingdom/access-engine/internal/audit"
"github.com/netkingdom/access-engine/internal/callerauth"
"github.com/netkingdom/access-engine/internal/decision"
"github.com/netkingdom/access-engine/internal/policy"
"github.com/netkingdom/access-engine/internal/registry"
"github.com/netkingdom/access-engine/internal/sign"
"github.com/netkingdom/access-engine/internal/taskexecution"
"github.com/netkingdom/access-engine/pkg/api"
)
func pilotRequest(now time.Time) api.CheckRequest {
return api.CheckRequest{ID: "source-only-service-http", Tenant: "tenant:platform", Subject: api.SubjectRef{ID: "secrets-engine", Type: "service"}, Action: "execute-ccr", Resource: api.ResourceRef{ID: "catalog:synthetic", Type: "secret-catalog-lane", System: "secrets-engine", Attributes: map[string]any{"stage": "prod"}}, Context: map[string]any{"human_control": true, "service_actor_binding": taskexecution.DeclaredServiceBinding(), "execution_contract": map[string]any{"contract_version": "SECRETS-EXEC-20261011-v1", "operation": "metadata-apply-ccr", "execution_actor": "service:secrets-engine", "operation_deadline": now.Add(30 * time.Second).Format(time.RFC3339Nano)}, "approval": map[string]any{"caller_claim": "unchanged and untrusted in B1"}}}
}
func pilotEngine(t *testing.T) *decision.Engine {
t.Helper()
p, err := policy.LoadAndValidateFile(context.Background(), filepath.Join("..", "..", "examples", "secrets-engine-service-task-execution", "policy_package.md"))
if err != nil {
t.Fatal(err)
}
engine, err := decision.NewEngine(registry.NewStore(), p)
if err != nil {
t.Fatal(err)
}
return engine
}
func pilotToken(kind string, now time.Time) string {
payload := map[string]any{"case": kind, "exp": now.Add(2 * time.Minute).Unix()}
if kind == "expired" {
payload["exp"] = now.Add(-time.Second).Unix()
}
if kind == "no-expiry" {
delete(payload, "exp")
}
raw, _ := json.Marshal(payload)
return "synthetic." + base64.RawURLEncoding.EncodeToString(raw) + ".not-a-real-token"
}
func TestServicePilotRealHTTPAndTokenReviewSignedDeny(t *testing.T) {
now := time.Now().UTC()
var reviews atomic.Int64
reviewer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reviews.Add(1)
if r.URL.Path != "/apis/authentication.k8s.io/v1/tokenreviews" || r.Header.Get("Authorization") != "Bearer synthetic-reviewer" {
t.Error("unexpected reviewer route/auth")
w.WriteHeader(403)
return
}
var body struct {
Spec struct {
Token string `json:"token"`
Audiences []string `json:"audiences"`
} `json:"spec"`
}
if json.NewDecoder(r.Body).Decode(&body) != nil {
w.WriteHeader(400)
return
}
if !reflect.DeepEqual(body.Spec.Audiences, []string{"flex-auth", "access-engine"}) {
t.Error("unexpected bounded runtime audience request")
}
pieces := strings.Split(body.Spec.Token, ".")
raw, _ := base64.RawURLEncoding.DecodeString(pieces[1])
var claims map[string]any
json.Unmarshal(raw, &claims)
principal := taskexecution.ServicePilotPrincipal
audiences := []string{"flex-auth"}
authenticated := true
switch claims["case"] {
case "foreign":
principal = "system:serviceaccount:other:other"
case "alternate-audience":
audiences = []string{"access-engine"}
case "wrong-audience":
audiences = []string{"openbao"}
case "unauthenticated":
authenticated = false
}
json.NewEncoder(w).Encode(map[string]any{"status": map[string]any{"authenticated": authenticated, "audiences": audiences, "user": map[string]any{"username": principal}}})
}))
defer reviewer.Close()
folder := t.TempDir()
ca := filepath.Join(folder, "ca.crt")
reviewToken := filepath.Join(folder, "reviewer-token")
os.WriteFile(ca, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: reviewer.Certificate().Raw}), 0600)
os.WriteFile(reviewToken, []byte("synthetic-reviewer"), 0600)
engine := pilotEngine(t)
private := ed25519.NewKeyFromSeed(make([]byte, 32))
if engine.SetEnvelopeSigningKey(private, "synthetic-b1") != nil {
t.Fatal("signer config")
}
log := audit.NewJSONLDecisionLog(filepath.Join(folder, "decisions.jsonl"))
engine.SetDecisionLog(log)
bindings := map[string]string{"secrets-engine": taskexecution.ServicePilotPrincipal}
inputs, err := buildServicePilotInputs(engine, callerauth.ModeEnforce, "flex-auth", reviewer.URL, reviewToken, ca, bindings)
if err != nil {
t.Fatal(err)
}
server := httptest.NewServer(newServeMuxWithServicePilotInputs(engine, callerauth.Disabled(), inputs))
defer server.Close()
post := func(path string, request api.CheckRequest, token string) (int, []byte) {
raw, _ := json.Marshal(request)
req, _ := http.NewRequest("POST", server.URL+path, bytes.NewReader(raw))
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
response, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
body, _ := io.ReadAll(response.Body)
return response.StatusCode, body
}
original := pilotRequest(now)
status, body := post("/v1/check", original, pilotToken("valid", now))
if status != 200 {
t.Fatalf("status=%d", status)
}
var envelope api.DecisionEnvelope
if json.Unmarshal(body, &envelope) != nil {
t.Fatal("decode")
}
if envelope.Effect != api.DecisionEffectDeny || envelope.Reason != "service_execution_integration_unadmitted" {
t.Fatal("B1 changed policy gate")
}
if sign.Verify(envelope, map[string]ed25519.PublicKey{"synthetic-b1": private.Public().(ed25519.PublicKey)}) != nil {
t.Fatal("actual response signature")
}
if envelope.Binding.SubmittedRequestDigest != api.RequestDigest(original) || !reflect.DeepEqual(envelope.Binding.Context, original.Context) {
t.Fatal("original submitted request/context changed")
}
if envelope.Provenance.Caller == nil || envelope.Provenance.Caller.Mode != "enforce" || envelope.Provenance.Caller.Principal != taskexecution.ServicePilotPrincipal || envelope.Provenance.Caller.Audience != "flex-auth" {
t.Fatal("caller provenance")
}
records, err := log.ReadAll()
if err != nil || len(records) != 1 || !reflect.DeepEqual(records[0], envelope) {
t.Fatal("recorded signed response differs")
}
if reviews.Load() != 1 {
t.Fatal("request was reviewed more than once")
}
for _, kind := range []string{"foreign", "alternate-audience", "wrong-audience", "expired", "no-expiry", "unauthenticated"} {
t.Run(kind, func(t *testing.T) {
status, _ := post("/v1/check", pilotRequest(now), pilotToken(kind, now))
if status == 200 {
t.Fatal("invalid source caller accepted")
}
})
}
for _, mutate := range []func(*api.CheckRequest){func(r *api.CheckRequest) { r.Subject.ID = "other" }, func(r *api.CheckRequest) { r.Subject.Type = "human" }, func(r *api.CheckRequest) { r.Tenant = "other" }, func(r *api.CheckRequest) { r.Context["service_actor_binding"].(map[string]any)["extra"] = true }, func(r *api.CheckRequest) { r.Context["verified"] = true }, func(r *api.CheckRequest) { r.Context["delegation"] = true }, func(r *api.CheckRequest) {
r.Context["execution_contract"].(map[string]any)["operation_deadline"] = now.Add(5 * time.Minute).Format(time.RFC3339Nano)
}} {
request := pilotRequest(now)
mutate(&request)
if status, _ := post("/v1/check", request, pilotToken("valid", now)); status == 200 {
t.Fatal("invalid public request accepted")
}
}
if status, _ := post("/v1/check", pilotRequest(now), ""); status == 200 {
t.Fatal("missing caller accepted")
}
if status, _ := post("/v1/batch_check", pilotRequest(now), pilotToken("valid", now)); status != 403 {
t.Fatal("batch bypass")
}
records, _ = log.ReadAll()
if len(records) != 1 {
t.Fatal("refused request entered decision record")
}
// Exercise the actual production serve flag/configuration path in a disposable
// subprocess of this compiled test binary. Its environment contains only the
// synthetic argument handoff; no ambient backend credentials are inherited.
seedFile := filepath.Join(folder, "synthetic-seed")
os.WriteFile(seedFile, []byte(base64.RawURLEncoding.EncodeToString(make([]byte, 32))), 0440)
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
address := listener.Addr().String()
listener.Close()
registryPath, _ := filepath.Abs(filepath.Join("..", "..", "examples", "secrets-engine", "registry_snapshot.json"))
policyPath, _ := filepath.Abs(filepath.Join("..", "..", "examples", "secrets-engine-service-task-execution", "policy_package.md"))
cliRecord := filepath.Join(folder, "cli-records.jsonl")
cliArgs := []string{"serve", "--addr", address, "--registry", registryPath, "--policy", policyPath, "--log", cliRecord, "--caller-auth-mode", "enforce", "--caller-audience", "flex-auth", "--caller-kubernetes-url", reviewer.URL, "--caller-reviewer-token-file", reviewToken, "--caller-ca-file", ca, "--caller-binding", "secrets-engine=" + taskexecution.ServicePilotPrincipal, "--envelope-signing-key-file", seedFile, "--envelope-signing-kid", "synthetic-b1"}
argsJSON, _ := json.Marshal(cliArgs)
child := exec.Command(os.Args[0], "-test.run=^TestServicePilotChildCLI$")
child.Env = []string{"ACCESS_B1_SERVER_ARGS=" + string(argsJSON)}
child.Stdout = io.Discard
child.Stderr = io.Discard
if child.Start() != nil {
t.Fatal("start disposable CLI")
}
defer func() { child.Process.Kill(); child.Wait() }()
ready := false
for attempt := 0; attempt < 100; attempt++ {
resp, err := http.Get("http://" + address + "/healthz")
if err == nil {
resp.Body.Close()
ready = true
break
}
time.Sleep(20 * time.Millisecond)
}
if !ready {
t.Fatal("disposable production serve path not ready")
}
raw, _ := json.Marshal(original)
cliRequest, _ := http.NewRequest("POST", "http://"+address+"/v1/check", bytes.NewReader(raw))
cliRequest.Header.Set("Authorization", "Bearer "+pilotToken("valid", now))
cliResponse, err := http.DefaultClient.Do(cliRequest)
if err != nil {
t.Fatal(err)
}
defer cliResponse.Body.Close()
var cliEnvelope api.DecisionEnvelope
if cliResponse.StatusCode != 200 || json.NewDecoder(cliResponse.Body).Decode(&cliEnvelope) != nil {
t.Fatal("production serve path refused supported source input")
}
if cliEnvelope.Effect != api.DecisionEffectDeny || cliEnvelope.Reason != "service_execution_integration_unadmitted" || sign.Verify(cliEnvelope, map[string]ed25519.PublicKey{"synthetic-b1": private.Public().(ed25519.PublicKey)}) != nil {
t.Fatal("production CLI did not emit signed DENY")
}
if cliEnvelope.Binding.SubmittedRequestDigest != api.RequestDigest(original) || !reflect.DeepEqual(cliEnvelope.Binding.Context, original.Context) {
t.Fatal("production CLI changed original request/context")
}
cliRecords, err := audit.NewJSONLDecisionLog(cliRecord).ReadAll()
if err != nil || len(cliRecords) != 1 || !reflect.DeepEqual(cliRecords[0], cliEnvelope) {
t.Fatal("production CLI response/record differ")
}
for _, mode := range []callerauth.Mode{callerauth.ModeWarn, callerauth.ModeDisabled} {
if _, err := buildServicePilotInputs(engine, mode, "flex-auth", reviewer.URL, reviewToken, ca, bindings); err == nil {
t.Fatal("non-enforcing task mode accepted")
}
}
if _, err := buildServicePilotInputs(engine, callerauth.ModeEnforce, "access-engine", reviewer.URL, reviewToken, ca, bindings); err == nil {
t.Fatal("task pin renamed audience accepted")
}
missing := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/v1/check", bytes.NewReader(body))
newServeMuxWithCallerAuth(engine, callerauth.Disabled()).ServeHTTP(missing, req)
if missing.Code != 503 {
t.Fatal("unwired generic handler accepted task pin")
}
}
func TestServicePilotGenericEngineAndCLIBypassesRefuse(t *testing.T) {
engine := pilotEngine(t)
request := pilotRequest(time.Now())
if _, err := engine.Check(context.Background(), request); err == nil {
t.Fatal("generic Check bypass")
}
if _, err := engine.BatchCheck(context.Background(), api.BatchCheckRequest{}); err == nil {
t.Fatal("generic Batch bypass")
}
if _, err := engine.ListAllowed(context.Background(), decision.ListAllowedRequest{}); err == nil {
t.Fatal("generic List bypass")
}
if _, err := engine.CheckServicePilot(context.Background(), taskexecution.ServiceInput{}); err == nil {
t.Fatal("zero input trusted")
}
folder := t.TempDir()
path := filepath.Join(folder, "request.json")
raw, _ := json.Marshal(request)
os.WriteFile(path, raw, 0600)
var stdout, stderr bytes.Buffer
code := run([]string{"check", "--registry", filepath.Join("..", "..", "examples", "secrets-engine", "registry_snapshot.json"), "--policy", filepath.Join("..", "..", "examples", "secrets-engine-service-task-execution", "policy_package.md"), "--request", path}, &stdout, &stderr)
if code == 0 || stdout.Len() != 0 {
t.Fatal("generic CLI emitted task verdict")
}
}
// The earlier separately named positive fixture policy is not an exception:
// generic task actions cannot bypass the typed path on any loaded package.
func TestServicePilotSeparatelyNamedSyntheticAllowCannotBypass(t *testing.T) {
p, err := policy.LoadAndValidateFile(context.Background(), filepath.Join("..", "..", "examples", "secrets-engine-service-task-execution", "policy_package.md"))
if err != nil {
t.Fatal(err)
}
p.Metadata.ID = "synthetic-legacy-stack-positive-only"
p.RegoModule = "package " + p.Metadata.Package + "\ndefault decision := {\"effect\":\"allow\",\"reason\":\"synthetic_only\"}\n"
request := pilotRequest(time.Now())
result, err := p.Evaluate(context.Background(), request)
if err != nil || result.Effect != api.DecisionEffectAllow {
t.Fatal("synthetic precondition did not allow")
}
engine, err := decision.NewEngine(registry.NewStore(), p)
if err != nil {
t.Fatal(err)
}
if _, err := engine.Check(context.Background(), request); err == nil {
t.Fatal("separately named generic task allow escaped")
}
if _, err := engine.BatchCheck(context.Background(), api.BatchCheckRequest{Action: "execute-ccr"}); err == nil {
t.Fatal("generic task batch escaped")
}
if _, err := engine.ListAllowed(context.Background(), decision.ListAllowedRequest{Action: "execute-ccr"}); err == nil {
t.Fatal("generic task list escaped")
}
raw, _ := json.Marshal(request)
recorder := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/v1/check", bytes.NewReader(raw))
newServeMux(engine).ServeHTTP(recorder, req)
if recorder.Code != 503 {
t.Fatal("separate synthetic generic HTTP task route did not refuse")
}
}
func TestServicePilotWithholdsUnsignedOrAccidentalAllowBeforeRecord(t *testing.T) {
now := time.Now().UTC()
adapter, err := taskexecution.NewServicePilotInputs(fixedTokenReviewer{identity: callerauth.Identity{Username: taskexecution.ServicePilotPrincipal, Audiences: []string{"flex-auth"}, NotAfter: now.Add(time.Minute)}})
if err != nil {
t.Fatal(err)
}
input, err := adapter.Prepare(context.Background(), "Bearer synthetic", pilotRequest(now), now)
if err != nil {
t.Fatal(err)
}
for _, accidentalAllow := range []bool{false, true} {
t.Run(map[bool]string{false: "unsigned", true: "accidental-allow"}[accidentalAllow], func(t *testing.T) {
p, err := policy.LoadAndValidateFile(context.Background(), filepath.Join("..", "..", "examples", "secrets-engine-service-task-execution", "policy_package.md"))
if err != nil {
t.Fatal(err)
}
if accidentalAllow {
p.RegoModule = "package " + p.Metadata.Package + "\ndefault decision := {\"effect\":\"allow\",\"reason\":\"synthetic_only\"}\n"
}
engine, err := decision.NewEngine(registry.NewStore(), p)
if err != nil {
t.Fatal(err)
}
if accidentalAllow {
engine.SetEnvelopeSigningKey(ed25519.NewKeyFromSeed(make([]byte, 32)), "synthetic")
}
log := audit.NewJSONLDecisionLog(filepath.Join(t.TempDir(), "records.jsonl"))
engine.SetDecisionLog(log)
if _, err := engine.CheckServicePilot(context.Background(), input); err == nil {
t.Fatal("B1 released unsupported result")
}
records, err := log.ReadAll()
if err != nil || len(records) != 0 {
t.Fatal("withheld result entered recorder")
}
})
}
}
func TestServicePilotChildCLI(t *testing.T) {
raw := os.Getenv("ACCESS_B1_SERVER_ARGS")
if raw == "" {
return
}
var args []string
if json.Unmarshal([]byte(raw), &args) != nil {
os.Exit(2)
}
os.Exit(run(args, os.Stdout, os.Stderr))
}