Bind trusted task approval to exact producer facts and lifetime
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Container Image / build-and-push (push) Failing after 1m10s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a140c4-354a-7823-8f0d-8f717397f786
This commit is contained in:
tegwick 2026-10-16 17:27:30 +02:00
parent 4f6946bbb3
commit 2b064eee51
31 changed files with 2170 additions and 30 deletions

View file

@ -19,6 +19,7 @@ on:
- "cmd/**"
- "internal/**"
- "pkg/**"
- "third_party/**"
- "examples/**"
- "go.mod"
- "go.sum"

View file

@ -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

View file

@ -333,6 +333,7 @@ func runServe(args []string, stdout, stderr io.Writer) int {
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 @@ func runServe(args []string, stdout, stderr io.Writer) int {
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 newServeMuxWithCallerAuth(engine *decisioncore.Engine, authenticator *calle
}
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 @@ func newServeMuxWithServicePilotInputs(engine *decisioncore.Engine, authenticato
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 @@ func newServeMuxWithServicePilotInputs(engine *decisioncore.Engine, authenticato
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 @@ func newServeMuxWithServicePilotInputs(engine *decisioncore.Engine, authenticato
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 @@ func newServeMuxWithServicePilotInputs(engine *decisioncore.Engine, authenticato
// 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 {
@ -547,6 +572,9 @@ func buildServicePilotInputs(engine *decisioncore.Engine, mode callerauth.Mode,
if err != nil {
return nil, err
}
if engine.IsTrustedServicePilot() {
return taskexecution.NewTrustedServiceInputs(reviewer)
}
return taskexecution.NewServicePilotInputs(reviewer)
}
@ -575,11 +603,15 @@ func writeCallerAuthError(w http.ResponseWriter, err error) {
}
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
}

View file

@ -0,0 +1,430 @@
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.Context["execution_contract"].(map[string]any)["operation_deadline"] = now.Add(60 * time.Second).UTC().Truncate(time.Second).Format(time.RFC3339)
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("30"), "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("120"), "not_after": now.Add(119 * 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) { testTrustedServiceHTTP(t, false) }
func TestTrustedServiceRetainedRegistryPreapprovalAndRefusals(t *testing.T) {
testTrustedServiceHTTP(t, true)
}
func testTrustedServiceHTTP(t *testing.T, retained bool) {
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)
if retained {
delete(original.Context, "approval")
}
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 "short-fresh":
v, _ := approvalfact.DecodeJSON(claimRaw)
fresh := v.(map[string]any)["freshness"].(map[string]any)
fresh["ttl_seconds"] = json.Number("20")
fresh["not_after"] = now.Add(19 * time.Second).Format(time.RFC3339Nano)
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)
}
store := registry.NewStore()
if retained {
store, err = registry.LoadFilePreservingNumbers(filepath.Join("..", "..", "examples", "secrets-engine", "registry_snapshot.json"))
if err != nil {
t.Fatal(err)
}
}
engine, err := decision.NewEngine(store, 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
}
var pre api.DecisionEnvelope
if retained {
preStatus, preRaw := post(original)
if preStatus != 200 {
t.Fatalf("preapproval status%d", preStatus)
}
pre, err = sign.DecodeEnvelope(preRaw)
if err != nil || sign.Verify(pre, map[string]ed25519.PublicKey{"synthetic-v2": key.Public().(ed25519.PublicKey)}) != nil {
t.Fatal("preapproval not authenticated")
}
if pre.Effect != api.DecisionEffectDeny || pre.Lifetime != nil || pre.Binding.ApprovalBindingDigest == "" || pre.Binding.ApprovalBindingDigest != pre.Binding.RequestDigest {
t.Fatal("preapproval receipt is execution authority or lacks exactclaimfree digest")
}
if pre.Diagnostics["registry_subject"] != true || pre.Provenance.RegistrySnapshotDigest != store.Digest() {
t.Fatal("retained registry not applied")
}
rawOriginal, _ := json.Marshal(original)
var legacyPre api.CheckRequest
json.Unmarshal(rawOriginal, &legacyPre)
if pre.Binding.SubmittedRequestDigest != api.RequestDigest(legacyPre) || pre.Binding.RequestDigest == api.RequestDigest(legacyPre) {
t.Fatal("original/evaluated retained registry distinction lost")
}
carried := trustedRequest(t, now).Context["approval"].(map[string]any)
native := map[string]any{"action": pre.Binding.Action, "actor": pre.Binding.Subject.ID, "principal": "secrets-engine", "purpose": pre.Binding.Context["purpose"], "target": pre.Binding.Resource}
nativeRaw, _ := json.Marshal(native)
_, nativeDigest, err := approvalfact.CanonicalBinding(nativeRaw)
if err != nil {
t.Fatal(err)
}
native["digest"] = nativeDigest
native["pdp_digest"] = pre.Binding.ApprovalBindingDigest
native["pdp_path"] = true
native["human_control"] = true
carried["binding"] = native
original.Context["approval"] = carried
claimRaw, _ = json.Marshal(carried)
originalBytes, _ := json.Marshal(original)
original, err = taskexecution.DecodeTrustedServiceRequest(bytes.NewReader(originalBytes))
if err != nil {
t.Fatal(err)
}
}
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) {
t.Fatal("original submitted digest changed")
}
if retained {
if envelope.Binding.RequestDigest == api.RequestDigest(legacyRequest) || envelope.Binding.ApprovalBindingDigest != pre.Binding.ApprovalBindingDigest {
t.Fatal("normalized approval correspondence changed")
}
} else if envelope.Binding.RequestDigest != api.RequestDigest(legacyRequest) || envelope.Binding.ApprovalBindingDigest != api.ApprovalBindingDigest(legacyRequest) {
t.Fatal("published digest profile changed")
}
if !reflect.DeepEqual(envelope.Binding.Context, original.Context) {
t.Fatal("original context changed")
}
if envelope.Lifetime.ExpiresAt != original.Context["execution_contract"].(map[string]any)["operation_deadline"] {
t.Fatal("decision does not bound entire exact operation")
}
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)
}
records, err := audit.NewJSONLDecisionLog(record).ReadAll()
expectedCount := 1
if retained {
expectedCount = 2
}
if err != nil || len(records) != expectedCount || !reflect.DeepEqual(records[len(records)-1], envelope) {
t.Fatal("returned record parity")
}
if retained && !reflect.DeepEqual(records[0], pre) {
t.Fatal("preapproval record parity")
}
for _, failure := range []string{"redirect", "duplicate", "changed", "expired", "short-fresh"} {
t.Run(failure, func(t *testing.T) {
mode = failure
status, _ := post(original)
if status == 200 {
t.Fatal("bad source admitted")
}
})
}
mode = "ok"
if retained {
t.Run("registry-drift-cannot-rebind-approved-act", func(t *testing.T) {
subject, _ := store.Subject("secrets-engine")
originalSubject := subject
subject.Roles = append(append([]api.CanonicalRole{}, subject.Roles...), api.CanonicalRole("synthetic-owner-drift"))
if err := store.PutSubject(subject); err != nil {
t.Fatal(err)
}
defer store.PutSubject(originalSubject)
if status, _ := post(original); status == 200 {
t.Fatal("changed registry silently rebound original/fresh claim")
}
})
}
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(-121 * 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")
registryBytes := []byte(`{}`)
if retained {
registryBytes, err = os.ReadFile(filepath.Join("..", "..", "examples", "secrets-engine", "registry_snapshot.json"))
if err != nil {
t.Fatal(err)
}
}
os.WriteFile(registryPath, registryBytes, 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")
}
}

View file

@ -0,0 +1,108 @@
# 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 exactly the immutable operation deadline, after validating that
it fits the source-selected 120s hard maximum and every caller/original/fresh
freshness/approval bound. At least the declared backend TTL plus the existing
5s cleanup reserve must still fit after source I/O and final evaluation. The
source maximum conservatively matches the existing 120s task-token cap; the
structural backend budget maximum is not a promise every budget can fit. In
particular backend TTL120 cannot fit this authority ceiling plus reserve. A
fractional operation deadline is retained exactly rather than rounded earlier.
Approval's default 30s observation freshness cannot cover the minimum30+5
operation budget. A separate owner-admitted source configuration must explicitly
supply adequate original/fresh observation lifetime; fetching anew cannot renew
an original claim. The disposable reviewed proof uses backendTTL30, immutable
operationdeadline+60s and explicitly configured Approval freshness120. This is
an entire-operation authority interval, not a decision-to-start window. Platform
must retain native-entry and per-call deadline/cleanup gates; delays simply refuse. 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.
## Producer normalization before Approval issue
The retained registry enriches Secrets subject attributes. Consequently the
submitted approval-free digest is not the evaluated approval-free digest. This
is the existing `docs/request-enrichment.md` contract, not a reason to remove
registry facts or copy its enrichment rules into Secrets.
The existing `/v1/check` typed v2 path accepts the exact original claim-free
proposal and returns a signed DENY containing the authoritative normalized
binding and original `submitted_request_digest`. For this v2 path it also emits
`approval_binding_digest` when claim-free, equal to evaluated `request_digest`.
That is a normalization receipt supplying facts only. It has no ALLOW lifetime
and cannot satisfy execution authorization. Before issue, Secrets authenticates
this receipt using its existing independent producer trust/policy/source pins,
checks original request/context correspondence, caller/deadline/current-time
bounds and normalized self-digests, then derives the native act and issue digest
from the authenticated producer binding. It does not supply expected registry
facts or expected digests to the producer. The Approval API records these exact
facts at issue; human approval follows its unchanged authenticated contract.
The submitted proposal remains unchanged; the final request attaches only the
original issued claim. Final ALLOW rechecks independent source facts, native/PDP
correspondence, normalization and all temporal/caller conditions. A changed
registry cannot silently refresh or replace an approved act: a changed evaluated
digest/native target refuses even when the freshly fetched claim is still valid.
The source tests cover the retained registry, signed DENY/ALLOW separation,
original/evaluated digest distinction, unchanged context, source/registry pins,
registry drift, stale/refreshed claims and exact response/record parity. Earlier
empty-registry proofs remain explicitly scoped historical source fixtures.
R3 retains precise decision `not_before`; it does not floor that lower bound to
mask Approval's current whole-second consumption receipt defect. Approval-owner
precision repair and full owner integration remain separate reviewed evidence.

View file

@ -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}

View file

@ -0,0 +1,120 @@
---
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: 120s
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.
Its 120s declared TTL is a hard source ceiling, not a blanket grant. The typed
path derives actual expiry exactly from the immutable operation deadline and
requires the entire declared backend TTL plus the existing 5s cleanup reserve
within original/fresh claim, approval and caller bounds. Budgets that cannot fit
refuse; the 120s structural backend maximum is not guaranteed admissible.
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()
time.parse_rfc3339_ns(c.operation_deadline) <= time.now_ns() + 120000000000
time.parse_rfc3339_ns(c.operation_deadline) >= time.now_ns() + (c.backend_budget.ttl_seconds + 5) * 1000000000
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)
time.parse_rfc3339_ns(input.context.approval.freshness.not_after) >= 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}}
}
```

View file

@ -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}

3
go.mod
View file

@ -3,6 +3,7 @@ module github.com/netkingdom/access-engine
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 @@ require (
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

View file

@ -8,6 +8,7 @@ import (
"path/filepath"
"sync"
"github.com/netkingdom/access-engine/internal/sign"
"github.com/netkingdom/access-engine/pkg/api"
)
@ -67,8 +68,8 @@ func (l *JSONLDecisionLog) ReadAll() ([]api.DecisionEnvelope, error) {
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)

View file

@ -158,6 +158,10 @@ func (e *Engine) decide(ctx context.Context, request api.CheckRequest) (api.Deci
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 @@ func (e *Engine) CheckServicePilot(ctx context.Context, input taskexecution.Serv
// 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 @@ 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" {
if e.IsTaskPin() || request.Action == "execute-ccr" {
return nil, ErrServicePilotUnavailable
}
decisions := make([]api.DecisionEnvelope, 0, len(request.Resources))
@ -232,7 +236,7 @@ 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" {
if e.IsTaskPin() || request.Action == "execute-ccr" {
return nil, ErrServicePilotUnavailable
}
candidates := e.store.ResourceRefs(request.System, request.ResourceType)

View file

@ -0,0 +1,193 @@
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
}
native, err := nativeServiceAct(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
}
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)
// Claim-free signed DENY is the producer-owned normalization receipt used
// before Approval issue. It supplies facts only, never execution authority.
envelope.Binding.ApprovalBindingDigest = api.ApprovalBindingDigest(evaluated)
envelope.Provenance.ApprovalObservation = approvalEvidence
if envelope.Effect == api.DecisionEffectAllow {
if approvalEvidence == nil || envelope.Lifetime == nil {
return api.DecisionEnvelope{}, ErrServicePilotUnavailable
}
lifetime, err := trustedTaskLifetime(contract, operationEnd, deadline, now, e.policy.Metadata.AllowTTL)
if err != nil {
return api.DecisionEnvelope{}, err
}
envelope.Lifetime = lifetime
}
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
}
// The source ceiling is deliberately no larger than the existing task-token
// ceiling. Structural backend budgets do not guarantee an executable window.
const trustedTaskMaxAuthority = 120 * time.Second
const trustedTaskCleanupReserve = 5 * time.Second
// trustedTaskLifetime authorizes the entire exact operation, not merely its
// start. It cannot extend any caller, original/fresh fact, or approval bound.
func trustedTaskLifetime(contract map[string]any, operationEnd, authorityEnd, now time.Time, declared string) (*api.DecisionLifetime, error) {
maximum, err := api.ParseAllowTTL(declared)
if err != nil || maximum <= 0 || maximum > trustedTaskMaxAuthority || now.IsZero() || operationEnd.After(authorityEnd) || !operationEnd.After(now) || operationEnd.Sub(now) > maximum {
return nil, ErrServicePilotUnavailable
}
budget, ok := contract["backend_budget"].(map[string]any)
if !ok {
return nil, ErrServicePilotUnavailable
}
number, ok := budget["ttl_seconds"].(json.Number)
if !ok {
return nil, ErrServicePilotUnavailable
}
seconds, err := number.Int64()
if err != nil || seconds < 30 || seconds > 120 {
return nil, ErrServicePilotUnavailable
}
remaining := operationEnd.Sub(now)
if remaining < time.Duration(seconds)*time.Second+trustedTaskCleanupReserve {
return nil, ErrServicePilotUnavailable
}
return &api.DecisionLifetime{Kind: api.DecisionLifetimeTTL, TTL: remaining.String(), NotBefore: now.UTC().Format(time.RFC3339Nano), ExpiresAt: operationEnd.UTC().Format(time.RFC3339Nano)}, nil
}

View file

@ -0,0 +1,56 @@
package decision
import (
"encoding/json"
"testing"
"time"
)
func TestTrustedTaskWholeOperationLifetime(t *testing.T) {
now := time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC)
contract := func(ttl string) map[string]any {
return map[string]any{"backend_budget": map[string]any{"ttl_seconds": json.Number(ttl), "uses": json.Number("12")}}
}
end := now.Add(60 * time.Second)
lifetime, err := trustedTaskLifetime(contract("30"), end, now.Add(120*time.Second), now, "120s")
if err != nil {
t.Fatal(err)
}
if lifetime.ExpiresAt != end.Format(time.RFC3339) || lifetime.TTL != "1m0s" || lifetime.NotBefore != now.Format(time.RFC3339) {
t.Fatal("lifetime not derived from exact operation")
}
fractional := end.Add(123456789 * time.Nanosecond)
lifetime, err = trustedTaskLifetime(contract("30"), fractional, fractional, now, "120s")
if err != nil || lifetime.ExpiresAt != fractional.Format(time.RFC3339Nano) {
t.Fatal("exact fractional deadline shortened or extended")
}
cases := []struct {
name string
ttl string
end, authority, time time.Time
maximum string
}{
{"old-15s-conflict", "30", end, end, now, "15s"},
{"insufficient-cleanup-reserve", "30", now.Add(35*time.Second - time.Nanosecond), end, now, "120s"},
{"elapsed-consume-setup", "30", end, end, now.Add(26 * time.Second), "120s"},
{"expired-operation", "30", now, now, now, "120s"},
{"source-hard-maximum", "30", now.Add(120*time.Second + time.Nanosecond), now.Add(130 * time.Second), now, "120s"},
{"attempted-policy-cap-bypass", "30", end, end, now, "121s"},
{"shorter-original-or-fresh-fact", "30", end, end.Add(-time.Nanosecond), now, "120s"},
{"structural-120-cannot-fit", "120", now.Add(120 * time.Second), now.Add(120 * time.Second), now, "120s"},
{"noninteger-structural-ttl", "30.0", end, end, now, "120s"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if _, err := trustedTaskLifetime(contract(c.ttl), c.end, c.authority, c.time, c.maximum); err == nil {
t.Fatal("invalid temporal authority accepted")
}
})
}
if _, err := trustedTaskLifetime(contract("30"), now.Add(35*time.Second), end, now, "120s"); err != nil {
t.Fatal("exact required lifetime refused")
}
if _, err := trustedTaskLifetime(contract("115"), now.Add(120*time.Second), now.Add(120*time.Second), now, "120s"); err != nil {
t.Fatal("max fitting declared budget refused")
}
}

View file

@ -112,7 +112,9 @@ func (o *Outbox) nextEntry() (entry, int64, error) {
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 {

View file

@ -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")
}
}

View file

@ -136,7 +136,9 @@ func (o *Outbox) load() error {
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 {

View file

@ -1,6 +1,7 @@
package policy
import (
"bytes"
"context"
"encoding/json"
"fmt"
@ -375,7 +376,25 @@ func (p *Package) runFixtures(ctx context.Context) []FixtureResult {
}
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
}

View file

@ -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")
}
}
}

View file

@ -1,6 +1,7 @@
package registry
import (
"bytes"
"encoding/json"
"fmt"
"os"
@ -8,10 +9,12 @@ import (
"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 @@ func LoadFile(path string) (*Store, error) {
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 @@ func sortedValues[T any](items map[string]T) []T {
}
return out
}
// NumbersPreserved reports the source profile of file-backed registry facts.
func (s *Store) NumbersPreserved() bool { return s != nil && s.numbersPreserved }

View file

@ -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")
}
}
}

View file

@ -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 @@ func Verify(envelope api.DecisionEnvelope, keys map[string]ed25519.PublicKey) er
}
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
}

View file

@ -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
}

View file

@ -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")
}
}

View file

@ -7,6 +7,7 @@ import (
"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 @@ var ErrServiceInput = errors.New("service task execution input refused")
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 @@ const ServicePilotPrincipal = "system:serviceaccount:secrets-engine:secrets-engi
// 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 @@ func NewServicePilotInputs(reviewer callerauth.TokenReviewer) (*ServicePilotInpu
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 @@ func (s ServiceInput) Request() (api.CheckRequest, error) {
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 @@ func (s *ServicePilotInputs) Prepare(ctx context.Context, authorization string,
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
}

View file

@ -635,16 +635,30 @@ type Obligation struct {
// 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.

55
third_party/approvalfact/README.md vendored Normal file
View file

@ -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.

13
third_party/approvalfact/SOURCE.json vendored Normal file
View file

@ -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"
}
}

295
third_party/approvalfact/canonical.go vendored Normal file
View file

@ -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
}

170
third_party/approvalfact/claim.go vendored Normal file
View file

@ -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
}

3
third_party/approvalfact/go.mod vendored Normal file
View file

@ -0,0 +1,3 @@
module github.com/netkingdom/approval-engine/adapters/go/approvalfact
go 1.26.7

View file

@ -239,3 +239,36 @@ source evidence only. B2 depends on independently reviewed Approval fact contrac
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.
2026-10-16 B2/B3 R2 temporal correction (R1 retained): whole-stack review exposed
that R1's 15s ALLOW and default Approval freshness30 cannot fit the declared
minimum backendTTL30 plus existing cleanupreserve5. The v2 successor now derives
exact ALLOW expiry from the immutable operationdeadline, requiring all original/
fresh approval/freshness/caller bounds, a conservative source maximum120 and
remaining declaredTTL+5 after source I/O/evaluation. No consumer/native deadline
or cleanup guard is relaxed. Max structural budgets that cannot fit refuse.
The fixture declares backendTTL30/deadline60 and explicit source freshness120;
owner runtime source freshness configuration and full native integration remain
separate reviewed gates. See docs/trusted-service-task-source.md.
2026-10-16 B2/B3 R3 follows actual retained-registry integration failure: subject
enrichment changed evaluated approval-free digest from the original proposal.
The existing signed claim-free DENY now explicitly carries v2 approvalfree digest
(equal evaluated requestdigest), enabling producer-owned preapproval facts under
the already published enrichment contract. The final original proposal adds only
the issued claim; registry drift/refreshed facts cannot silently rebind it.
Retained-registry HTTP and production serve proofs and negative joins pass.
R3 preserves precise notbefore pending separate Approval-owner timestamp fidelity
repair; no rounding waiver, copied registry algorithm or empty-registry workaround.