diff --git a/cmd/access-engine/main.go b/cmd/access-engine/main.go --- a/cmd/access-engine/main.go +++ b/cmd/access-engine/main.go @@ -22,6 +22,7 @@ "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 @@ 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 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 @@ 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 @@ 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 @@ -500,6 +532,22 @@ writeHTTP(w, decisions, err) }) 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) { @@ -615,7 +663,7 @@ 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 --- /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/internal/decision/engine.go b/internal/decision/engine.go --- a/internal/decision/engine.go +++ b/internal/decision/engine.go @@ -20,6 +20,7 @@ "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 @@ 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 @@ // 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 @@ // 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/service_input.go b/internal/taskexecution/service_input.go --- 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 } @@ -46,6 +53,44 @@ return r, nil } 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. diff --git a/internal/taskexecution/service_input_test.go b/internal/taskexecution/service_input_test.go --- 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,35 @@ 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" + "bytes" + "encoding/json":{"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/internal/taskexecution/identity.go b/internal/taskexecution/identity.go --- 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/docs/service-task-execution-contract.md b/docs/service-task-execution-contract.md --- a/docs/service-task-execution-contract.md +++ b/docs/service-task-execution-contract.md @@ -51,9 +51,32 @@ 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 @@ 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/workplans/FLEX-WP-0024-decision-envelope-authenticity.md b/workplans/FLEX-WP-0024-decision-envelope-authenticity.md --- a/workplans/FLEX-WP-0024-decision-envelope-authenticity.md +++ b/workplans/FLEX-WP-0024-decision-envelope-authenticity.md @@ -226,3 +226,14 @@ 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.