the-custodian/docs/changes/netkingdom-target-2026-10-16/access-native-source/b2-b3/trusted-source-over-b1-r2.patch
codex e19bcd1f3d
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Reject generated-only commits / generated-commit-guard (push) Successful in 13s
docs: preserve reviewed NetKingdom session artifacts
2026-11-01 22:42:33 +01:00

2332 lines
90 KiB
Diff

diff --git a/.forgejo/workflows/image.yaml b/.forgejo/workflows/image.yaml
--- a/.forgejo/workflows/image.yaml
+++ b/.forgejo/workflows/image.yaml
@@ -19,6 +19,7 @@
- "cmd/**"
- "internal/**"
- "pkg/**"
+ - "third_party/**"
- "examples/**"
- "go.mod"
- "go.sum"
diff --git a/Containerfile b/Containerfile
--- a/Containerfile
+++ b/Containerfile
@@ -1,6 +1,8 @@
FROM golang:1.26.7-alpine@sha256:28d89ee9cc0ff9fec75c82ca201e6bf7fdf9a679d4b7b24dfa04f2bb766bb468 AS build
WORKDIR /src
COPY go.mod go.sum ./
+# Portable, unchanged Approval-owner pure data helper; available before download.
+COPY third_party/approvalfact ./third_party/approvalfact
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/access-engine ./cmd/access-engine && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/flex-auth ./cmd/flex-auth
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
@@ -333,6 +333,7 @@
logPath := fs.String("log", "", "optional JSONL decision log path")
signingKeyFile := fs.String("envelope-signing-key-file", "", "owner-mounted base64url Ed25519 seed file")
signingKID := fs.String("envelope-signing-kid", "", "independently admitted envelope signing key id")
+ approvalSourceFile := fs.String("approval-source-file", "", "owner fixed Approval HTTPS reader configuration file")
outboxDir := fs.String("outbox-dir", "", "durable emission outbox directory (FLEX-WP-0031); exclusive with --log")
emissionSource := fs.String("emission-source", "", "audit-core source name for emitted decision events, e.g. flex-auth.tenant-engine")
auditURL := fs.String("audit-url", "", "admitted audit-core /v1/events endpoint; empty disables drain and heartbeats")
@@ -402,7 +403,19 @@
if err != nil {
return fail(stderr, err)
}
- mux := newServeMuxWithServicePilotInputs(engine, authenticator, serviceInputs)
+ var approvalReader *taskexecution.ApprovalReader
+ if engine.IsTrustedServicePilot() {
+ if *signingKeyFile == "" || *approvalSourceFile == "" {
+ return fail(stderr, fmt.Errorf("trusted task source requires signing and fixed Approval reader configuration"))
+ }
+ approvalReader, err = taskexecution.LoadApprovalReader(*approvalSourceFile)
+ if err != nil {
+ return fail(stderr, err)
+ }
+ } else if *approvalSourceFile != "" {
+ return fail(stderr, fmt.Errorf("Approval reader requires exact trusted task source package"))
+ }
+ mux := newServeMuxWithTaskFacts(engine, authenticator, serviceInputs, approvalReader)
if outbox != nil {
mountEmission(mux, outbox)
}
@@ -457,6 +470,9 @@
}
func newServeMuxWithServicePilotInputs(engine *decisioncore.Engine, authenticator *callerauth.Authenticator, serviceInputs *taskexecution.ServicePilotInputs) *http.ServeMux {
+ return newServeMuxWithTaskFacts(engine, authenticator, serviceInputs, nil)
+}
+func newServeMuxWithTaskFacts(engine *decisioncore.Engine, authenticator *callerauth.Authenticator, serviceInputs *taskexecution.ServicePilotInputs, approvalReader *taskexecution.ApprovalReader) *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("content-type", "application/json")
@@ -467,12 +483,16 @@
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
- if engine.IsServicePilot() {
+ if engine.IsTaskPin() {
if serviceInputs == nil {
http.Error(w, "service input unavailable", http.StatusServiceUnavailable)
return
}
- request, err := taskexecution.DecodeServiceRequest(r.Body)
+ decode := taskexecution.DecodeServiceRequest
+ if engine.IsTrustedServicePilot() {
+ decode = taskexecution.DecodeTrustedServiceRequest
+ }
+ request, err := decode(r.Body)
if err != nil || request.Purpose != "" {
http.Error(w, "invalid service task request", http.StatusBadRequest)
return
@@ -482,7 +502,12 @@
http.Error(w, "service task input refused", http.StatusForbidden)
return
}
- decision, err := engine.CheckServicePilot(r.Context(), input)
+ var decision api.DecisionEnvelope
+ if engine.IsTrustedServicePilot() {
+ decision, err = engine.CheckTrustedService(r.Context(), input, approvalReader)
+ } else {
+ decision, err = engine.CheckServicePilot(r.Context(), input)
+ }
writeHTTP(w, decision, err)
return
}
@@ -506,7 +531,7 @@
writeHTTP(w, decision, err)
})
mux.HandleFunc("/v1/batch_check", func(w http.ResponseWriter, r *http.Request) {
- if engine.IsServicePilot() {
+ if engine.IsTaskPin() {
http.Error(w, "service task batch unavailable", http.StatusForbidden)
return
}
@@ -537,7 +562,7 @@
// 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() {
+ if !engine.IsTaskPin() {
return nil, nil
}
if mode != callerauth.ModeEnforce || audience != "flex-auth" || len(bindings) != 1 || bindings["secrets-engine"] != taskexecution.ServicePilotPrincipal {
@@ -546,6 +571,9 @@
reviewer, err := callerauth.NewKubernetesTokenReviewer(kubernetesURL, audience, reviewerTokenFile, caFile)
if err != nil {
return nil, err
+ }
+ if engine.IsTrustedServicePilot() {
+ return taskexecution.NewTrustedServiceInputs(reviewer)
}
return taskexecution.NewServicePilotInputs(reviewer)
}
@@ -575,11 +603,15 @@
}
func buildEngine(ctx context.Context, registryPath, policyPath, logPath string) (*decisioncore.Engine, error) {
- store, err := registry.LoadFile(registryPath)
+ pkg, err := policy.LoadAndValidateFile(ctx, policyPath)
if err != nil {
return nil, err
}
- pkg, err := policy.LoadAndValidateFile(ctx, policyPath)
+ load := registry.LoadFile
+ if pkg.Metadata.ID == taskexecution.ServicePilotPackageID && pkg.Metadata.Version == taskexecution.TrustedServicePilotPackageVersion {
+ load = registry.LoadFilePreservingNumbers
+ }
+ store, err := load(registryPath)
if err != nil {
return nil, err
}
diff --git a/cmd/access-engine/trusted_service_http_test.go b/cmd/access-engine/trusted_service_http_test.go
new file mode 100644
--- /dev/null
+++ b/cmd/access-engine/trusted_service_http_test.go
@@ -0,0 +1,330 @@
+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"
+ "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"
+ "github.com/netkingdom/approval-engine/adapters/go/approvalfact"
+)
+
+func trustedRequest(t *testing.T, now time.Time) api.CheckRequest {
+ t.Helper()
+ r := pilotRequest(now)
+ r.Subject.Tenant = r.Tenant
+ r.Resource.Tenant = r.Tenant
+ r.Resource.Attributes["numeric_fixture"] = json.Number("1.0")
+ c := r.Context["execution_contract"].(map[string]any)
+ c["operation_id"] = "11111111-2222-4333-8444-555555555555"
+ c["ccr_id"] = "synthetic-change"
+ c["execution_grant_ccr_id"] = "synthetic-grant"
+ hash := "sha256:" + strings.Repeat("1", 64)
+ for _, key := range []string{"ccr_immutable_sha256", "grant_immutable_sha256", "catalog_sha256", "expected_before", "expected_after"} {
+ c[key] = hash
+ }
+ c["backend_budget"] = map[string]any{"ttl_seconds": json.Number("60"), "uses": json.Number("12")}
+ var ops []any
+ for i, method := range []string{"GET", "PUT", "GET", "GET", "PUT", "GET"} {
+ path := "sys/policies/acl/synthetic"
+ if i >= 3 {
+ path = "auth/synthetic/role/synthetic"
+ }
+ ops = append(ops, map[string]any{"method": method, "path": path, "payload_sha256": hash})
+ }
+ c["operations"] = ops
+ delete(r.Context, "approval")
+ raw, _ := json.Marshal(map[string]any{"action": r.Action, "actor": r.Subject.ID, "principal": "secrets-engine", "purpose": "synthetic source-only proof", "target": r.Resource})
+ r.Context["purpose"] = "synthetic source-only proof"
+ _, digest, err := approvalfact.CanonicalBinding(raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ bindingValue, err := approvalfact.DecodeJSON(raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ binding := bindingValue.(map[string]any)
+ // PDP profile intentionally retains the published legacy numeric projection.
+ encoded, _ := json.Marshal(r)
+ var legacy api.CheckRequest
+ json.Unmarshal(encoded, &legacy)
+ binding["digest"] = digest
+ binding["pdp_digest"] = api.ApprovalBindingDigest(legacy)
+ binding["pdp_path"] = true
+ binding["human_control"] = true
+ r.Context["approval"] = map[string]any{"schema_version": "0.1", "kind": "approval-claim", "issuer": "approval-engine", "approval_id": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", "state": "valid", "valid_now": true, "consumed": false, "reason_code": "ok", "binding": binding, "freshness": map[string]any{"observed_at": now.Add(-time.Second).Format(time.RFC3339Nano), "ttl_seconds": json.Number("60"), "not_after": now.Add(59 * time.Second).Format(time.RFC3339Nano)}, "validity": map[string]any{"not_before": now.Add(-time.Hour).Format(time.RFC3339Nano), "expires_at": now.Add(time.Hour).Format(time.RFC3339Nano)}}
+ return r
+}
+func TestTrustedServiceActualHTTPPositiveAndRefusals(t *testing.T) {
+ now := time.Now().UTC()
+ folder := t.TempDir()
+ reviewer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/apis/authentication.k8s.io/v1/tokenreviews" || r.Header.Get("Authorization") != "Bearer synthetic-reviewer" {
+ w.WriteHeader(403)
+ return
+ }
+ json.NewEncoder(w).Encode(map[string]any{"status": map[string]any{"authenticated": true, "audiences": []string{"flex-auth"}, "user": map[string]any{"username": taskexecution.ServicePilotPrincipal}}})
+ }))
+ defer reviewer.Close()
+ ca := filepath.Join(folder, "review-ca")
+ token := filepath.Join(folder, "review-token")
+ os.WriteFile(ca, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: reviewer.Certificate().Raw}), 0600)
+ os.WriteFile(token, []byte("synthetic-reviewer"), 0600)
+ original := trustedRequest(t, now)
+ claimRaw, _ := json.Marshal(original.Context["approval"])
+ mode := "ok"
+ reads := 0
+ provider := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ reads++
+ if r.URL.Path != "/v1/approvals/aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee/claim" || r.Header.Get("Authorization") != "Bearer synthetic-approval-reader" {
+ w.WriteHeader(403)
+ return
+ }
+ if mode == "redirect" {
+ http.Redirect(w, r, reviewer.URL, 302)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ switch mode {
+ case "duplicate":
+ io.WriteString(w, `{"state":"valid","state":"invalid"}`)
+ case "changed":
+ v, _ := approvalfact.DecodeJSON(claimRaw)
+ v.(map[string]any)["consumed"] = true
+ json.NewEncoder(w).Encode(v)
+ case "expired":
+ v, _ := approvalfact.DecodeJSON(claimRaw)
+ v.(map[string]any)["freshness"].(map[string]any)["not_after"] = now.Add(-time.Second).Format(time.RFC3339Nano)
+ json.NewEncoder(w).Encode(v)
+ default:
+ w.Write(claimRaw)
+ }
+ }))
+ defer provider.Close()
+ pca := filepath.Join(folder, "approval-ca")
+ ptoken := filepath.Join(folder, "approval-token")
+ os.WriteFile(pca, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: provider.Certificate().Raw}), 0600)
+ os.WriteFile(ptoken, []byte("synthetic-approval-reader"), 0440)
+ source, err := taskexecution.NewApprovalReader(taskexecution.ApprovalSourceConfig{SourceID: "synthetic-approval", SourceRevision: "test-source-revision", Origin: provider.URL, StoreTenant: "tenant:platform", CAFile: pca, TokenFile: ptoken})
+ if err != nil {
+ t.Fatal(err)
+ }
+ pkg, err := policy.LoadAndValidateFile(context.Background(), filepath.Join("..", "..", "examples", "secrets-engine-service-task-execution-v2", "policy_package.md"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ engine, err := decision.NewEngine(registry.NewStore(), pkg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ key := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{19}, 32))
+ engine.SetEnvelopeSigningKey(key, "synthetic-v2")
+ record := filepath.Join(folder, "record.jsonl")
+ engine.SetDecisionLog(audit.NewJSONLDecisionLog(record))
+ inputs, err := buildServicePilotInputs(engine, callerauth.ModeEnforce, "flex-auth", reviewer.URL, token, ca, map[string]string{"secrets-engine": taskexecution.ServicePilotPrincipal})
+ if err != nil {
+ t.Fatal(err)
+ }
+ server := httptest.NewServer(newServeMuxWithTaskFacts(engine, callerauth.Disabled(), inputs, source))
+ defer server.Close()
+ post := func(request api.CheckRequest) (int, []byte) {
+ t.Helper()
+ raw, _ := json.Marshal(request)
+ req, _ := http.NewRequest("POST", server.URL+"/v1/check", bytes.NewReader(raw))
+ req.Header.Set("Authorization", "Bearer "+pilotToken("valid", now))
+ 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
+ }
+ status, body := post(original)
+ if status != 200 {
+ t.Fatalf("positive status %d: %s", status, body)
+ }
+ envelope, err := sign.DecodeEnvelope(body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Logf("actual HTTP source policy pin: %s %s %s", pkg.Metadata.ID, pkg.Metadata.Version, pkg.Digest())
+ if envelope.Effect != api.DecisionEffectAllow || sign.Verify(envelope, map[string]ed25519.PublicKey{"synthetic-v2": key.Public().(ed25519.PublicKey)}) != nil {
+ t.Fatalf("not signed ALLOW: %s", body)
+ }
+ if envelope.Provenance.ApprovalObservation == nil || envelope.Provenance.ApprovalObservation.SourceID != "synthetic-approval" || reads != 1 {
+ t.Fatal("missing authenticated observation provenance")
+ }
+ legacyRaw, _ := json.Marshal(original)
+ var legacyRequest api.CheckRequest
+ json.Unmarshal(legacyRaw, &legacyRequest)
+ if envelope.Binding.SubmittedRequestDigest != api.RequestDigest(legacyRequest) || envelope.Binding.RequestDigest != api.RequestDigest(legacyRequest) || envelope.Binding.ApprovalBindingDigest != api.ApprovalBindingDigest(legacyRequest) {
+ t.Fatal("published original/full/approval-free digest profile changed")
+ }
+ if !reflect.DeepEqual(envelope.Binding.Context, original.Context) {
+ t.Fatal("original context changed")
+ }
+ if envelope.Binding.Resource.Attributes["numeric_fixture"] != json.Number("1.0") {
+ t.Fatal("native numeric kind lost")
+ }
+ recorded, err := os.ReadFile(record)
+ if err != nil {
+ t.Fatal(err)
+ }
+ recordEnvelope, err := sign.DecodeEnvelope(bytes.TrimSpace(recorded))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(recordEnvelope, envelope) {
+ t.Fatal("returned/recorded mismatch")
+ }
+ for _, failure := range []string{"redirect", "duplicate", "changed", "expired"} {
+ t.Run(failure, func(t *testing.T) {
+ mode = failure
+ status, _ := post(original)
+ if status == 200 {
+ t.Fatal("bad source admitted")
+ }
+ })
+ }
+ mode = "ok"
+ t.Run("original-stale", func(t *testing.T) {
+ raw, _ := json.Marshal(original)
+ r, err := taskexecution.DecodeTrustedServiceRequest(bytes.NewReader(raw))
+ if err != nil {
+ t.Fatal(err)
+ }
+ fresh := r.Context["approval"].(map[string]any)["freshness"].(map[string]any)
+ fresh["observed_at"] = now.Add(-61 * time.Second).Format(time.RFC3339Nano)
+ fresh["not_after"] = now.Add(-time.Second).Format(time.RFC3339Nano)
+ status, _ := post(r)
+ if status == 200 {
+ t.Fatal("fresh provider rescued stale original")
+ }
+ })
+ t.Run("native-number-kind", func(t *testing.T) {
+ raw, _ := json.Marshal(original)
+ r, _ := taskexecution.DecodeTrustedServiceRequest(bytes.NewReader(raw))
+ r.Resource.Attributes["numeric_fixture"] = json.Number("1")
+ status, _ := post(r)
+ if status == 200 {
+ t.Fatal("changed native integer/float authority accepted")
+ }
+ })
+ after, _ := os.ReadFile(record)
+ if !bytes.Equal(after, recorded) {
+ t.Fatal("refused calls recorded executable envelopes")
+ }
+ // Exercise actual production CLI configuration with no ambient credentials.
+ seedPath := filepath.Join(folder, "cli-seed")
+ os.WriteFile(seedPath, []byte(base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{19}, 32))), 0440)
+ sourcePath := filepath.Join(folder, "approval-source.json")
+ sourceJSON, _ := json.Marshal(taskexecution.ApprovalSourceConfig{SourceID: "synthetic-approval", SourceRevision: "test-source-revision", Origin: provider.URL, StoreTenant: "tenant:platform", CAFile: pca, TokenFile: ptoken})
+ os.WriteFile(sourcePath, sourceJSON, 0600)
+ registryPath := filepath.Join(folder, "registry.json")
+ os.WriteFile(registryPath, []byte(`{}`), 0600)
+ policyPath, _ := filepath.Abs(filepath.Join("..", "..", "examples", "secrets-engine-service-task-execution-v2", "policy_package.md"))
+ cliRecord := filepath.Join(folder, "cli-record.jsonl")
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ address := listener.Addr().String()
+ listener.Close()
+ args := []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", token, "--caller-ca-file", ca, "--caller-binding", "secrets-engine=" + taskexecution.ServicePilotPrincipal, "--envelope-signing-key-file", seedPath, "--envelope-signing-kid", "synthetic-v2", "--approval-source-file", sourcePath}
+ argsJSON, _ := json.Marshal(args)
+ 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 err := child.Start(); err != nil {
+ t.Fatal(err)
+ }
+ defer func() { child.Process.Kill(); child.Wait() }()
+ ready := false
+ for attempt := 0; attempt < 100; attempt++ {
+ response, err := http.Get("http://" + address + "/healthz")
+ if err == nil {
+ response.Body.Close()
+ ready = true
+ break
+ }
+ time.Sleep(20 * time.Millisecond)
+ }
+ if !ready {
+ t.Fatal("trusted CLI not ready")
+ }
+ raw, _ := json.Marshal(original)
+ req, _ := http.NewRequest("POST", "http://"+address+"/v1/check", bytes.NewReader(raw))
+ req.Header.Set("Authorization", "Bearer "+pilotToken("valid", now))
+ response, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer response.Body.Close()
+ wire, _ := io.ReadAll(response.Body)
+ cliEnvelope, err := sign.DecodeEnvelope(wire)
+ if response.StatusCode != 200 || err != nil || cliEnvelope.Effect != api.DecisionEffectAllow || sign.Verify(cliEnvelope, map[string]ed25519.PublicKey{"synthetic-v2": key.Public().(ed25519.PublicKey)}) != nil {
+ t.Fatalf("CLI did not return signed trusted ALLOW: %s", wire)
+ }
+ cliRecords, err := audit.NewJSONLDecisionLog(cliRecord).ReadAll()
+ if err != nil || len(cliRecords) != 1 || !reflect.DeepEqual(cliRecords[0], cliEnvelope) {
+ t.Fatal("CLI response/readback record mismatch")
+ }
+ if !reflect.DeepEqual(cliEnvelope.Binding.Context, original.Context) {
+ t.Fatal("CLI changed original numeric context")
+ }
+
+}
+
+func TestTrustedServiceMissingAndGenericAdmissionRefuse(t *testing.T) {
+ pkg, err := policy.LoadAndValidateFile(context.Background(), filepath.Join("..", "..", "examples", "secrets-engine-service-task-execution-v2", "policy_package.md"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ engine, err := decision.NewEngine(registry.NewStore(), pkg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ request := trustedRequest(t, time.Now().UTC())
+ if _, err := engine.Check(context.Background(), request); err == nil {
+ t.Fatal("generic check bypass")
+ }
+ if _, err := engine.BatchCheck(context.Background(), api.BatchCheckRequest{Action: "execute-ccr"}); err == nil {
+ t.Fatal("generic batch bypass")
+ }
+ if _, err := engine.ListAllowed(context.Background(), decision.ListAllowedRequest{Action: "execute-ccr"}); err == nil {
+ t.Fatal("generic list bypass")
+ }
+ if _, err := engine.CheckTrustedService(context.Background(), taskexecution.ServiceInput{}, nil); err == nil {
+ t.Fatal("missing typed input and fixed source accepted")
+ }
+ recorder := httptest.NewRecorder()
+ raw, _ := json.Marshal(request)
+ newServeMux(engine).ServeHTTP(recorder, httptest.NewRequest("POST", "/v1/check", bytes.NewReader(raw)))
+ if recorder.Code != 503 {
+ t.Fatal("unwired typed path accepted")
+ }
+}
diff --git a/docs/trusted-service-task-source.md b/docs/trusted-service-task-source.md
new file mode 100644
--- /dev/null
+++ b/docs/trusted-service-task-source.md
@@ -0,0 +1,62 @@
+# Trusted service task source proposal (v2)
+
+The separate `secrets-engine.task-execution.service-pilot` v2 source package can
+return ALLOW only through the existing `/v1/check` typed task path. The original
+v1 package remains unchanged and DENY-only. No chart, runtime, source identity,
+credential custody or native execution admission is enabled by these sources.
+
+The server requires enforced TokenReview, audience `flex-auth`, the exact retained
+Secrets service-account mapping, mounted envelope signing configuration and an
+owner-installed `--approval-source-file`. This public configuration contains
+`source_id`, `source_revision`, fixed HTTPS `origin`, `store_tenant` exactly
+`tenant:platform`, absolute `ca_file` and absolute mounted `token_file`. The
+credential must independently carry existing Approval `approval:read` authority
+for that store. Source labels describe the owner-admitted configuration; they do
+not perform its admission. No key or token value belongs in the configuration.
+
+The reader sends one authenticated GET to the fixed
+`/v1/approvals/{UUID}/claim` endpoint, accepts only bounded strict JSON, uses only
+the configured CA, refuses redirects and environmental proxies, and has a total
+three-second HTTP timeout. It neither creates nor approves nor consumes. Mounted
+bearer files must be regular, bounded and have no group write or other access;
+0440 is supported. Public configuration/CA files also refuse group/other writes.
+Errors do not include response bodies, credentials or transport diagnostics.
+
+The unchanged Approval-owner pure data helper is copied with exact source pins
+under `third_party/approvalfact`. The fixed reader establishes provider origin;
+the helper establishes schema and semantic correspondence. Both original and
+fresh claims must independently be currently valid, unconsumed, human-controlled
+PDP-path claims for the exact UUID, native five-field act and Access approval-free
+request digest. Nonfreshness authority must match exactly. The freshly fetched
+claim never replaces the submitted claim/context or renews original freshness.
+
+The native act derives from the normalized subject/resource, fixed service
+principal, action and nonempty `context.purpose`. The HTTP decoder and task registry
+loader preserve raw JSON numeric representations. Registry facts loaded through
+the old lossy profile cannot enter the trusted path. Only the Approval-owner
+helper canonicalizes native act data. Existing PDP full/submitted/approval-free
+digests retain their published numeric profile through a digest-only legacy
+projection; the signed structured binding retains the original numeric kinds.
+Inputs unsupported by that existing PDP profile refuse; this proposal does not
+weaken any consumer numeric admission or invent numeric restrictions.
+
+The same existing policy evaluator decides after those joins. Generic check,
+batch and list entry points refuse task execution across all policy names. No
+request-selected verified flag, provider origin or policy enable switch bypasses
+the typed path. Missing approval can receive signed DENY for preapproval binding;
+ALLOW requires authenticated matching original/fresh claims. Its expiry is capped
+by policy TTL, caller expiry, operation deadline and both claims' freshness and
+validity, rechecked after I/O/evaluation. Existing source-owned signing covers the
+original binding and new `provenance.approval_observation` receipt. The receipt
+contains source/configuration identity, exact fetched-byte digest, native digest
+and observation/validation/deadline timestamps. JSONL readback and outbox replay
+preserve producer numeric signatures; no second signing algorithm is introduced.
+
+Packaging must copy the local owner helper before `go mod download`. Existing
+ordinary image CI triggers include `third_party/**` so a helper update cannot
+silently leave the old image. No build publication is invoked by this preparation.
+Rollback selects the unchanged v1 package or removes the v2 admission; generic
+fallback remains refused. Rotating/withdrawing source or signing trust is a
+separate owner-admitted runtime operation. The earlier Platform positive proof
+remains evidence only at its old ec21518e Access pin; its generic synthetic policy
+is deliberately incompatible with the successor guarded source.
diff --git a/examples/secrets-engine-service-task-execution-v2/policy_fixtures.yaml b/examples/secrets-engine-service-task-execution-v2/policy_fixtures.yaml
new file mode 100644
--- /dev/null
+++ b/examples/secrets-engine-service-task-execution-v2/policy_fixtures.yaml
@@ -0,0 +1,8 @@
+- id: unsigned-service-caller-not-authority
+ request:
+ tenant: tenant:platform
+ subject: {id: secrets-engine, type: service}
+ action: execute-ccr
+ resource: {id: catalog:test, type: secret-catalog-lane, system: secrets-engine}
+ context: {human_control: true, runtime_admission: true, verified: true}
+ expect: {effect: deny, reason: service_execution_contract_unmatched}
diff --git a/examples/secrets-engine-service-task-execution-v2/policy_package.md b/examples/secrets-engine-service-task-execution-v2/policy_package.md
new file mode 100644
--- /dev/null
+++ b/examples/secrets-engine-service-task-execution-v2/policy_package.md
@@ -0,0 +1,112 @@
+---
+id: secrets-engine.task-execution.service-pilot
+name: Secrets admitted service task execution
+namespace: secrets-engine:secret-catalog-lane
+version: v2
+status: proposed
+package: flexauth.secrets_engine.service_task_execution
+allow_ttl: 15s
+actions: [execute-ccr]
+owner: team:platform-security
+fixtures: [policy_fixtures.yaml]
+caring:
+ profile: caring-0.4.0-rc2
+ enforce: false
+activation:
+ mode: local
+---
+
+# Trusted service task source successor
+
+This source package is evaluated exclusively by the typed v2 HTTP path. That
+path requires exact TokenReview caller mapping, immutable numeric source
+representations, a fixed authenticated Approval reader, separate original and
+fresh claim validation and native/PDP correspondence before policy evaluation.
+Generic check, batch and list routes refuse task execution. Selecting this source
+package alone cannot bypass those joins. No deployment or trust admission is
+established by this package. The v1 package remains unchanged and DENY-only.
+
+```rego
+default decision := {"effect": "deny", "reason": "service_execution_contract_unmatched"}
+
+decision := {"effect": "allow", "reason": "exact_task_execution_matched"} {
+ candidate_matches
+}
+
+hash_ok(x) { is_string(x); regex.match("^sha256:[0-9a-f]{64}$", x) }
+
+candidate_matches {
+ input.action == "execute-ccr"
+ input.tenant == "tenant:platform"
+ input.subject.id == "secrets-engine"
+ input.subject.type == "service"
+ input.resource.system == "secrets-engine"
+ input.resource.type == "secret-catalog-lane"
+ input.context.human_control == true
+ c := input.context.execution_contract
+ c.contract_version == "SECRETS-EXEC-20261011-v1"
+ c.operation == "metadata-apply-ccr"
+ c.execution_actor == "service:secrets-engine"
+ regex.match("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", c.operation_id)
+ c.ccr_id != c.execution_grant_ccr_id
+ hash_ok(c.ccr_immutable_sha256)
+ hash_ok(c.grant_immutable_sha256)
+ hash_ok(c.catalog_sha256)
+ hash_ok(c.expected_before)
+ hash_ok(c.expected_after)
+ c.backend_budget.ttl_seconds <= 120
+ c.backend_budget.ttl_seconds >= 30
+ c.backend_budget.uses == 12
+ count(c.operations) == 6
+ c.operations[0].method == "GET"
+ c.operations[1].method == "PUT"
+ c.operations[2].method == "GET"
+ c.operations[3].method == "GET"
+ c.operations[4].method == "PUT"
+ c.operations[5].method == "GET"
+ regex.match("^sys/policies/acl/[A-Za-z0-9_-]+$", c.operations[0].path)
+ regex.match("^auth/[A-Za-z0-9_-]+/role/[A-Za-z0-9_-]+$", c.operations[3].path)
+ c.operations[0].path == c.operations[1].path
+ c.operations[1].path == c.operations[2].path
+ c.operations[3].path == c.operations[4].path
+ c.operations[4].path == c.operations[5].path
+ every_operation_hash_valid
+ b := input.context.service_actor_binding
+ count(b) == 5
+ b.version == "proposed-service-task-caller/v1"
+ b.transport_principal == "system:serviceaccount:secrets-engine:secrets-engine"
+ b.subject_id == input.subject.id
+ b.subject_type == "service"
+ b.tenant == input.tenant
+ not input.context.task_execution_projection
+ not input.context.initiator
+ not input.context.represented_principal
+ not input.context.delegation
+ input.resource.attributes.stage == "prod"
+ time.parse_rfc3339_ns(c.operation_deadline) > time.now_ns()
+ input.context.approval.schema_version == "0.1"
+ input.context.approval.kind == "approval-claim"
+ input.context.approval.issuer == "approval-engine"
+ input.context.approval.valid_now == true
+ input.context.approval.consumed == false
+ input.context.approval.reason_code == "ok"
+ input.context.approval.binding.pdp_path == true
+ hash_ok(input.context.approval.binding.pdp_digest)
+ time.parse_rfc3339_ns(input.context.approval.freshness.not_after) > time.now_ns()
+ time.parse_rfc3339_ns(input.context.approval.validity.expires_at) >= time.parse_rfc3339_ns(c.operation_deadline)
+ input.context.approval.binding.human_control == true
+}
+
+every_operation_hash_valid {
+ count([x | x := input.context.execution_contract.operations[_]; hash_ok(x.payload_sha256)]) == 6
+}
+```
+
+```rego test
+import data.flexauth.secrets_engine.service_task_execution
+
+test_unsigned_flags_cannot_enable {
+ service_task_execution.decision.effect == "deny"
+ with input as {"context": {"runtime_admission": true, "verified": true}}
+}
+```
diff --git a/examples/secrets-engine-service-task-execution-v2/resource_manifest.yaml b/examples/secrets-engine-service-task-execution-v2/resource_manifest.yaml
new file mode 100644
--- /dev/null
+++ b/examples/secrets-engine-service-task-execution-v2/resource_manifest.yaml
@@ -0,0 +1,6 @@
+# Synthetic source-validation fixture only; no estate registry admission.
+resources:
+ - id: catalog:synthetic
+ system: secrets-engine
+ type: secret-catalog-lane
+ attributes: {stage: prod}
diff --git a/go.mod b/go.mod
--- a/go.mod
+++ b/go.mod
@@ -3,6 +3,7 @@
go 1.26.7
require (
+ github.com/netkingdom/approval-engine/adapters/go/approvalfact v0.0.0
github.com/open-policy-agent/opa v1.4.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -39,3 +40,5 @@
google.golang.org/protobuf v1.36.6 // indirect
sigs.k8s.io/yaml v1.4.0 // indirect
)
+
+replace github.com/netkingdom/approval-engine/adapters/go/approvalfact => ./third_party/approvalfact
diff --git a/internal/audit/log.go b/internal/audit/log.go
--- a/internal/audit/log.go
+++ b/internal/audit/log.go
@@ -8,6 +8,7 @@
"path/filepath"
"sync"
+ "github.com/netkingdom/access-engine/internal/sign"
"github.com/netkingdom/access-engine/pkg/api"
)
@@ -67,8 +68,8 @@
if scanner.Text() == "" {
continue
}
- var decision api.DecisionEnvelope
- if err := json.Unmarshal(scanner.Bytes(), &decision); err != nil {
+ decision, err := sign.DecodeEnvelope(scanner.Bytes())
+ if err != nil {
return nil, fmt.Errorf("unmarshal decision log line %d: %w", len(decisions)+1, err)
}
decisions = append(decisions, decision)
diff --git a/internal/decision/engine.go b/internal/decision/engine.go
--- a/internal/decision/engine.go
+++ b/internal/decision/engine.go
@@ -158,6 +158,10 @@
func (e *Engine) IsServicePilot() bool {
return e.policy.Metadata.ID == taskexecution.ServicePilotPackageID && e.policy.Metadata.Version == taskexecution.ServicePilotPackageVersion
}
+func (e *Engine) IsTrustedServicePilot() bool {
+ return e.policy.Metadata.ID == taskexecution.ServicePilotPackageID && e.policy.Metadata.Version == taskexecution.TrustedServicePilotPackageVersion
+}
+func (e *Engine) IsTaskPin() bool { return e.IsServicePilot() || e.IsTrustedServicePilot() }
// ErrServicePilotUnavailable withholds any result that is not this B1 slice's
// signed DENY. Provider joins and executable eligibility remain unimplemented.
@@ -189,7 +193,7 @@
// 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" {
+ if e.IsTaskPin() || request.Action == "execute-ccr" {
return api.DecisionEnvelope{}, ErrServicePilotUnavailable
}
decision, err := e.decide(ctx, request)
@@ -205,7 +209,7 @@
// 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" {
+ if e.IsTaskPin() || request.Action == "execute-ccr" {
return nil, ErrServicePilotUnavailable
}
decisions := make([]api.DecisionEnvelope, 0, len(request.Resources))
@@ -232,7 +236,7 @@
// 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" {
+ if e.IsTaskPin() || request.Action == "execute-ccr" {
return nil, ErrServicePilotUnavailable
}
candidates := e.store.ResourceRefs(request.System, request.ResourceType)
diff --git a/internal/decision/trusted_service.go b/internal/decision/trusted_service.go
new file mode 100644
--- /dev/null
+++ b/internal/decision/trusted_service.go
@@ -0,0 +1,170 @@
+package decision
+
+import (
+ "context"
+ "encoding/json"
+ "github.com/netkingdom/access-engine/internal/callerauth"
+ "github.com/netkingdom/access-engine/internal/sign"
+ "github.com/netkingdom/access-engine/internal/taskexecution"
+ "github.com/netkingdom/access-engine/pkg/api"
+ "github.com/netkingdom/approval-engine/adapters/go/approvalfact"
+ "time"
+)
+
+// legacyDigestProjection preserves the published PDP digest profile while the
+// signed structured binding retains original numeric kinds. It is used only
+// for digest material, never to reconstruct an Approval-native act.
+func legacyDigestProjection(request api.CheckRequest) (api.CheckRequest, error) {
+ raw, err := json.Marshal(request)
+ if err != nil {
+ return api.CheckRequest{}, err
+ }
+ var projected api.CheckRequest
+ err = json.Unmarshal(raw, &projected)
+ return projected, err
+}
+func nativeServiceAct(request api.CheckRequest) ([]byte, error) {
+ purpose, ok := request.Context["purpose"].(string)
+ if !ok || purpose == "" || request.Subject.ID != "secrets-engine" || request.Subject.Type != "service" {
+ return nil, taskexecution.ErrServiceInput
+ }
+ if principal, exists := request.Subject.Attributes["principal"]; exists && principal != "secrets-engine" {
+ return nil, taskexecution.ErrServiceInput
+ }
+ // All map numbers originate from the number-preserving request/registry
+ // profile. This raw draft is canonicalized only by the Approval owner helper.
+ return json.Marshal(map[string]any{"action": request.Action, "actor": request.Subject.ID, "principal": "secrets-engine", "purpose": purpose, "target": request.Resource})
+}
+func (e *Engine) signTaskEnvelope(envelope *api.DecisionEnvelope) error {
+ e.mu.RLock()
+ key, kid := e.signingKey, e.signingKID
+ e.mu.RUnlock()
+ if len(key) == 0 {
+ return ErrServicePilotUnavailable
+ }
+ return sign.Sign(envelope, key, kid)
+}
+
+// CheckTrustedService is the only v2 task evaluation path. Its source reader
+// and immutable service input are server-created, not HTTP validation flags.
+// It never consumes an approval or causes a protected backend effect.
+func (e *Engine) CheckTrustedService(ctx context.Context, input taskexecution.ServiceInput, source *taskexecution.ApprovalReader) (api.DecisionEnvelope, error) {
+ if !e.IsTrustedServicePilot() || !input.NumberPreserved() || !source.Configured() {
+ return api.DecisionEnvelope{}, ErrServicePilotUnavailable
+ }
+ now := e.now()
+ submitted, err := input.RequestAt(now)
+ if err != nil {
+ return api.DecisionEnvelope{}, err
+ }
+ normalized, facts := e.normalizeRequest(submitted)
+ if (facts.resourceFound || facts.subjectFound) && !e.store.NumbersPreserved() {
+ return api.DecisionEnvelope{}, ErrServicePilotUnavailable
+ }
+ original, err := legacyDigestProjection(submitted)
+ if err != nil {
+ return api.DecisionEnvelope{}, err
+ }
+ evaluated, err := legacyDigestProjection(normalized)
+ if err != nil {
+ return api.DecisionEnvelope{}, err
+ }
+ ctx = callerauth.WithRecord(ctx, input.Caller())
+ var approvalEvidence *api.ApprovalObservationProvenance
+ deadline := input.Caller().NotAfter
+ contract := submitted.Context["execution_contract"].(map[string]any)
+ operationEnd, err := time.Parse(time.RFC3339Nano, contract["operation_deadline"].(string))
+ if err != nil {
+ return api.DecisionEnvelope{}, taskexecution.ErrServiceInput
+ }
+ if operationEnd.Before(deadline) {
+ deadline = operationEnd
+ }
+ carried, hasClaim := submitted.Context[api.ApprovalContextKey]
+ if hasClaim {
+ claim, ok := carried.(map[string]any)
+ if !ok {
+ return api.DecisionEnvelope{}, taskexecution.ErrApprovalSource
+ }
+ id, ok := claim["approval_id"].(string)
+ if !ok {
+ return api.DecisionEnvelope{}, taskexecution.ErrApprovalSource
+ }
+ native, err := nativeServiceAct(normalized)
+ if err != nil {
+ return api.DecisionEnvelope{}, err
+ }
+ originalRaw, err := json.Marshal(carried)
+ if err != nil {
+ return api.DecisionEnvelope{}, taskexecution.ErrApprovalSource
+ }
+ observed, err := source.Observe(ctx, id)
+ if err != nil {
+ return api.DecisionEnvelope{}, err
+ }
+ // One validation instant AFTER network I/O; a fresh read cannot rescue the
+ // original carried claim's freshness, source expiry or operation deadline.
+ now = e.now()
+ if _, err := input.RequestAt(now); err != nil {
+ return api.DecisionEnvelope{}, err
+ }
+ verified, err := observed.Validate(originalRaw, approvalfact.Expected{ApprovalID: id, NativeBindingJSON: native, PDPDigest: api.ApprovalBindingDigest(evaluated)}, now)
+ if err != nil {
+ return api.DecisionEnvelope{}, err
+ }
+ factEnd, err := verified.Deadline()
+ if err != nil {
+ return api.DecisionEnvelope{}, err
+ }
+ if factEnd.Before(deadline) {
+ deadline = factEnd
+ }
+ approvalEvidence, err = verified.Provenance()
+ if err != nil {
+ return api.DecisionEnvelope{}, err
+ }
+ }
+ expectation, err := e.policy.EvaluatePreservingNumbers(ctx, normalized)
+ if err != nil {
+ return api.DecisionEnvelope{}, err
+ }
+ if !hasClaim && expectation.Effect == api.DecisionEffectAllow {
+ return api.DecisionEnvelope{}, ErrServicePilotUnavailable
+ }
+ now = e.now()
+ if _, err := input.RequestAt(now); err != nil {
+ return api.DecisionEnvelope{}, err
+ }
+ envelope := e.envelope(ctx, normalized, submitted, expectation, facts)
+ envelope.Binding.RequestDigest = api.RequestDigest(evaluated)
+ envelope.Binding.SubmittedRequestDigest = api.RequestDigest(original)
+ if hasClaim {
+ envelope.Binding.ApprovalBindingDigest = api.ApprovalBindingDigest(evaluated)
+ }
+ envelope.Provenance.ApprovalObservation = approvalEvidence
+ if envelope.Effect == api.DecisionEffectAllow {
+ if approvalEvidence == nil || envelope.Lifetime == nil || !deadline.After(now) {
+ return api.DecisionEnvelope{}, ErrServicePilotUnavailable
+ }
+ end, err := time.Parse(time.RFC3339, envelope.Lifetime.ExpiresAt)
+ if err != nil {
+ return api.DecisionEnvelope{}, err
+ }
+ if deadline.Before(end) {
+ end = deadline
+ }
+ // Floor expiry to whole seconds, never extend an authoritative deadline.
+ end = end.Truncate(time.Second)
+ if !end.After(now) {
+ return api.DecisionEnvelope{}, ErrServicePilotUnavailable
+ }
+ envelope.Lifetime.ExpiresAt = end.UTC().Format(time.RFC3339)
+ }
+ if err := e.signTaskEnvelope(&envelope); err != nil {
+ return api.DecisionEnvelope{}, err
+ }
+ if err := e.recordDecisions([]api.DecisionEnvelope{envelope}); err != nil {
+ return api.DecisionEnvelope{}, err
+ }
+ return envelope, nil
+}
diff --git a/internal/emission/delivery.go b/internal/emission/delivery.go
--- a/internal/emission/delivery.go
+++ b/internal/emission/delivery.go
@@ -112,7 +112,9 @@
return entry{}, 0, err
}
var e entry
- if err := json.Unmarshal(line, &e); err != nil {
+ decoder := json.NewDecoder(bytes.NewReader(line))
+ decoder.UseNumber()
+ if err := decoder.Decode(&e); err != nil {
return entry{}, 0, err
}
if e.Seq != o.cursor.Seq+1 || e.Event.Source != o.source {
diff --git a/internal/emission/numeric_replay_test.go b/internal/emission/numeric_replay_test.go
new file mode 100644
--- /dev/null
+++ b/internal/emission/numeric_replay_test.go
@@ -0,0 +1,53 @@
+package emission
+
+import (
+ "bytes"
+ "crypto/ed25519"
+ "encoding/json"
+ "github.com/netkingdom/access-engine/internal/sign"
+ "github.com/netkingdom/access-engine/pkg/api"
+ "testing"
+)
+
+func TestOutboxReplayPreservesProducerNumericSignature(t *testing.T) {
+ folder := t.TempDir()
+ outbox, err := Open(folder, "synthetic-source")
+ if err != nil {
+ t.Fatal(err)
+ }
+ key := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{21}, 32))
+ envelope := api.DecisionEnvelope{ID: "decision:synthetic-numeric", Effect: api.DecisionEffectDeny, Resource: api.ResourceRef{ID: "synthetic", Type: "tenant", System: "tenant-engine"}}
+ envelope.Binding = &api.DecisionBinding{Context: map[string]any{"float": json.Number("1.0"), "negative_zero": json.Number("-0.0"), "integer": json.Number("9007199254740993"), "exponent": json.Number("1e-07")}}
+ if err := sign.Sign(&envelope, key, "synthetic-numeric"); err != nil {
+ t.Fatal(err)
+ }
+ if err := outbox.Append(envelope); err != nil {
+ t.Fatal(err)
+ }
+ outbox.Close()
+ outbox, err = Open(folder, "synthetic-source")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer outbox.Close()
+ entry, _, err := outbox.nextEntry()
+ if err != nil {
+ t.Fatal(err)
+ }
+ wire, err := json.Marshal(entry.Event.Data)
+ if err != nil {
+ t.Fatal(err)
+ }
+ replayed, err := sign.DecodeEnvelope(wire)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := sign.Verify(replayed, map[string]ed25519.PublicKey{"synthetic-numeric": key.Public().(ed25519.PublicKey)}); err != nil {
+ t.Fatal(err)
+ }
+ before, _ := sign.CanonicalBytes(envelope)
+ after, _ := sign.CanonicalBytes(replayed)
+ if !bytes.Equal(before, after) {
+ t.Fatal("replayed signed bytes changed")
+ }
+}
diff --git a/internal/emission/outbox.go b/internal/emission/outbox.go
--- a/internal/emission/outbox.go
+++ b/internal/emission/outbox.go
@@ -136,7 +136,9 @@
return os.Truncate(o.path, o.size)
}
var e entry
- if err := json.Unmarshal(data[good:good+end], &e); err != nil {
+ decoder := json.NewDecoder(bytes.NewReader(data[good : good+end]))
+ decoder.UseNumber()
+ if err := decoder.Decode(&e); err != nil {
return fmt.Errorf("outbox line %d is corrupt: %w", line, err)
}
if e.Seq != o.seq+1 || e.Event.Source != o.source {
diff --git a/internal/policy/package.go b/internal/policy/package.go
--- a/internal/policy/package.go
+++ b/internal/policy/package.go
@@ -1,6 +1,7 @@
package policy
import (
+ "bytes"
"context"
"encoding/json"
"fmt"
@@ -375,7 +376,25 @@
}
func (p *Package) evaluateDecision(ctx context.Context, request api.CheckRequest) (api.DecisionExpectation, error) {
+ return p.evaluateDecisionProfile(ctx, request, false)
+}
+
+// EvaluatePreservingNumbers feeds the same evaluator the task profile's exact
+// numeric values. Ordinary evaluation behavior and canonical contracts stay put.
+func (p *Package) EvaluatePreservingNumbers(ctx context.Context, request api.CheckRequest) (api.DecisionExpectation, error) {
+ return p.evaluateDecisionProfile(ctx, request, true)
+}
+func (p *Package) evaluateDecisionProfile(ctx context.Context, request api.CheckRequest, preserve bool) (api.DecisionExpectation, error) {
input, err := toRegoInput(request)
+ if preserve {
+ raw, e := json.Marshal(request)
+ if e != nil {
+ return api.DecisionExpectation{}, e
+ }
+ decoder := json.NewDecoder(bytes.NewReader(raw))
+ decoder.UseNumber()
+ err = decoder.Decode(&input)
+ }
if err != nil {
return api.DecisionExpectation{}, err
}
diff --git a/internal/registry/numeric_source_test.go b/internal/registry/numeric_source_test.go
new file mode 100644
--- /dev/null
+++ b/internal/registry/numeric_source_test.go
@@ -0,0 +1,36 @@
+package registry
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestTaskRegistryNumericSourceAndStrictDecode(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "snapshot.json")
+ raw := []byte(`{"resource_manifests":[{"id":"synthetic","system":"secrets-engine","resources":[{"id":"catalog:synthetic","type":"secret-catalog-lane","attributes":{"float":1.0,"integer":9007199254740993,"negative_zero":-0.0}}]}]}`)
+ os.WriteFile(path, raw, 0600)
+ store, err := LoadFilePreservingNumbers(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !store.NumbersPreserved() {
+ t.Fatal("missing source profile")
+ }
+ resource, ok := store.Resource("secrets-engine", "catalog:synthetic")
+ if !ok {
+ t.Fatal("missing resource")
+ }
+ for k, v := range map[string]string{"float": "1.0", "integer": "9007199254740993", "negative_zero": "-0.0"} {
+ if resource.Attributes[k] != json.Number(v) {
+ t.Fatalf("lost native representation: %s", k)
+ }
+ }
+ for _, raw := range []string{`{} {}`, `{"subjects":[],"subjects":[]}`, `{"unknown":true}`} {
+ os.WriteFile(path, []byte(raw), 0600)
+ if _, err := LoadFilePreservingNumbers(path); err == nil {
+ t.Fatal("invalid authoritative registry accepted")
+ }
+ }
+}
diff --git a/internal/registry/store.go b/internal/registry/store.go
--- a/internal/registry/store.go
+++ b/internal/registry/store.go
@@ -1,6 +1,7 @@
package registry
import (
+ "bytes"
"encoding/json"
"fmt"
"os"
@@ -8,10 +9,12 @@
"sort"
"github.com/netkingdom/access-engine/pkg/api"
+ "github.com/netkingdom/approval-engine/adapters/go/approvalfact"
)
// Store is a deterministic local registry for standalone development and tests.
type Store struct {
+ numbersPreserved bool
systems map[string]api.ProtectedSystemManifest
resourceManifests map[string]api.ResourceManifest
resources map[string]api.Resource
@@ -66,6 +69,32 @@
return store, nil
}
+// LoadFilePreservingNumbers is the task-source profile: owner registry numeric
+// representations stay intact for native act correspondence. Ordinary loaders
+// and their existing snapshot/digest behavior remain unchanged.
+func LoadFilePreservingNumbers(path string) (*Store, error) {
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+ if _, err := approvalfact.DecodeJSON(raw); err != nil {
+ return nil, err
+ }
+ var snapshot Snapshot
+ decoder := json.NewDecoder(bytes.NewReader(raw))
+ decoder.UseNumber()
+ decoder.DisallowUnknownFields()
+ if err := decoder.Decode(&snapshot); err != nil {
+ return nil, err
+ }
+ store := NewStore()
+ if err := store.ApplySnapshot(snapshot); err != nil {
+ return nil, err
+ }
+ store.numbersPreserved = true
+ return store, nil
+}
+
// SaveFile writes the store as deterministic pretty-printed JSON.
func (s *Store) SaveFile(path string) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
@@ -380,3 +409,6 @@
}
return out
}
+
+// NumbersPreserved reports the source profile of file-backed registry facts.
+func (s *Store) NumbersPreserved() bool { return s != nil && s.numbersPreserved }
diff --git a/internal/sign/numeric_decode_test.go b/internal/sign/numeric_decode_test.go
new file mode 100644
--- /dev/null
+++ b/internal/sign/numeric_decode_test.go
@@ -0,0 +1,54 @@
+package sign
+
+import (
+ "bytes"
+ "crypto/ed25519"
+ "encoding/json"
+ "github.com/netkingdom/access-engine/pkg/api"
+ "testing"
+)
+
+func TestProducerNumericEnvelopeDecodeCompatibility(t *testing.T) {
+ key := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{11}, 32))
+ keys := map[string]ed25519.PublicKey{"synthetic-numeric": key.Public().(ed25519.PublicKey)}
+ for _, raw := range []string{`1`, `1.0`, `-0.0`, `1e-07`, `1e+20`, `9007199254740993`, `1.7976931348623157e308`, `5e-324`, `true`, `"<>&\u2028\u2029 café"`, `[1,1.0,-0.0]`, `{"nested":1.0}`} {
+ t.Run(raw, func(t *testing.T) {
+ var value any
+ decoder := json.NewDecoder(bytes.NewBufferString(raw))
+ decoder.UseNumber()
+ if err := decoder.Decode(&value); err != nil {
+ t.Fatal(err)
+ }
+ original := api.DecisionEnvelope{Binding: &api.DecisionBinding{Context: map[string]any{"numeric": value}}}
+ if err := Sign(&original, key, "synthetic-numeric"); err != nil {
+ t.Fatal(err)
+ }
+ wire, _ := json.Marshal(original)
+ decoded, err := DecodeEnvelope(wire)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := Verify(decoded, keys); err != nil {
+ t.Fatal(err)
+ }
+ before, _ := CanonicalBytes(original)
+ after, _ := CanonicalBytes(decoded)
+ if !bytes.Equal(before, after) {
+ t.Fatal("producer canonical bytes changed")
+ }
+ })
+ }
+ // Existing float64 producer profile still verifies through the new decoder.
+ legacy := api.DecisionEnvelope{Binding: &api.DecisionBinding{Context: map[string]any{"number": float64(1), "exponent": float64(1e-7)}}}
+ Sign(&legacy, key, "synthetic-numeric")
+ wire, _ := json.Marshal(legacy)
+ decoded, err := DecodeEnvelope(wire)
+ if err != nil || Verify(decoded, keys) != nil {
+ t.Fatal("legacy producer compatibility failed")
+ }
+ for _, raw := range []string{`{} {}`, `{"new_unknown_authority":true}`} {
+ if _, err := DecodeEnvelope([]byte(raw)); err == nil {
+ t.Fatal("invalid envelope accepted")
+ }
+ }
+}
diff --git a/internal/sign/sign.go b/internal/sign/sign.go
--- a/internal/sign/sign.go
+++ b/internal/sign/sign.go
@@ -4,10 +4,12 @@
package sign
import (
+ "bytes"
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"fmt"
+ "io"
"github.com/netkingdom/access-engine/pkg/api"
)
@@ -74,3 +76,20 @@
}
return nil
}
+
+// DecodeEnvelope preserves JSON numeric kinds/lexemes emitted by task inputs.
+// Legacy producer envelopes remain byte-compatible. It does not invent another
+// signature canonicalization: CanonicalBytes still calls typed json.Marshal.
+func DecodeEnvelope(raw []byte) (api.DecisionEnvelope, error) {
+ var envelope api.DecisionEnvelope
+ decoder := json.NewDecoder(bytes.NewReader(raw))
+ decoder.UseNumber()
+ decoder.DisallowUnknownFields()
+ if err := decoder.Decode(&envelope); err != nil {
+ return envelope, err
+ }
+ if _, err := decoder.Token(); err != io.EOF {
+ return api.DecisionEnvelope{}, fmt.Errorf("trailing envelope data")
+ }
+ return envelope, nil
+}
diff --git a/internal/taskexecution/approval_source.go b/internal/taskexecution/approval_source.go
new file mode 100644
--- /dev/null
+++ b/internal/taskexecution/approval_source.go
@@ -0,0 +1,215 @@
+package taskexecution
+
+import (
+ "context"
+ "crypto/sha256"
+ "crypto/tls"
+ "crypto/x509"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "io"
+ "mime"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "time"
+
+ "github.com/netkingdom/access-engine/pkg/api"
+ "github.com/netkingdom/approval-engine/adapters/go/approvalfact"
+)
+
+var ErrApprovalSource = errors.New("trusted Approval source refused")
+var approvalUUID = regexp.MustCompile(`^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$`)
+
+type ApprovalSourceConfig struct {
+ SourceID string `json:"source_id"`
+ SourceRevision string `json:"source_revision"`
+ Origin string `json:"origin"`
+ StoreTenant string `json:"store_tenant"`
+ CAFile string `json:"ca_file"`
+ TokenFile string `json:"token_file"`
+}
+
+// ApprovalReader is installed from owner configuration, never an HTTP request.
+// It reads one fixed PIP endpoint; no policy, retry, consume or effect lives here.
+type ApprovalReader struct {
+ config ApprovalSourceConfig
+ configurationDigest string
+ client *http.Client
+}
+
+func NewApprovalReader(config ApprovalSourceConfig) (*ApprovalReader, error) {
+ u, err := url.Parse(config.Origin)
+ if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") || strings.ContainsAny(config.Origin, "\r\n\t ") || config.SourceID == "" || config.SourceRevision == "" || config.StoreTenant != "tenant:platform" || !filepath.IsAbs(config.CAFile) || !filepath.IsAbs(config.TokenFile) {
+ return nil, ErrApprovalSource
+ }
+ ca, err := readPublicApprovalFile(config.CAFile, 1048576)
+ if err != nil {
+ return nil, ErrApprovalSource
+ }
+ pool := x509.NewCertPool()
+ if !pool.AppendCertsFromPEM(ca) {
+ return nil, ErrApprovalSource
+ }
+ config.Origin = strings.TrimRight(config.Origin, "/")
+ digest := api.CanonicalDigest(struct {
+ Config ApprovalSourceConfig `json:"configuration"`
+ CA string `json:"ca_sha256"`
+ }{config, byteDigest(ca)})
+ return &ApprovalReader{config: config, configurationDigest: digest, client: &http.Client{Timeout: 3 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, Transport: &http.Transport{Proxy: nil, DisableCompression: true, TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: pool}}}}, nil
+}
+func readPublicApprovalFile(path string, limit int64) ([]byte, error) {
+ file, err := os.Open(path)
+ if err != nil {
+ return nil, ErrApprovalSource
+ }
+ defer file.Close()
+ info, err := file.Stat()
+ if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0022 != 0 {
+ return nil, ErrApprovalSource
+ }
+ raw, err := io.ReadAll(io.LimitReader(file, limit+1))
+ if err != nil || int64(len(raw)) > limit {
+ return nil, ErrApprovalSource
+ }
+ return raw, nil
+}
+func LoadApprovalReader(path string) (*ApprovalReader, error) {
+ if !filepath.IsAbs(path) {
+ return nil, ErrApprovalSource
+ }
+ raw, err := readPublicApprovalFile(path, 65536)
+ if err != nil {
+ return nil, ErrApprovalSource
+ }
+ if _, err := approvalfact.DecodeJSON(raw); err != nil {
+ return nil, ErrApprovalSource
+ }
+ var config ApprovalSourceConfig
+ decoder := json.NewDecoder(strings.NewReader(string(raw)))
+ decoder.DisallowUnknownFields()
+ if decoder.Decode(&config) != nil {
+ return nil, ErrApprovalSource
+ }
+ return NewApprovalReader(config)
+}
+func (r *ApprovalReader) Configured() bool {
+ return r != nil && r.client != nil && r.configurationDigest != ""
+}
+func byteDigest(raw []byte) string {
+ h := sha256.Sum256(raw)
+ return "sha256:" + hex.EncodeToString(h[:])
+}
+func readApprovalToken(path string) (string, error) {
+ file, err := os.Open(path)
+ if err != nil {
+ return "", ErrApprovalSource
+ }
+ defer file.Close()
+ info, err := file.Stat()
+ if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0027 != 0 {
+ return "", ErrApprovalSource
+ }
+ raw, err := io.ReadAll(io.LimitReader(file, 8193))
+ if err != nil || len(raw) > 8192 {
+ return "", ErrApprovalSource
+ }
+ token := strings.TrimSpace(string(raw))
+ if token == "" || strings.ContainsAny(token, " \t\r\n") {
+ return "", ErrApprovalSource
+ }
+ return token, nil
+}
+
+// ApprovalObservation cannot be created by caller JSON or a public verified flag.
+// It is raw fact data plus the source/configuration used by this fixed reader.
+type ApprovalObservation struct {
+ raw []byte
+ sourceID, revision, configurationDigest string
+}
+
+func (r *ApprovalReader) Observe(ctx context.Context, id string) (ApprovalObservation, error) {
+ if !r.Configured() || !approvalUUID.MatchString(id) {
+ return ApprovalObservation{}, ErrApprovalSource
+ }
+ token, err := readApprovalToken(r.config.TokenFile)
+ if err != nil {
+ return ApprovalObservation{}, err
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, r.config.Origin+"/v1/approvals/"+id+"/claim", nil)
+ if err != nil {
+ return ApprovalObservation{}, ErrApprovalSource
+ }
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("Accept", "application/json")
+ response, err := r.client.Do(req)
+ if err != nil {
+ return ApprovalObservation{}, ErrApprovalSource
+ }
+ defer response.Body.Close()
+ types := response.Header.Values("Content-Type")
+ if len(types) != 1 {
+ return ApprovalObservation{}, ErrApprovalSource
+ }
+ media, parameters, err := mime.ParseMediaType(types[0])
+ if err != nil || media != "application/json" || (parameters["charset"] != "" && !strings.EqualFold(parameters["charset"], "utf-8")) || (len(parameters) != 0 && (len(parameters) != 1 || parameters["charset"] == "")) || response.StatusCode != 200 || response.Header.Get("Content-Encoding") != "" {
+ return ApprovalObservation{}, ErrApprovalSource
+ }
+ raw, err := io.ReadAll(io.LimitReader(response.Body, 524289))
+ if err != nil || len(raw) > 524288 {
+ return ApprovalObservation{}, ErrApprovalSource
+ }
+ if _, err := approvalfact.DecodeJSON(raw); err != nil {
+ return ApprovalObservation{}, ErrApprovalSource
+ }
+ return ApprovalObservation{raw: append([]byte(nil), raw...), sourceID: r.config.SourceID, revision: r.config.SourceRevision, configurationDigest: r.configurationDigest}, nil
+}
+
+// VerifiedApprovalFacts establishes typed fact correspondence, not a decision.
+// Only an authenticated fixed-reader observation can construct this handle.
+type VerifiedApprovalFacts struct {
+ original, fetched approvalfact.Fact
+ evidence api.ApprovalObservationProvenance
+ valid bool
+}
+
+func (o ApprovalObservation) Validate(original []byte, expected approvalfact.Expected, now time.Time) (VerifiedApprovalFacts, error) {
+ if len(o.raw) == 0 || o.sourceID == "" || o.revision == "" || o.configurationDigest == "" {
+ return VerifiedApprovalFacts{}, ErrApprovalSource
+ }
+ originalFact, err := approvalfact.ValidateClaim(original, expected, now)
+ if err != nil {
+ return VerifiedApprovalFacts{}, ErrApprovalSource
+ }
+ fetched, err := approvalfact.ValidateClaim(o.raw, expected, now)
+ if err != nil || approvalfact.MatchObserved(originalFact, fetched) != nil {
+ return VerifiedApprovalFacts{}, ErrApprovalSource
+ }
+ limit := originalFact.Deadline()
+ if fetched.Deadline().Before(limit) {
+ limit = fetched.Deadline()
+ }
+ evidence := api.ApprovalObservationProvenance{SourceID: o.sourceID, SourceRevision: o.revision, ConfigurationDigest: o.configurationDigest, ClaimDigest: byteDigest(o.raw), NativeBindingDigest: fetched.NativeDigest(), ObservedAt: fetched.ObservedAt().UTC().Format(time.RFC3339Nano), NotAfter: limit.UTC().Format(time.RFC3339Nano), ValidatedAt: now.UTC().Format(time.RFC3339Nano)}
+ return VerifiedApprovalFacts{original: originalFact, fetched: fetched, evidence: evidence, valid: true}, nil
+}
+func (f VerifiedApprovalFacts) Deadline() (time.Time, error) {
+ if !f.valid {
+ return time.Time{}, ErrApprovalSource
+ }
+ a := f.original.Deadline()
+ if f.fetched.Deadline().Before(a) {
+ a = f.fetched.Deadline()
+ }
+ return a, nil
+}
+func (f VerifiedApprovalFacts) Provenance() (*api.ApprovalObservationProvenance, error) {
+ if !f.valid {
+ return nil, ErrApprovalSource
+ }
+ copy := f.evidence
+ return &copy, nil
+}
diff --git a/internal/taskexecution/approval_source_test.go b/internal/taskexecution/approval_source_test.go
new file mode 100644
--- /dev/null
+++ b/internal/taskexecution/approval_source_test.go
@@ -0,0 +1,118 @@
+package taskexecution
+
+import (
+ "context"
+ "encoding/pem"
+ "github.com/netkingdom/approval-engine/adapters/go/approvalfact"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+func TestFixedApprovalReaderRefusalBoundary(t *testing.T) {
+ folder := t.TempDir()
+ token := filepath.Join(folder, "token")
+ ca := filepath.Join(folder, "ca")
+ if err := os.WriteFile(token, []byte("synthetic-source-token"), 0440); err != nil {
+ t.Fatal(err)
+ }
+ var mode atomic.Value
+ mode.Store("ok")
+ var foreignCalls atomic.Int64
+ foreign := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ foreignCalls.Add(1)
+ t.Error("credential escaped fixed origin")
+ }))
+ defer foreign.Close()
+ server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Authorization") != "Bearer synthetic-source-token" || r.URL.Path != "/v1/approvals/aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee/claim" || r.Method != "GET" {
+ t.Error("wrong auth/path/method")
+ }
+ w.Header().Set("Content-Type", "application/json")
+ switch mode.Load().(string) {
+ case "redirect":
+ w.Header().Set("Location", foreign.URL)
+ w.WriteHeader(302)
+ return
+ case "status":
+ w.WriteHeader(403)
+ case "large":
+ w.Write([]byte(strings.Repeat(" ", 524289)))
+ case "duplicate":
+ w.Write([]byte(`{"a":true,"a":false}`))
+ case "invalid-utf8":
+ w.Write([]byte{'"', 255, '"'})
+ case "media":
+ w.Header().Set("Content-Type", "application/json; arbitrary=authority")
+ w.Write([]byte(`{}`))
+ case "duplicate-media":
+ w.Header().Add("Content-Type", "application/json")
+ w.Write([]byte(`{}`))
+ case "compressed":
+ w.Header().Set("Content-Encoding", "gzip")
+ w.Write([]byte(`{}`))
+ case "slow":
+ select {
+ case <-r.Context().Done():
+ case <-time.After(time.Second):
+ }
+ default:
+ w.Write([]byte(`{}`))
+ }
+ }))
+ defer server.Close()
+ os.WriteFile(ca, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}), 0600)
+ config := ApprovalSourceConfig{SourceID: "synthetic-source", SourceRevision: "synthetic-revision", Origin: server.URL, StoreTenant: "tenant:platform", CAFile: ca, TokenFile: token}
+ reader, err := NewApprovalReader(config)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := reader.Observe(context.Background(), "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"); err != nil {
+ t.Fatal(err)
+ }
+ for _, name := range []string{"redirect", "status", "large", "duplicate", "invalid-utf8", "media", "duplicate-media", "compressed", "slow"} {
+ t.Run(name, func(t *testing.T) {
+ mode.Store(name)
+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
+ defer cancel()
+ if _, err := reader.Observe(ctx, "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"); err == nil {
+ t.Fatal("invalid provider response admitted")
+ }
+ })
+ }
+ if foreignCalls.Load() != 0 {
+ t.Fatal("redirect followed")
+ }
+ mode.Store("ok")
+ for _, bad := range []string{"../consume", "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee/consume", ""} {
+ if _, err := reader.Observe(context.Background(), bad); err == nil {
+ t.Fatal("request selected route")
+ }
+ }
+ for _, mode := range []os.FileMode{0460, 0644, 0620} {
+ os.Chmod(token, mode)
+ if _, err := reader.Observe(context.Background(), "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"); err == nil {
+ t.Fatal("unsafe token permissions")
+ }
+ }
+ for _, origin := range []string{"http://127.0.0.1", "https://user:password@localhost", "https://localhost/path", "https://localhost?query=1", "https://localhost#fragment"} {
+ bad := config
+ bad.Origin = origin
+ if _, err := NewApprovalReader(bad); err == nil {
+ t.Fatal("unsafe origin")
+ }
+ }
+ bad := config
+ bad.StoreTenant = "tenant:foreign"
+ if _, err := NewApprovalReader(bad); err == nil {
+ t.Fatal("foreign tenant configured")
+ }
+ if _, err := (ApprovalObservation{}).Validate([]byte(`{}`), approvalfact.Expected{}, time.Now()); err == nil {
+ t.Fatal("zero observation admitted")
+ }
+}
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
@@ -7,6 +7,7 @@
"errors"
"github.com/netkingdom/access-engine/internal/callerauth"
"github.com/netkingdom/access-engine/pkg/api"
+ "github.com/netkingdom/approval-engine/adapters/go/approvalfact"
"io"
"time"
)
@@ -15,6 +16,7 @@
const ServicePilotPackageID = "secrets-engine.task-execution.service-pilot"
const ServicePilotPackageVersion = "v1"
+const TrustedServicePilotPackageVersion = "v2"
const ServicePilotMappingVersion = "proposed-service-task-caller/v1"
const ServicePilotPrincipal = "system:serviceaccount:secrets-engine:secrets-engine"
@@ -24,7 +26,10 @@
// 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 }
+type ServicePilotInputs struct {
+ auth *callerauth.Authenticator
+ preserveNumbers bool
+}
func NewServicePilotInputs(reviewer callerauth.TokenReviewer) (*ServicePilotInputs, error) {
a, e := callerauth.New(callerauth.ModeEnforce, reviewer, "flex-auth", map[string]string{"secrets-engine": ServicePilotPrincipal}, nil)
@@ -34,12 +39,24 @@
return &ServicePilotInputs{auth: a}, nil
}
+// NewTrustedServiceInputs preserves numeric representations only for the exact
+// v2 task path; it grants no Approval-source trust or executable eligibility.
+func NewTrustedServiceInputs(reviewer callerauth.TokenReviewer) (*ServicePilotInputs, error) {
+ input, err := NewServicePilotInputs(reviewer)
+ if err != nil {
+ return nil, err
+ }
+ input.preserveNumbers = true
+ return input, nil
+}
+
// ServiceInput cannot be fabricated through exported fields. It proves only
// source input preparation through TokenReview, not approval provenance/consume
// or execution authority. Default zero value is not an authenticated input.
type ServiceInput struct {
- serialized []byte
- caller callerauth.Record
+ serialized []byte
+ caller callerauth.Record
+ preserveNumbers bool
}
func (s ServiceInput) Request() (api.CheckRequest, error) {
@@ -47,7 +64,11 @@
return api.CheckRequest{}, ErrServiceInput
}
var r api.CheckRequest
- if json.Unmarshal(s.serialized, &r) != nil {
+ decoder := json.NewDecoder(bytes.NewReader(s.serialized))
+ if s.preserveNumbers {
+ decoder.UseNumber()
+ }
+ if decoder.Decode(&r) != nil {
return r, ErrServiceInput
}
return r, nil
@@ -142,5 +163,27 @@
if err != nil {
return refuse()
}
- return ServiceInput{serialized: raw, caller: record}, nil
+ return ServiceInput{serialized: raw, caller: record, preserveNumbers: s.preserveNumbers}, nil
}
+
+func (s ServiceInput) NumberPreserved() bool { return s.preserveNumbers }
+
+// DecodeTrustedServiceRequest preserves numbers in the exact new task profile.
+// Owner helper validates strict Unicode/JSON; this is not a new canonicalizer.
+func DecodeTrustedServiceRequest(reader io.Reader) (api.CheckRequest, error) {
+ raw, err := io.ReadAll(io.LimitReader(reader, 524289))
+ if err != nil || len(raw) > 524288 {
+ return api.CheckRequest{}, ErrServiceInput
+ }
+ if _, err := approvalfact.DecodeJSON(raw); err != nil {
+ return api.CheckRequest{}, ErrServiceInput
+ }
+ var request api.CheckRequest
+ decoder := json.NewDecoder(bytes.NewReader(raw))
+ decoder.UseNumber()
+ decoder.DisallowUnknownFields()
+ if decoder.Decode(&request) != nil {
+ return api.CheckRequest{}, ErrServiceInput
+ }
+ return request, nil
+}
diff --git a/pkg/api/canonical.go b/pkg/api/canonical.go
--- a/pkg/api/canonical.go
+++ b/pkg/api/canonical.go
@@ -635,16 +635,30 @@
// DecisionProvenance captures evaluator and policy provenance.
type DecisionProvenance struct {
- Evaluator string `json:"evaluator" yaml:"evaluator"`
- Mode string `json:"mode" yaml:"mode"`
- PolicyPackage string `json:"policy_package,omitempty" yaml:"policy_package,omitempty"`
- PolicyVersion string `json:"policy_version,omitempty" yaml:"policy_version,omitempty"`
- PolicyPackageDigest string `json:"policy_package_digest,omitempty" yaml:"policy_package_digest,omitempty"`
- RegistrySnapshotDigest string `json:"registry_snapshot_digest,omitempty" yaml:"registry_snapshot_digest,omitempty"`
- DirectoryETag string `json:"directory_etag,omitempty" yaml:"directory_etag,omitempty"`
- InputClaimDigests map[string]string `json:"input_claim_digests,omitempty" yaml:"input_claim_digests,omitempty"`
- DecisionTime string `json:"decision_time,omitempty" yaml:"decision_time,omitempty"`
- Caller *CallerProvenance `json:"caller,omitempty" yaml:"caller,omitempty"`
+ Evaluator string `json:"evaluator" yaml:"evaluator"`
+ Mode string `json:"mode" yaml:"mode"`
+ PolicyPackage string `json:"policy_package,omitempty" yaml:"policy_package,omitempty"`
+ PolicyVersion string `json:"policy_version,omitempty" yaml:"policy_version,omitempty"`
+ PolicyPackageDigest string `json:"policy_package_digest,omitempty" yaml:"policy_package_digest,omitempty"`
+ RegistrySnapshotDigest string `json:"registry_snapshot_digest,omitempty" yaml:"registry_snapshot_digest,omitempty"`
+ DirectoryETag string `json:"directory_etag,omitempty" yaml:"directory_etag,omitempty"`
+ InputClaimDigests map[string]string `json:"input_claim_digests,omitempty" yaml:"input_claim_digests,omitempty"`
+ DecisionTime string `json:"decision_time,omitempty" yaml:"decision_time,omitempty"`
+ ApprovalObservation *ApprovalObservationProvenance `json:"approval_observation,omitempty" yaml:"approval_observation,omitempty"`
+ Caller *CallerProvenance `json:"caller,omitempty" yaml:"caller,omitempty"`
+}
+
+// ApprovalObservationProvenance binds fresh server-owned fact evidence without
+// rewriting the original carried claim/context or either PDP request digest.
+type ApprovalObservationProvenance struct {
+ SourceID string `json:"source_id"`
+ SourceRevision string `json:"source_revision"`
+ ConfigurationDigest string `json:"configuration_digest"`
+ ClaimDigest string `json:"claim_digest"`
+ NativeBindingDigest string `json:"native_binding_digest"`
+ ObservedAt string `json:"observed_at"`
+ NotAfter string `json:"not_after"`
+ ValidatedAt string `json:"validated_at"`
}
// CallerProvenance records how the request was authenticated to flex-auth.
diff --git a/third_party/approvalfact/README.md b/third_party/approvalfact/README.md
new file mode 100644
--- /dev/null
+++ b/third_party/approvalfact/README.md
@@ -0,0 +1,55 @@
+# Approval fact data adapter
+
+Provider-owned pure Go module for Access's Approval B2 source reader. No network,
+credentials, subprocess, policy evaluator, server or consume mutation exists here.
+Python `approval_engine.binding` remains the authoritative native format.
+
+API:
+
+- `CanonicalBinding(rawJSON)` returns native five-field canonical UTF-8 bytes
+ and sha256 digest; extra binding metadata is excluded.
+- `ValidateClaim(rawClaim, Expected, now)` validates one exact human/PDP-path
+ claim against independently owner-derived `Expected.NativeBindingJSON`, exact
+ approval ID and actual Access approval-free PDP digest.
+- `MatchObserved(submitted, observed)` requires two validated facts at the same
+ captured instant and identical nonfreshness authority facts. No request is
+ rewritten. `Fact.RawJSON()` returns a detached original byte copy; deadlines
+ preserve each input's own freshness and approval expiry.
+
+`Fact` means validated data, never authenticated provider origin. Access must
+fetch provider bytes through its independently admitted fixed origin/identity,
+then wrap this data in its own unforgeable source handle. A caller-supplied claim
+cannot become source truth merely by passing this helper. Access remains the
+single evaluator and must bound decision/operation/caller lifetimes separately.
+PEP consume CAS and live one-shot journal remain required before any effect.
+
+Expected native act bytes must preserve the actual owner/compiler/normalization
+representation. Marshaling float64-decoded CheckRequest maps loses 1.0/1 and
+large integers. Raw submitted resource is sufficient only when Access proves
+the active registry cannot override that target and applies its exact existing
+normalization. Otherwise authoritative raw enrichment/compiler evidence is
+needed. This helper supplies no fallback or invented restricted numeric domain.
+
+The strict parser retains integer/floating lexical types, arbitrary integer
+magnitudes, finite IEEE-754 float semantics and valid Unicode scalar values.
+Canonical float/string formatting follows Python source, including negative
+floating zero, exponent switching/padding, HTML and U+2028/U+2029. Duplicate
+object keys, nonfinite/overflow numbers, invalid UTF-8 and lone surrogates refuse.
+Inputs are bounded to 1 MiB and 128 container levels. Timestamp precision is at
+most nanoseconds with strict zoned RFC3339; current producer emits whole seconds.
+These explicit parser/resource limits fail closed, never normalize authority.
+
+`go test -race ./...` checks the portable producer vectors, typed fact pairing,
+strict JSON/Unicode and exact deadline semantics. From the owner repo root,
+`python3 scripts/check_go_approvalfact.py` runs a deterministic 14,114-case
+development-only differential corpus against the actual Python producer,
+including random finite IEEE bit patterns, exponent boundaries/subnormals and
+Unicode. It uses a test-only local oracle file; production imports no oracle or
+Python runtime. Sample conformance is evidence, not a formal proof over all JSON.
+
+The module pins Go 1.26.7 to match the existing Access consumer toolchain.
+`testdata/owner-vectors.json` is an unchanged portable copy of the Approval owner
+fixture, SHA256 `dfb0a8810cd91847d7af6a7af6a51607a67a66fad7b86fc57365308c36bd9323`.
+Transport cases requiring HTTP remain the Access adapter's tests; this module
+checks their duplicate/nonfinite bodies only. Import/deployment/native identity
+admission and positive policy gates require separate owner evidence.
diff --git a/third_party/approvalfact/SOURCE.json b/third_party/approvalfact/SOURCE.json
new file mode 100644
--- /dev/null
+++ b/third_party/approvalfact/SOURCE.json
@@ -0,0 +1,13 @@
+{
+ "owner": "approval-engine",
+ "module": "github.com/netkingdom/approval-engine/adapters/go/approvalfact",
+ "source_artifact": "approval-go-fact/manifest.json",
+ "review_status": "owner-frozen; independent coordinator review required",
+ "role": "pure data canonicalization and fact correspondence only; not provider authentication or PDP",
+ "files_sha256": {
+ "canonical.go": "d48588fb26e9037c8e83421586b8a818f4c0a52afb874f3fadf7229fc971c282",
+ "claim.go": "007a2494f79c47c104635402407e9e68f17d8599d23e0f2c8ebf6cb1b47e3621",
+ "go.mod": "bb09c53ffea926fbfba6e580ed3bd71ca358575401717389b3c7c49c0c674b43",
+ "README.md": "5d936b7f3de87bbf4e946f0dc44823d2f69b62b4122b55b9064791b7a19fd4f5"
+ }
+}
diff --git a/third_party/approvalfact/canonical.go b/third_party/approvalfact/canonical.go
new file mode 100644
--- /dev/null
+++ b/third_party/approvalfact/canonical.go
@@ -0,0 +1,295 @@
+// Package approvalfact provides data-only Approval claim correspondence.
+// It neither authenticates a provider nor decides permission or consumes objects.
+package approvalfact
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "io"
+ "math"
+ "math/big"
+ "sort"
+ "strconv"
+ "strings"
+ "unicode/utf8"
+)
+
+const MaxJSONBytes = 1 << 20
+
+var errJSON = errors.New("invalid Approval JSON")
+
+// DecodeJSON retains numeric lexical types, refuses duplicates and invalid
+// Unicode, and bounds work. A Go map previously decoded into float64 cannot
+// recover the producer's integer-versus-floating identity; pass source bytes.
+func DecodeJSON(raw []byte) (any, error) {
+ if len(raw) > MaxJSONBytes || !utf8.Valid(raw) || !validSurrogates(raw) {
+ return nil, errJSON
+ }
+ d := json.NewDecoder(bytes.NewReader(raw))
+ d.UseNumber()
+ var read func(int) (any, error)
+ read = func(depth int) (any, error) {
+ if depth > 128 {
+ return nil, errJSON
+ }
+ t, e := d.Token()
+ if e != nil {
+ return nil, errJSON
+ }
+ switch x := t.(type) {
+ case json.Delim:
+ if x == '{' {
+ m := map[string]any{}
+ for d.More() {
+ k, e := d.Token()
+ if e != nil {
+ return nil, errJSON
+ }
+ s, ok := k.(string)
+ if !ok {
+ return nil, errJSON
+ }
+ if _, ok = m[s]; ok {
+ return nil, errJSON
+ }
+ v, e := read(depth + 1)
+ if e != nil {
+ return nil, e
+ }
+ m[s] = v
+ }
+ end, e := d.Token()
+ if e != nil || end != json.Delim('}') {
+ return nil, errJSON
+ }
+ return m, nil
+ }
+ if x == '[' {
+ a := []any{}
+ for d.More() {
+ v, e := read(depth + 1)
+ if e != nil {
+ return nil, e
+ }
+ a = append(a, v)
+ }
+ end, e := d.Token()
+ if e != nil || end != json.Delim(']') {
+ return nil, errJSON
+ }
+ return a, nil
+ }
+ return nil, errJSON
+ case json.Number:
+ if _, e := pythonNumber(x); e != nil {
+ return nil, e
+ }
+ return x, nil
+ case string, bool, nil:
+ return x, nil
+ default:
+ return nil, errJSON
+ }
+ }
+ v, e := read(0)
+ if e != nil {
+ return nil, e
+ }
+ if _, e = d.Token(); e != io.EOF {
+ return nil, errJSON
+ }
+ return v, nil
+}
+
+// encoding/json replaces lone UTF-16 surrogates, whereas the Python producer
+// cannot encode them as UTF-8. Reject before the decoder can erase that fact.
+func validSurrogates(raw []byte) bool {
+ in := false
+ for i := 0; i < len(raw); i++ {
+ if raw[i] == '"' {
+ in = !in
+ continue
+ }
+ if !in || raw[i] != '\\' {
+ continue
+ }
+ i++
+ if i >= len(raw) {
+ return false
+ }
+ if raw[i] != 'u' {
+ continue
+ }
+ if i+4 >= len(raw) {
+ return false
+ }
+ n, e := strconv.ParseUint(string(raw[i+1:i+5]), 16, 16)
+ if e != nil {
+ return false
+ }
+ i += 4
+ if n >= 0xdc00 && n <= 0xdfff {
+ return false
+ }
+ if n < 0xd800 || n > 0xdbff {
+ continue
+ }
+ if i+6 >= len(raw) || raw[i+1] != '\\' || raw[i+2] != 'u' {
+ return false
+ }
+ low, e := strconv.ParseUint(string(raw[i+3:i+7]), 16, 16)
+ if e != nil || low < 0xdc00 || low > 0xdfff {
+ return false
+ }
+ i += 6
+ }
+ return !in
+}
+
+func pythonNumber(n json.Number) (string, error) {
+ s := string(n)
+ if !strings.ContainsAny(s, ".eE") {
+ i, ok := new(big.Int).SetString(s, 10)
+ if !ok {
+ return "", errJSON
+ }
+ return i.String(), nil
+ }
+ f, e := strconv.ParseFloat(s, 64)
+ if e != nil || math.IsInf(f, 0) || math.IsNaN(f) {
+ return "", errJSON
+ }
+ // Python repr uses shortest round-trip digits, fixed form for exponents
+ // -4..15, a mandatory .0 for integral floats, and two exponent digits.
+ sci := strconv.FormatFloat(f, 'e', -1, 64)
+ at := strings.IndexByte(sci, 'e')
+ exp, _ := strconv.Atoi(sci[at+1:])
+ if exp >= -4 && exp < 16 {
+ s = strconv.FormatFloat(f, 'f', -1, 64)
+ if !strings.Contains(s, ".") {
+ s += ".0"
+ }
+ return s, nil
+ }
+ sign := "+"
+ if exp < 0 {
+ sign = "-"
+ exp = -exp
+ }
+ digits := strconv.Itoa(exp)
+ if len(digits) < 2 {
+ digits = "0" + digits
+ }
+ return sci[:at] + "e" + sign + digits, nil
+}
+
+func quote(s string) string {
+ var b strings.Builder
+ b.WriteByte('"')
+ for _, r := range s {
+ switch r {
+ case '"', '\\':
+ b.WriteByte('\\')
+ b.WriteRune(r)
+ case '\b':
+ b.WriteString(`\b`)
+ case '\f':
+ b.WriteString(`\f`)
+ case '\n':
+ b.WriteString(`\n`)
+ case '\r':
+ b.WriteString(`\r`)
+ case '\t':
+ b.WriteString(`\t`)
+ default:
+ if r < 32 {
+ b.WriteString(`\u00`)
+ const h = "0123456789abcdef"
+ b.WriteByte(h[byte(r)>>4])
+ b.WriteByte(h[byte(r)&15])
+ } else {
+ b.WriteRune(r)
+ }
+ }
+ }
+ b.WriteByte('"')
+ return b.String()
+}
+
+func canonical(v any) (string, error) {
+ switch x := v.(type) {
+ case nil:
+ return "null", nil
+ case bool:
+ if x {
+ return "true", nil
+ }
+ return "false", nil
+ case string:
+ return quote(x), nil
+ case json.Number:
+ return pythonNumber(x)
+ case []any:
+ a := make([]string, len(x))
+ for i, v := range x {
+ s, e := canonical(v)
+ if e != nil {
+ return "", e
+ }
+ a[i] = s
+ }
+ return "[" + strings.Join(a, ",") + "]", nil
+ case map[string]any:
+ keys := make([]string, 0, len(x))
+ for k := range x {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ a := make([]string, 0, len(keys))
+ for _, k := range keys {
+ s, e := canonical(x[k])
+ if e != nil {
+ return "", e
+ }
+ a = append(a, quote(k)+":"+s)
+ }
+ return "{" + strings.Join(a, ",") + "}", nil
+ default:
+ return "", errJSON
+ }
+}
+
+// CanonicalBinding reproduces approval_engine.binding.binding_digest over the
+// five native act fields. Extra binding metadata is excluded by that contract.
+func CanonicalBinding(raw []byte) ([]byte, string, error) {
+ v, e := DecodeJSON(raw)
+ if e != nil {
+ return nil, "", e
+ }
+ m, ok := v.(map[string]any)
+ if !ok {
+ return nil, "", errJSON
+ }
+ act := map[string]any{}
+ for _, k := range []string{"action", "actor", "principal", "purpose"} {
+ s, ok := m[k].(string)
+ if !ok || s == "" {
+ return nil, "", errJSON
+ }
+ act[k] = s
+ }
+ target, ok := m["target"].(map[string]any)
+ if !ok {
+ return nil, "", errJSON
+ }
+ act["target"] = target
+ s, e := canonical(act)
+ if e != nil {
+ return nil, "", e
+ }
+ sum := sha256.Sum256([]byte(s))
+ return []byte(s), "sha256:" + hex.EncodeToString(sum[:]), nil
+}
diff --git a/third_party/approvalfact/claim.go b/third_party/approvalfact/claim.go
new file mode 100644
--- /dev/null
+++ b/third_party/approvalfact/claim.go
@@ -0,0 +1,170 @@
+package approvalfact
+
+import (
+ "encoding/json"
+ "errors"
+ "regexp"
+ "strconv"
+ "time"
+)
+
+var errClaim = errors.New("Approval fact does not match required claim")
+var digestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
+var uuidPattern = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
+var timePattern = regexp.MustCompile(`^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,9})?(Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])$`)
+
+// Expected contains independently owner-derived native act bytes and the actual
+// Access approval-free digest. Marshaling an already float64-decoded Go request
+// is not an authoritative reconstruction of the native act.
+type Expected struct {
+ ApprovalID string
+ NativeBindingJSON []byte
+ PDPDigest string
+}
+
+// Fact is validated data shape/correspondence, NOT authenticated provider origin
+// or execution authority. Access must wrap it only after its own source admission.
+type Fact struct {
+ raw []byte
+ stable string
+ digest string
+ observed, timeLimit, validatedAt time.Time
+}
+
+func (f Fact) RawJSON() []byte { return append([]byte(nil), f.raw...) }
+func (f Fact) ObservedAt() time.Time { return f.observed }
+func (f Fact) Deadline() time.Time { return f.timeLimit }
+func (f Fact) NativeDigest() string { return f.digest }
+
+func members(m map[string]any, required, optional []string) bool {
+ allowed := map[string]bool{}
+ for _, k := range required {
+ allowed[k] = true
+ if _, ok := m[k]; !ok {
+ return false
+ }
+ }
+ for _, k := range optional {
+ allowed[k] = true
+ }
+ for k := range m {
+ if !allowed[k] {
+ return false
+ }
+ }
+ return true
+}
+func object(v any) (map[string]any, bool) { m, ok := v.(map[string]any); return m, ok }
+func stamp(v any) (time.Time, error) {
+ s, ok := v.(string)
+ if !ok || !timePattern.MatchString(s) {
+ return time.Time{}, errClaim
+ }
+ t, e := time.Parse(time.RFC3339Nano, s)
+ if e != nil {
+ return time.Time{}, errClaim
+ }
+ return t, nil
+}
+
+// ValidateClaim checks one original or fetched schema-0.1 human/PDP-path claim
+// at a caller-captured instant, using the existing Approval producer semantics.
+// It has no I/O, policy evaluator, credentials, cache, or consume mutation.
+func ValidateClaim(raw []byte, expected Expected, now time.Time) (Fact, error) {
+ if now.IsZero() || !uuidPattern.MatchString(expected.ApprovalID) || !digestPattern.MatchString(expected.PDPDigest) {
+ return Fact{}, errClaim
+ }
+ _, want, e := CanonicalBinding(expected.NativeBindingJSON)
+ if e != nil {
+ return Fact{}, errClaim
+ }
+ v, e := DecodeJSON(raw)
+ if e != nil {
+ return Fact{}, errClaim
+ }
+ m, ok := object(v)
+ if !ok || !members(m, []string{"schema_version", "kind", "issuer", "approval_id", "state", "valid_now", "consumed", "binding", "freshness", "validity", "reason_code"}, []string{"yields_to"}) {
+ return Fact{}, errClaim
+ }
+ if y, ok := m["yields_to"]; ok {
+ if _, ok = y.(string); !ok {
+ return Fact{}, errClaim
+ }
+ }
+ if m["schema_version"] != "0.1" || m["kind"] != "approval-claim" || m["issuer"] != "approval-engine" || m["approval_id"] != expected.ApprovalID || m["state"] != "valid" || m["valid_now"] != true || m["consumed"] != false || m["reason_code"] != "ok" {
+ return Fact{}, errClaim
+ }
+ b, ok := object(m["binding"])
+ if !ok || !members(b, []string{"action", "actor", "principal", "purpose", "target", "digest", "pdp_digest", "pdp_path", "human_control"}, nil) {
+ return Fact{}, errClaim
+ }
+ if b["digest"] != want || b["pdp_digest"] != expected.PDPDigest || b["pdp_path"] != true || b["human_control"] != true {
+ return Fact{}, errClaim
+ }
+ text, e := canonical(b)
+ if e != nil {
+ return Fact{}, errClaim
+ }
+ _, got, e := CanonicalBinding([]byte(text))
+ if e != nil || got != want {
+ return Fact{}, errClaim
+ }
+ f, ok := object(m["freshness"])
+ if !ok || !members(f, []string{"observed_at", "ttl_seconds", "not_after"}, nil) {
+ return Fact{}, errClaim
+ }
+ observed, e := stamp(f["observed_at"])
+ if e != nil {
+ return Fact{}, e
+ }
+ notAfter, e := stamp(f["not_after"])
+ if e != nil {
+ return Fact{}, e
+ }
+ ttl, ok := f["ttl_seconds"].(json.Number)
+ if !ok {
+ return Fact{}, errClaim
+ }
+ seconds, e := strconv.ParseInt(string(ttl), 10, 64)
+ if e != nil || seconds < 1 {
+ return Fact{}, errClaim
+ }
+ // Bound arithmetic to time.Duration's representable seconds, not a new
+ // semantic freshness grant or configured TTL. Unsupported extremes refuse.
+ if seconds > int64((1<<63-1)/int64(time.Second)) || !observed.Add(time.Duration(seconds)*time.Second).Equal(notAfter) || now.Before(observed) || !now.Before(notAfter) {
+ return Fact{}, errClaim
+ }
+ validity, ok := object(m["validity"])
+ if !ok || !members(validity, []string{"not_before", "expires_at"}, nil) {
+ return Fact{}, errClaim
+ }
+ before, e := stamp(validity["not_before"])
+ if e != nil {
+ return Fact{}, e
+ }
+ expires, e := stamp(validity["expires_at"])
+ if e != nil || now.Before(before) || !now.Before(expires) {
+ return Fact{}, errClaim
+ }
+ limit := notAfter
+ if expires.Before(limit) {
+ limit = expires
+ }
+ delete(m, "freshness")
+ delete(m, "yields_to")
+ stable, e := canonical(m)
+ if e != nil {
+ return Fact{}, errClaim
+ }
+ return Fact{raw: append([]byte(nil), raw...), stable: stable, digest: want, observed: observed, timeLimit: limit, validatedAt: now}, nil
+}
+
+// MatchObserved requires independently validated original/fetched facts at the
+// same captured instant and exact nonfreshness authority correspondence. It
+// never rewrites the original request or extends its original deadline.
+func MatchObserved(submitted, observed Fact) error {
+ if submitted.stable == "" || observed.stable == "" || !submitted.validatedAt.Equal(observed.validatedAt) || submitted.stable != observed.stable {
+ return errClaim
+ }
+ return nil
+}
diff --git a/third_party/approvalfact/go.mod b/third_party/approvalfact/go.mod
new file mode 100644
--- /dev/null
+++ b/third_party/approvalfact/go.mod
@@ -0,0 +1,3 @@
+module github.com/netkingdom/approval-engine/adapters/go/approvalfact
+
+go 1.26.7
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
@@ -237,3 +237,14 @@
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.
+
+2026-10-16 B2/B3 source successor preparation (separate from frozen B1): the
+isolated v2 source path now joins the fixed authenticated Approval reader,
+unchanged Approval-owner canonical/fact helper, original/fresh correspondence,
+raw numeric request/registry source retention, same policy evaluator and signed
+source provenance. Disposable actual HTTP and production serve subprocess prove
+synthetic signed ALLOW and fail-closed negative joins with exact response/record
+parity. See `docs/trusted-service-task-source.md`. T06 remains `progress`: this
+source proposal requires independent cross-owner review and full consumer/Platform
+successor integration, then separately admitted runtime custody, source trust,
+image provenance and rollout. No source-only proof establishes those runtime gates.