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