From 4f6946bbb338f61e4ee19efe32dcfe2757c22dcc Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 16 Oct 2026 16:25:55 +0200 Subject: [PATCH] Bind service task HTTP input to authenticated caller before evaluation Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a140c4-354a-7823-8f0d-8f717397f786 --- cmd/access-engine/main.go | 52 ++- cmd/access-engine/service_pilot_http_test.go | 376 ++++++++++++++++++ docs/service-task-execution-contract.md | 32 +- internal/decision/engine.go | 42 ++ internal/taskexecution/identity.go | 5 +- internal/taskexecution/service_input.go | 47 ++- internal/taskexecution/service_input_test.go | 32 ++ ...-WP-0024-decision-envelope-authenticity.md | 13 + 8 files changed, 590 insertions(+), 9 deletions(-) create mode 100644 cmd/access-engine/service_pilot_http_test.go diff --git a/cmd/access-engine/main.go b/cmd/access-engine/main.go index 194827c..b8a0ff9 100644 --- a/cmd/access-engine/main.go +++ b/cmd/access-engine/main.go @@ -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 diff --git a/cmd/access-engine/service_pilot_http_test.go b/cmd/access-engine/service_pilot_http_test.go new file mode 100644 index 0000000..c729e0d --- /dev/null +++ b/cmd/access-engine/service_pilot_http_test.go @@ -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)) +} diff --git a/docs/service-task-execution-contract.md b/docs/service-task-execution-contract.md index 2adbccd..c16a93d 100644 --- a/docs/service-task-execution-contract.md +++ b/docs/service-task-execution-contract.md @@ -51,9 +51,32 @@ trusted served approval, consume confirmation or operational entitlement. Maker tests use a synthetic TokenReviewer, not actual Kubernetes authentication. Actual deployed TokenReview origin/CA/RBAC/audience/projection rotation and mapping -admission must be proved before runtime use. The adapter is not wired into HTTP -routes, decision engine evaluation or a native executor; source package remains -hard-closed. A forged public service_actor_binding alone remains insufficient. +admission must be proved before runtime use. The B1 source path now wires the actual service adapter into `/v1/check` only +when the exact installed service-pilot v1 package is selected. Startup requires +enforced retained `flex-auth` audience and the one exact Secrets mapping. The +actual reviewer authenticates once; the detached ServiceInput is rechecked for +caller/operation expiry at use. The single existing decision engine records only +a signed DENY on this path; unsigned output or an accidental policy allow is +withheld before recording. No native executor or Approval fact join is installed. +A forged public service_actor_binding alone remains insufficient. + +Generic Check, BatchCheck, ListAllowed and CLI/HTTP evaluation cannot evaluate +`execute-ccr` under any package; the dedicated typed path is required. The exact +service pin also refuses generic calls and batch requests. This deliberately +invalidates the older separately named synthetic positive stack fixture on the +new source. Its positive proof remains historical at Access `ec21518e`, not proof +for B1. Do not add a fixture exception; a positive successor must use reviewed +B2/B3 provider joins. The literal service policy false gate is unchanged. + +B1 retains the exact original request context, carried claim, submitted digest +and producer-owned canonicalization. Its bounded strict JSON decoder refuses +duplicates, trailing/unknown typed fields and oversized input. The caller's +Approval blob remains unverified and cannot enable anything. B2 requires the +Approval owner's fact contract/vector review; B3 additionally requires exact +server-owned native-act representation and the complete trusted join. Fresh +provider observations stay out of band: no refreshed context or claim replacement +may relax strict Secrets request correspondence. Access never invokes a +credential-carrying Python subprocess, consumes approval or performs the effect. ## Exact operation and independent gates @@ -129,7 +152,8 @@ bind actual compiler bytes to the whole descriptor, enforce journal phase/order, admit grant/baseline/estate or perform an effect. Those are explicit implementation requirements; a matching predicate and reviewed TokenReview input are insufficient. The service adapter carries authenticated transport evidence outside the canonical -request but is not connected to HTTP evaluation/provider validation. A caller's +request. B1 connects it only to signed-DENY HTTP evaluation; provider validation and +executable eligibility remain absent. A caller's service_actor_binding remains a public declaration independently compared by the adapter, never a substitute for runtime provenance or exact operation authority. diff --git a/internal/decision/engine.go b/internal/decision/engine.go index 3bc524f..ae4951a 100644 --- a/internal/decision/engine.go +++ b/internal/decision/engine.go @@ -20,6 +20,7 @@ import ( "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" ) @@ -153,9 +154,44 @@ func (e *Engine) decide(ctx context.Context, request api.CheckRequest) (api.Deci return envelope, nil } +// IsServicePilot selects the existing exact source package, never a request flag. +func (e *Engine) IsServicePilot() bool { + return e.policy.Metadata.ID == taskexecution.ServicePilotPackageID && e.policy.Metadata.Version == taskexecution.ServicePilotPackageVersion +} + +// ErrServicePilotUnavailable withholds any result that is not this B1 slice's +// signed DENY. Provider joins and executable eligibility remain unimplemented. +var ErrServicePilotUnavailable = errors.New("service pilot source path unavailable") + +// CheckServicePilot records only a signed DENY after authenticating the exact +// immutable service input. It does not validate Approval facts or grant an act. +func (e *Engine) CheckServicePilot(ctx context.Context, input taskexecution.ServiceInput) (api.DecisionEnvelope, error) { + if !e.IsServicePilot() { + return api.DecisionEnvelope{}, ErrServicePilotUnavailable + } + request, err := input.RequestAt(e.now()) + if err != nil { + return api.DecisionEnvelope{}, err + } + envelope, err := e.decide(callerauth.WithRecord(ctx, input.Caller()), request) + if err != nil { + return api.DecisionEnvelope{}, err + } + if envelope.Effect != api.DecisionEffectDeny || envelope.Signature == nil || envelope.Signature.Mode != api.EnvelopeSignatureSigned { + return api.DecisionEnvelope{}, ErrServicePilotUnavailable + } + if err := e.recordDecisions([]api.DecisionEnvelope{envelope}); err != nil { + return api.DecisionEnvelope{}, err + } + return envelope, nil +} + // Check evaluates one subject/action/resource request. The decision is // released only after its required record commits (GH-DEC-2026-024). func (e *Engine) Check(ctx context.Context, request api.CheckRequest) (api.DecisionEnvelope, error) { + if e.IsServicePilot() || request.Action == "execute-ccr" { + return api.DecisionEnvelope{}, ErrServicePilotUnavailable + } decision, err := e.decide(ctx, request) if err != nil { return api.DecisionEnvelope{}, err @@ -169,6 +205,9 @@ func (e *Engine) Check(ctx context.Context, request api.CheckRequest) (api.Decis // BatchCheck evaluates one subject/action/context tuple against resources in // request order, and commits the whole batch in one durable write. func (e *Engine) BatchCheck(ctx context.Context, request api.BatchCheckRequest) ([]api.DecisionEnvelope, error) { + if e.IsServicePilot() || request.Action == "execute-ccr" { + return nil, ErrServicePilotUnavailable + } decisions := make([]api.DecisionEnvelope, 0, len(request.Resources)) for _, resource := range request.Resources { decision, err := e.decide(ctx, api.CheckRequest{ @@ -193,6 +232,9 @@ func (e *Engine) BatchCheck(ctx context.Context, request api.BatchCheckRequest) // ListAllowed evaluates candidate resources and returns only allow decisions. func (e *Engine) ListAllowed(ctx context.Context, request ListAllowedRequest) ([]api.DecisionEnvelope, error) { + if e.IsServicePilot() || request.Action == "execute-ccr" { + return nil, ErrServicePilotUnavailable + } candidates := e.store.ResourceRefs(request.System, request.ResourceType) decisions := make([]api.DecisionEnvelope, 0, len(candidates)) for _, resource := range candidates { diff --git a/internal/taskexecution/identity.go b/internal/taskexecution/identity.go index 5fca861..11bd8a1 100644 --- a/internal/taskexecution/identity.go +++ b/internal/taskexecution/identity.go @@ -1,5 +1,6 @@ -// Package taskexecution contains proposed source-only identity input validation. -// It is not registered with HTTP evaluation and grants no executable admission. +// Package taskexecution contains task input validation, not policy evaluation. +// The human component remains source-only. The B1 service HTTP path can emit +// only signed DENY and grants no executable admission. package taskexecution import ( diff --git a/internal/taskexecution/service_input.go b/internal/taskexecution/service_input.go index 363e3e7..300d856 100644 --- a/internal/taskexecution/service_input.go +++ b/internal/taskexecution/service_input.go @@ -1,20 +1,27 @@ package taskexecution import ( + "bytes" "context" "encoding/json" "errors" "github.com/netkingdom/access-engine/internal/callerauth" "github.com/netkingdom/access-engine/pkg/api" + "io" "time" ) var ErrServiceInput = errors.New("service task execution input refused") +const ServicePilotPackageID = "secrets-engine.task-execution.service-pilot" +const ServicePilotPackageVersion = "v1" + const ServicePilotMappingVersion = "proposed-service-task-caller/v1" const ServicePilotPrincipal = "system:serviceaccount:secrets-engine:secrets-engine" -// ServicePilotInputs is source preparation only. It installs the explicit +// ServicePilotInputs installs the exact B1 service input mapping for signed DENY. +// It does not validate Approval facts or establish executable eligibility. +// It installs the explicit // proposed fixed subject-to-transport mapping, never a request-selected mapping. // No constructor parameter can select warn/disabled mode or another subject. type ServicePilotInputs struct{ auth *callerauth.Authenticator } @@ -47,6 +54,44 @@ func (s ServiceInput) Request() (api.CheckRequest, error) { } func (s ServiceInput) Caller() callerauth.Record { return s.caller } +// RequestAt rechecks transport and operation lifetime at the runtime use site. +// Request() remains a detached source snapshot, never an execution capability. +func (s ServiceInput) RequestAt(now time.Time) (api.CheckRequest, error) { + r, err := s.Request() + if err != nil || now.IsZero() || s.caller.Audience != "flex-auth" || !s.caller.NotAfter.After(now) { + return api.CheckRequest{}, ErrServiceInput + } + contract, ok := r.Context["execution_contract"].(map[string]any) + if !ok { + return api.CheckRequest{}, ErrServiceInput + } + value, ok := contract["operation_deadline"].(string) + if !ok { + return api.CheckRequest{}, ErrServiceInput + } + deadline, err := time.Parse(time.RFC3339Nano, value) + if err != nil || !deadline.After(now) || deadline.After(s.caller.NotAfter) { + return api.CheckRequest{}, ErrServiceInput + } + return r, nil +} + +// DecodeServiceRequest reuses the owner strict JSON scanner for this bounded +// task-only route. It never changes producer canonicalization or legacy input. +func DecodeServiceRequest(reader io.Reader) (api.CheckRequest, error) { + raw, err := io.ReadAll(io.LimitReader(reader, 524289)) + var request api.CheckRequest + if err != nil || len(raw) > 524288 || strictObject(raw, &request) != nil { + return request, ErrServiceInput + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if decoder.Decode(&request) != nil { + return api.CheckRequest{}, ErrServiceInput + } + return request, nil +} + // DeclaredServiceBinding is public immutable metadata to bind at approval issue. // It is not authentication evidence; Prepare independently verifies its principal. func DeclaredServiceBinding() map[string]any { diff --git a/internal/taskexecution/service_input_test.go b/internal/taskexecution/service_input_test.go index f7eee86..a9d39b6 100644 --- a/internal/taskexecution/service_input_test.go +++ b/internal/taskexecution/service_input_test.go @@ -1,7 +1,9 @@ package taskexecution import ( + "bytes" "context" + "encoding/json" "github.com/netkingdom/access-engine/internal/callerauth" "github.com/netkingdom/access-engine/pkg/api" "testing" @@ -79,3 +81,33 @@ func TestServicePilotRefusals(t *testing.T) { t.Fatal("missing owner reviewer accepted") } } + +func TestServiceInputRuntimeRecheckAndStrictHTTPDecode(t *testing.T) { + now := time.Now().UTC() + r := serviceRequest(t, now) + reviewer := &serviceReviewer{identity: callerauth.Identity{Username: ServicePilotPrincipal, Audiences: []string{"flex-auth"}, NotAfter: now.Add(120 * time.Second)}} + adapter, _ := NewServicePilotInputs(reviewer) + input, err := adapter.Prepare(context.Background(), "Bearer synthetic", r, now) + if err != nil { + t.Fatal(err) + } + if _, err := input.RequestAt(now); err != nil { + t.Fatal(err) + } + for _, when := range []time.Time{time.Time{}, now.Add(60 * time.Second), now.Add(120 * time.Second)} { + if _, err := input.RequestAt(when); err == nil { + t.Fatal("stale/invalid runtime input accepted") + } + } + raw, _ := json.Marshal(r) + decoded, err := DecodeServiceRequest(bytes.NewReader(raw)) + if err != nil || api.RequestDigest(decoded) != api.RequestDigest(r) { + t.Fatal("valid decode changed canonical submitted input") + } + bad := [][]byte{append(append([]byte{}, raw...), []byte(" {}")...), []byte(`{"action":"execute-ccr","action":"apply"}`), []byte(`{"context":{"human_control":true,"human_control":false}}`), []byte(`{"unknown_task_flag":true}`), bytes.Repeat([]byte(" "), 524289)} + for _, body := range bad { + if _, err := DecodeServiceRequest(bytes.NewReader(body)); err == nil { + t.Fatal("malformed/oversized task request accepted") + } + } +} diff --git a/workplans/FLEX-WP-0024-decision-envelope-authenticity.md b/workplans/FLEX-WP-0024-decision-envelope-authenticity.md index a26b4f9..c31ff92 100644 --- a/workplans/FLEX-WP-0024-decision-envelope-authenticity.md +++ b/workplans/FLEX-WP-0024-decision-envelope-authenticity.md @@ -170,6 +170,7 @@ that observation applied to the artifact the standard regulates. id: FLEX-WP-0024-T05 status: done priority: high +state_hub_task_id: "cfe4d275-9f02-5090-8391-06eea59d45c5" ``` 2026-10-16 source follow-through: NetKingdom target Secrets Engine R2 owner review @@ -186,6 +187,7 @@ This is source preparation only, not signer admission or deployed acceptance. id: FLEX-WP-0024-T06 status: progress priority: high +state_hub_task_id: "7f785b52-bf62-5884-8d34-670d70bc9023" ``` Wait on Platform/OpenBao key custody, independent public-key and issuer/policy-pin @@ -226,3 +228,14 @@ revision, and adds offline native signer receipt verification using the existing Secrets verifier. No key, trust, image publication or live activation is admitted. The managed workload's missing rapp declaration remains explicit migration debt; this source binding does not fabricate a package declaration or raise its grade. + + +2026-10-16 separate B1 source follow-through wires ServicePilotInputs through actual +HTTP/TokenReview into a signed-DENY-only engine entry. Generic `execute-ccr` calls +refuse across every policy, including the old separately named positive fixture; +that proof stays pinned to ec21518e. Original context/submitted digest stay unchanged. +Actual disposable serve/TLS TokenReview/HTTP signed-DENY and bypass/refusal tests are +source evidence only. B2 depends on independently reviewed Approval fact contract +and canonical vectors; B3 additionally needs exact source/normalization numeric +representation and full trusted provider joins. No current policy false gate, +production trust, native deployment or protected effect is enabled by B1.