766 lines
34 KiB
Diff
766 lines
34 KiB
Diff
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.
|