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