diff --git a/.forgejo/workflows/image.yaml b/.forgejo/workflows/image.yaml index f77cfb1..447b65f 100644 --- a/.forgejo/workflows/image.yaml +++ b/.forgejo/workflows/image.yaml @@ -19,6 +19,7 @@ on: - "cmd/**" - "internal/**" - "pkg/**" + - "third_party/**" - "examples/**" - "go.mod" - "go.sum" diff --git a/Containerfile b/Containerfile index f42a559..56ba921 100644 --- 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 index b8a0ff9..5da62ba 100644 --- a/cmd/access-engine/main.go +++ b/cmd/access-engine/main.go @@ -333,6 +333,7 @@ func runServe(args []string, stdout, stderr io.Writer) int { logPath := fs.String("log", "", "optional JSONL decision log path") signingKeyFile := fs.String("envelope-signing-key-file", "", "owner-mounted base64url Ed25519 seed file") signingKID := fs.String("envelope-signing-kid", "", "independently admitted envelope signing key id") + approvalSourceFile := fs.String("approval-source-file", "", "owner fixed Approval HTTPS reader configuration file") outboxDir := fs.String("outbox-dir", "", "durable emission outbox directory (FLEX-WP-0031); exclusive with --log") emissionSource := fs.String("emission-source", "", "audit-core source name for emitted decision events, e.g. flex-auth.tenant-engine") auditURL := fs.String("audit-url", "", "admitted audit-core /v1/events endpoint; empty disables drain and heartbeats") @@ -402,7 +403,19 @@ func runServe(args []string, stdout, stderr io.Writer) int { if err != nil { return fail(stderr, err) } - mux := newServeMuxWithServicePilotInputs(engine, authenticator, serviceInputs) + var approvalReader *taskexecution.ApprovalReader + if engine.IsTrustedServicePilot() { + if *signingKeyFile == "" || *approvalSourceFile == "" { + return fail(stderr, fmt.Errorf("trusted task source requires signing and fixed Approval reader configuration")) + } + approvalReader, err = taskexecution.LoadApprovalReader(*approvalSourceFile) + if err != nil { + return fail(stderr, err) + } + } else if *approvalSourceFile != "" { + return fail(stderr, fmt.Errorf("Approval reader requires exact trusted task source package")) + } + mux := newServeMuxWithTaskFacts(engine, authenticator, serviceInputs, approvalReader) if outbox != nil { mountEmission(mux, outbox) } @@ -457,6 +470,9 @@ func newServeMuxWithCallerAuth(engine *decisioncore.Engine, authenticator *calle } func newServeMuxWithServicePilotInputs(engine *decisioncore.Engine, authenticator *callerauth.Authenticator, serviceInputs *taskexecution.ServicePilotInputs) *http.ServeMux { + return newServeMuxWithTaskFacts(engine, authenticator, serviceInputs, nil) +} +func newServeMuxWithTaskFacts(engine *decisioncore.Engine, authenticator *callerauth.Authenticator, serviceInputs *taskexecution.ServicePilotInputs, approvalReader *taskexecution.ApprovalReader) *http.ServeMux { mux := http.NewServeMux() mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("content-type", "application/json") @@ -467,12 +483,16 @@ func newServeMuxWithServicePilotInputs(engine *decisioncore.Engine, authenticato http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } - if engine.IsServicePilot() { + if engine.IsTaskPin() { if serviceInputs == nil { http.Error(w, "service input unavailable", http.StatusServiceUnavailable) return } - request, err := taskexecution.DecodeServiceRequest(r.Body) + decode := taskexecution.DecodeServiceRequest + if engine.IsTrustedServicePilot() { + decode = taskexecution.DecodeTrustedServiceRequest + } + request, err := decode(r.Body) if err != nil || request.Purpose != "" { http.Error(w, "invalid service task request", http.StatusBadRequest) return @@ -482,7 +502,12 @@ func newServeMuxWithServicePilotInputs(engine *decisioncore.Engine, authenticato http.Error(w, "service task input refused", http.StatusForbidden) return } - decision, err := engine.CheckServicePilot(r.Context(), input) + var decision api.DecisionEnvelope + if engine.IsTrustedServicePilot() { + decision, err = engine.CheckTrustedService(r.Context(), input, approvalReader) + } else { + decision, err = engine.CheckServicePilot(r.Context(), input) + } writeHTTP(w, decision, err) return } @@ -506,7 +531,7 @@ func newServeMuxWithServicePilotInputs(engine *decisioncore.Engine, authenticato writeHTTP(w, decision, err) }) mux.HandleFunc("/v1/batch_check", func(w http.ResponseWriter, r *http.Request) { - if engine.IsServicePilot() { + if engine.IsTaskPin() { http.Error(w, "service task batch unavailable", http.StatusForbidden) return } @@ -537,7 +562,7 @@ func newServeMuxWithServicePilotInputs(engine *decisioncore.Engine, authenticato // Only the existing exact service pilot package selects this source path. // This is not an eligibility toggle; successful preparation still only DENYs. func buildServicePilotInputs(engine *decisioncore.Engine, mode callerauth.Mode, audience, kubernetesURL, reviewerTokenFile, caFile string, bindings map[string]string) (*taskexecution.ServicePilotInputs, error) { - if !engine.IsServicePilot() { + if !engine.IsTaskPin() { return nil, nil } if mode != callerauth.ModeEnforce || audience != "flex-auth" || len(bindings) != 1 || bindings["secrets-engine"] != taskexecution.ServicePilotPrincipal { @@ -547,6 +572,9 @@ func buildServicePilotInputs(engine *decisioncore.Engine, mode callerauth.Mode, if err != nil { return nil, err } + if engine.IsTrustedServicePilot() { + return taskexecution.NewTrustedServiceInputs(reviewer) + } return taskexecution.NewServicePilotInputs(reviewer) } @@ -575,11 +603,15 @@ func writeCallerAuthError(w http.ResponseWriter, err error) { } func buildEngine(ctx context.Context, registryPath, policyPath, logPath string) (*decisioncore.Engine, error) { - store, err := registry.LoadFile(registryPath) + pkg, err := policy.LoadAndValidateFile(ctx, policyPath) if err != nil { return nil, err } - pkg, err := policy.LoadAndValidateFile(ctx, policyPath) + load := registry.LoadFile + if pkg.Metadata.ID == taskexecution.ServicePilotPackageID && pkg.Metadata.Version == taskexecution.TrustedServicePilotPackageVersion { + load = registry.LoadFilePreservingNumbers + } + store, err := load(registryPath) if err != nil { return nil, err } diff --git a/cmd/access-engine/trusted_service_http_test.go b/cmd/access-engine/trusted_service_http_test.go new file mode 100644 index 0000000..40a6e90 --- /dev/null +++ b/cmd/access-engine/trusted_service_http_test.go @@ -0,0 +1,430 @@ +package main + +import ( + "bytes" + "context" + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "encoding/pem" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/netkingdom/access-engine/internal/audit" + "github.com/netkingdom/access-engine/internal/callerauth" + "github.com/netkingdom/access-engine/internal/decision" + "github.com/netkingdom/access-engine/internal/policy" + "github.com/netkingdom/access-engine/internal/registry" + "github.com/netkingdom/access-engine/internal/sign" + "github.com/netkingdom/access-engine/internal/taskexecution" + "github.com/netkingdom/access-engine/pkg/api" + "github.com/netkingdom/approval-engine/adapters/go/approvalfact" +) + +func trustedRequest(t *testing.T, now time.Time) api.CheckRequest { + t.Helper() + r := pilotRequest(now) + r.Context["execution_contract"].(map[string]any)["operation_deadline"] = now.Add(60 * time.Second).UTC().Truncate(time.Second).Format(time.RFC3339) + r.Subject.Tenant = r.Tenant + r.Resource.Tenant = r.Tenant + r.Resource.Attributes["numeric_fixture"] = json.Number("1.0") + c := r.Context["execution_contract"].(map[string]any) + c["operation_id"] = "11111111-2222-4333-8444-555555555555" + c["ccr_id"] = "synthetic-change" + c["execution_grant_ccr_id"] = "synthetic-grant" + hash := "sha256:" + strings.Repeat("1", 64) + for _, key := range []string{"ccr_immutable_sha256", "grant_immutable_sha256", "catalog_sha256", "expected_before", "expected_after"} { + c[key] = hash + } + c["backend_budget"] = map[string]any{"ttl_seconds": json.Number("30"), "uses": json.Number("12")} + var ops []any + for i, method := range []string{"GET", "PUT", "GET", "GET", "PUT", "GET"} { + path := "sys/policies/acl/synthetic" + if i >= 3 { + path = "auth/synthetic/role/synthetic" + } + ops = append(ops, map[string]any{"method": method, "path": path, "payload_sha256": hash}) + } + c["operations"] = ops + delete(r.Context, "approval") + raw, _ := json.Marshal(map[string]any{"action": r.Action, "actor": r.Subject.ID, "principal": "secrets-engine", "purpose": "synthetic source-only proof", "target": r.Resource}) + r.Context["purpose"] = "synthetic source-only proof" + _, digest, err := approvalfact.CanonicalBinding(raw) + if err != nil { + t.Fatal(err) + } + bindingValue, err := approvalfact.DecodeJSON(raw) + if err != nil { + t.Fatal(err) + } + binding := bindingValue.(map[string]any) + // PDP profile intentionally retains the published legacy numeric projection. + encoded, _ := json.Marshal(r) + var legacy api.CheckRequest + json.Unmarshal(encoded, &legacy) + binding["digest"] = digest + binding["pdp_digest"] = api.ApprovalBindingDigest(legacy) + binding["pdp_path"] = true + binding["human_control"] = true + r.Context["approval"] = map[string]any{"schema_version": "0.1", "kind": "approval-claim", "issuer": "approval-engine", "approval_id": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", "state": "valid", "valid_now": true, "consumed": false, "reason_code": "ok", "binding": binding, "freshness": map[string]any{"observed_at": now.Add(-time.Second).Format(time.RFC3339Nano), "ttl_seconds": json.Number("120"), "not_after": now.Add(119 * time.Second).Format(time.RFC3339Nano)}, "validity": map[string]any{"not_before": now.Add(-time.Hour).Format(time.RFC3339Nano), "expires_at": now.Add(time.Hour).Format(time.RFC3339Nano)}} + return r +} +func TestTrustedServiceActualHTTPPositiveAndRefusals(t *testing.T) { testTrustedServiceHTTP(t, false) } +func TestTrustedServiceRetainedRegistryPreapprovalAndRefusals(t *testing.T) { + testTrustedServiceHTTP(t, true) +} +func testTrustedServiceHTTP(t *testing.T, retained bool) { + now := time.Now().UTC() + folder := t.TempDir() + reviewer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/apis/authentication.k8s.io/v1/tokenreviews" || r.Header.Get("Authorization") != "Bearer synthetic-reviewer" { + w.WriteHeader(403) + return + } + json.NewEncoder(w).Encode(map[string]any{"status": map[string]any{"authenticated": true, "audiences": []string{"flex-auth"}, "user": map[string]any{"username": taskexecution.ServicePilotPrincipal}}}) + })) + defer reviewer.Close() + ca := filepath.Join(folder, "review-ca") + token := filepath.Join(folder, "review-token") + os.WriteFile(ca, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: reviewer.Certificate().Raw}), 0600) + os.WriteFile(token, []byte("synthetic-reviewer"), 0600) + original := trustedRequest(t, now) + if retained { + delete(original.Context, "approval") + } + claimRaw, _ := json.Marshal(original.Context["approval"]) + mode := "ok" + reads := 0 + provider := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + reads++ + if r.URL.Path != "/v1/approvals/aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee/claim" || r.Header.Get("Authorization") != "Bearer synthetic-approval-reader" { + w.WriteHeader(403) + return + } + if mode == "redirect" { + http.Redirect(w, r, reviewer.URL, 302) + return + } + w.Header().Set("Content-Type", "application/json") + switch mode { + case "duplicate": + io.WriteString(w, `{"state":"valid","state":"invalid"}`) + case "changed": + v, _ := approvalfact.DecodeJSON(claimRaw) + v.(map[string]any)["consumed"] = true + json.NewEncoder(w).Encode(v) + case "short-fresh": + v, _ := approvalfact.DecodeJSON(claimRaw) + fresh := v.(map[string]any)["freshness"].(map[string]any) + fresh["ttl_seconds"] = json.Number("20") + fresh["not_after"] = now.Add(19 * time.Second).Format(time.RFC3339Nano) + json.NewEncoder(w).Encode(v) + case "expired": + v, _ := approvalfact.DecodeJSON(claimRaw) + v.(map[string]any)["freshness"].(map[string]any)["not_after"] = now.Add(-time.Second).Format(time.RFC3339Nano) + json.NewEncoder(w).Encode(v) + default: + w.Write(claimRaw) + } + })) + defer provider.Close() + pca := filepath.Join(folder, "approval-ca") + ptoken := filepath.Join(folder, "approval-token") + os.WriteFile(pca, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: provider.Certificate().Raw}), 0600) + os.WriteFile(ptoken, []byte("synthetic-approval-reader"), 0440) + source, err := taskexecution.NewApprovalReader(taskexecution.ApprovalSourceConfig{SourceID: "synthetic-approval", SourceRevision: "test-source-revision", Origin: provider.URL, StoreTenant: "tenant:platform", CAFile: pca, TokenFile: ptoken}) + if err != nil { + t.Fatal(err) + } + pkg, err := policy.LoadAndValidateFile(context.Background(), filepath.Join("..", "..", "examples", "secrets-engine-service-task-execution-v2", "policy_package.md")) + if err != nil { + t.Fatal(err) + } + store := registry.NewStore() + if retained { + store, err = registry.LoadFilePreservingNumbers(filepath.Join("..", "..", "examples", "secrets-engine", "registry_snapshot.json")) + if err != nil { + t.Fatal(err) + } + } + engine, err := decision.NewEngine(store, pkg) + if err != nil { + t.Fatal(err) + } + key := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{19}, 32)) + engine.SetEnvelopeSigningKey(key, "synthetic-v2") + record := filepath.Join(folder, "record.jsonl") + engine.SetDecisionLog(audit.NewJSONLDecisionLog(record)) + inputs, err := buildServicePilotInputs(engine, callerauth.ModeEnforce, "flex-auth", reviewer.URL, token, ca, map[string]string{"secrets-engine": taskexecution.ServicePilotPrincipal}) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(newServeMuxWithTaskFacts(engine, callerauth.Disabled(), inputs, source)) + defer server.Close() + post := func(request api.CheckRequest) (int, []byte) { + t.Helper() + raw, _ := json.Marshal(request) + req, _ := http.NewRequest("POST", server.URL+"/v1/check", bytes.NewReader(raw)) + req.Header.Set("Authorization", "Bearer "+pilotToken("valid", now)) + response, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + body, _ := io.ReadAll(response.Body) + return response.StatusCode, body + } + var pre api.DecisionEnvelope + if retained { + preStatus, preRaw := post(original) + if preStatus != 200 { + t.Fatalf("preapproval status%d", preStatus) + } + pre, err = sign.DecodeEnvelope(preRaw) + if err != nil || sign.Verify(pre, map[string]ed25519.PublicKey{"synthetic-v2": key.Public().(ed25519.PublicKey)}) != nil { + t.Fatal("preapproval not authenticated") + } + if pre.Effect != api.DecisionEffectDeny || pre.Lifetime != nil || pre.Binding.ApprovalBindingDigest == "" || pre.Binding.ApprovalBindingDigest != pre.Binding.RequestDigest { + t.Fatal("preapproval receipt is execution authority or lacks exactclaimfree digest") + } + if pre.Diagnostics["registry_subject"] != true || pre.Provenance.RegistrySnapshotDigest != store.Digest() { + t.Fatal("retained registry not applied") + } + rawOriginal, _ := json.Marshal(original) + var legacyPre api.CheckRequest + json.Unmarshal(rawOriginal, &legacyPre) + if pre.Binding.SubmittedRequestDigest != api.RequestDigest(legacyPre) || pre.Binding.RequestDigest == api.RequestDigest(legacyPre) { + t.Fatal("original/evaluated retained registry distinction lost") + } + carried := trustedRequest(t, now).Context["approval"].(map[string]any) + native := map[string]any{"action": pre.Binding.Action, "actor": pre.Binding.Subject.ID, "principal": "secrets-engine", "purpose": pre.Binding.Context["purpose"], "target": pre.Binding.Resource} + nativeRaw, _ := json.Marshal(native) + _, nativeDigest, err := approvalfact.CanonicalBinding(nativeRaw) + if err != nil { + t.Fatal(err) + } + native["digest"] = nativeDigest + native["pdp_digest"] = pre.Binding.ApprovalBindingDigest + native["pdp_path"] = true + native["human_control"] = true + carried["binding"] = native + original.Context["approval"] = carried + claimRaw, _ = json.Marshal(carried) + originalBytes, _ := json.Marshal(original) + original, err = taskexecution.DecodeTrustedServiceRequest(bytes.NewReader(originalBytes)) + if err != nil { + t.Fatal(err) + } + + } + status, body := post(original) + if status != 200 { + t.Fatalf("positive status %d: %s", status, body) + } + envelope, err := sign.DecodeEnvelope(body) + if err != nil { + t.Fatal(err) + } + t.Logf("actual HTTP source policy pin: %s %s %s", pkg.Metadata.ID, pkg.Metadata.Version, pkg.Digest()) + if envelope.Effect != api.DecisionEffectAllow || sign.Verify(envelope, map[string]ed25519.PublicKey{"synthetic-v2": key.Public().(ed25519.PublicKey)}) != nil { + t.Fatalf("not signed ALLOW: %s", body) + } + if envelope.Provenance.ApprovalObservation == nil || envelope.Provenance.ApprovalObservation.SourceID != "synthetic-approval" || reads != 1 { + t.Fatal("missing authenticated observation provenance") + } + legacyRaw, _ := json.Marshal(original) + var legacyRequest api.CheckRequest + json.Unmarshal(legacyRaw, &legacyRequest) + if envelope.Binding.SubmittedRequestDigest != api.RequestDigest(legacyRequest) { + t.Fatal("original submitted digest changed") + } + if retained { + if envelope.Binding.RequestDigest == api.RequestDigest(legacyRequest) || envelope.Binding.ApprovalBindingDigest != pre.Binding.ApprovalBindingDigest { + t.Fatal("normalized approval correspondence changed") + } + } else if envelope.Binding.RequestDigest != api.RequestDigest(legacyRequest) || envelope.Binding.ApprovalBindingDigest != api.ApprovalBindingDigest(legacyRequest) { + t.Fatal("published digest profile changed") + } + + if !reflect.DeepEqual(envelope.Binding.Context, original.Context) { + t.Fatal("original context changed") + } + if envelope.Lifetime.ExpiresAt != original.Context["execution_contract"].(map[string]any)["operation_deadline"] { + t.Fatal("decision does not bound entire exact operation") + } + if envelope.Binding.Resource.Attributes["numeric_fixture"] != json.Number("1.0") { + t.Fatal("native numeric kind lost") + } + recorded, err := os.ReadFile(record) + if err != nil { + t.Fatal(err) + } + records, err := audit.NewJSONLDecisionLog(record).ReadAll() + expectedCount := 1 + if retained { + expectedCount = 2 + } + if err != nil || len(records) != expectedCount || !reflect.DeepEqual(records[len(records)-1], envelope) { + t.Fatal("returned record parity") + } + if retained && !reflect.DeepEqual(records[0], pre) { + t.Fatal("preapproval record parity") + } + for _, failure := range []string{"redirect", "duplicate", "changed", "expired", "short-fresh"} { + t.Run(failure, func(t *testing.T) { + mode = failure + status, _ := post(original) + if status == 200 { + t.Fatal("bad source admitted") + } + }) + } + mode = "ok" + if retained { + t.Run("registry-drift-cannot-rebind-approved-act", func(t *testing.T) { + subject, _ := store.Subject("secrets-engine") + originalSubject := subject + subject.Roles = append(append([]api.CanonicalRole{}, subject.Roles...), api.CanonicalRole("synthetic-owner-drift")) + if err := store.PutSubject(subject); err != nil { + t.Fatal(err) + } + defer store.PutSubject(originalSubject) + if status, _ := post(original); status == 200 { + t.Fatal("changed registry silently rebound original/fresh claim") + } + }) + } + t.Run("original-stale", func(t *testing.T) { + raw, _ := json.Marshal(original) + r, err := taskexecution.DecodeTrustedServiceRequest(bytes.NewReader(raw)) + if err != nil { + t.Fatal(err) + } + fresh := r.Context["approval"].(map[string]any)["freshness"].(map[string]any) + fresh["observed_at"] = now.Add(-121 * time.Second).Format(time.RFC3339Nano) + fresh["not_after"] = now.Add(-time.Second).Format(time.RFC3339Nano) + status, _ := post(r) + if status == 200 { + t.Fatal("fresh provider rescued stale original") + } + }) + t.Run("native-number-kind", func(t *testing.T) { + raw, _ := json.Marshal(original) + r, _ := taskexecution.DecodeTrustedServiceRequest(bytes.NewReader(raw)) + r.Resource.Attributes["numeric_fixture"] = json.Number("1") + status, _ := post(r) + if status == 200 { + t.Fatal("changed native integer/float authority accepted") + } + }) + after, _ := os.ReadFile(record) + if !bytes.Equal(after, recorded) { + t.Fatal("refused calls recorded executable envelopes") + } + // Exercise actual production CLI configuration with no ambient credentials. + seedPath := filepath.Join(folder, "cli-seed") + os.WriteFile(seedPath, []byte(base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{19}, 32))), 0440) + sourcePath := filepath.Join(folder, "approval-source.json") + sourceJSON, _ := json.Marshal(taskexecution.ApprovalSourceConfig{SourceID: "synthetic-approval", SourceRevision: "test-source-revision", Origin: provider.URL, StoreTenant: "tenant:platform", CAFile: pca, TokenFile: ptoken}) + os.WriteFile(sourcePath, sourceJSON, 0600) + registryPath := filepath.Join(folder, "registry.json") + registryBytes := []byte(`{}`) + if retained { + registryBytes, err = os.ReadFile(filepath.Join("..", "..", "examples", "secrets-engine", "registry_snapshot.json")) + if err != nil { + t.Fatal(err) + } + } + os.WriteFile(registryPath, registryBytes, 0600) + policyPath, _ := filepath.Abs(filepath.Join("..", "..", "examples", "secrets-engine-service-task-execution-v2", "policy_package.md")) + cliRecord := filepath.Join(folder, "cli-record.jsonl") + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + address := listener.Addr().String() + listener.Close() + args := []string{"serve", "--addr", address, "--registry", registryPath, "--policy", policyPath, "--log", cliRecord, "--caller-auth-mode", "enforce", "--caller-audience", "flex-auth", "--caller-kubernetes-url", reviewer.URL, "--caller-reviewer-token-file", token, "--caller-ca-file", ca, "--caller-binding", "secrets-engine=" + taskexecution.ServicePilotPrincipal, "--envelope-signing-key-file", seedPath, "--envelope-signing-kid", "synthetic-v2", "--approval-source-file", sourcePath} + argsJSON, _ := json.Marshal(args) + child := exec.Command(os.Args[0], "-test.run=^TestServicePilotChildCLI$") + child.Env = []string{"ACCESS_B1_SERVER_ARGS=" + string(argsJSON)} + child.Stdout = io.Discard + child.Stderr = io.Discard + if err := child.Start(); err != nil { + t.Fatal(err) + } + defer func() { child.Process.Kill(); child.Wait() }() + ready := false + for attempt := 0; attempt < 100; attempt++ { + response, err := http.Get("http://" + address + "/healthz") + if err == nil { + response.Body.Close() + ready = true + break + } + time.Sleep(20 * time.Millisecond) + } + if !ready { + t.Fatal("trusted CLI not ready") + } + raw, _ := json.Marshal(original) + req, _ := http.NewRequest("POST", "http://"+address+"/v1/check", bytes.NewReader(raw)) + req.Header.Set("Authorization", "Bearer "+pilotToken("valid", now)) + response, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + wire, _ := io.ReadAll(response.Body) + cliEnvelope, err := sign.DecodeEnvelope(wire) + if response.StatusCode != 200 || err != nil || cliEnvelope.Effect != api.DecisionEffectAllow || sign.Verify(cliEnvelope, map[string]ed25519.PublicKey{"synthetic-v2": key.Public().(ed25519.PublicKey)}) != nil { + t.Fatalf("CLI did not return signed trusted ALLOW: %s", wire) + } + cliRecords, err := audit.NewJSONLDecisionLog(cliRecord).ReadAll() + if err != nil || len(cliRecords) != 1 || !reflect.DeepEqual(cliRecords[0], cliEnvelope) { + t.Fatal("CLI response/readback record mismatch") + } + if !reflect.DeepEqual(cliEnvelope.Binding.Context, original.Context) { + t.Fatal("CLI changed original numeric context") + } + +} + +func TestTrustedServiceMissingAndGenericAdmissionRefuse(t *testing.T) { + pkg, err := policy.LoadAndValidateFile(context.Background(), filepath.Join("..", "..", "examples", "secrets-engine-service-task-execution-v2", "policy_package.md")) + if err != nil { + t.Fatal(err) + } + engine, err := decision.NewEngine(registry.NewStore(), pkg) + if err != nil { + t.Fatal(err) + } + request := trustedRequest(t, time.Now().UTC()) + if _, err := engine.Check(context.Background(), request); err == nil { + t.Fatal("generic check bypass") + } + if _, err := engine.BatchCheck(context.Background(), api.BatchCheckRequest{Action: "execute-ccr"}); err == nil { + t.Fatal("generic batch bypass") + } + if _, err := engine.ListAllowed(context.Background(), decision.ListAllowedRequest{Action: "execute-ccr"}); err == nil { + t.Fatal("generic list bypass") + } + if _, err := engine.CheckTrustedService(context.Background(), taskexecution.ServiceInput{}, nil); err == nil { + t.Fatal("missing typed input and fixed source accepted") + } + recorder := httptest.NewRecorder() + raw, _ := json.Marshal(request) + newServeMux(engine).ServeHTTP(recorder, httptest.NewRequest("POST", "/v1/check", bytes.NewReader(raw))) + if recorder.Code != 503 { + t.Fatal("unwired typed path accepted") + } +} diff --git a/docs/trusted-service-task-source.md b/docs/trusted-service-task-source.md new file mode 100644 index 0000000..7c55b76 --- /dev/null +++ b/docs/trusted-service-task-source.md @@ -0,0 +1,108 @@ +# Trusted service task source proposal (v2) + +The separate `secrets-engine.task-execution.service-pilot` v2 source package can +return ALLOW only through the existing `/v1/check` typed task path. The original +v1 package remains unchanged and DENY-only. No chart, runtime, source identity, +credential custody or native execution admission is enabled by these sources. + +The server requires enforced TokenReview, audience `flex-auth`, the exact retained +Secrets service-account mapping, mounted envelope signing configuration and an +owner-installed `--approval-source-file`. This public configuration contains +`source_id`, `source_revision`, fixed HTTPS `origin`, `store_tenant` exactly +`tenant:platform`, absolute `ca_file` and absolute mounted `token_file`. The +credential must independently carry existing Approval `approval:read` authority +for that store. Source labels describe the owner-admitted configuration; they do +not perform its admission. No key or token value belongs in the configuration. + +The reader sends one authenticated GET to the fixed +`/v1/approvals/{UUID}/claim` endpoint, accepts only bounded strict JSON, uses only +the configured CA, refuses redirects and environmental proxies, and has a total +three-second HTTP timeout. It neither creates nor approves nor consumes. Mounted +bearer files must be regular, bounded and have no group write or other access; +0440 is supported. Public configuration/CA files also refuse group/other writes. +Errors do not include response bodies, credentials or transport diagnostics. + +The unchanged Approval-owner pure data helper is copied with exact source pins +under `third_party/approvalfact`. The fixed reader establishes provider origin; +the helper establishes schema and semantic correspondence. Both original and +fresh claims must independently be currently valid, unconsumed, human-controlled +PDP-path claims for the exact UUID, native five-field act and Access approval-free +request digest. Nonfreshness authority must match exactly. The freshly fetched +claim never replaces the submitted claim/context or renews original freshness. + +The native act derives from the normalized subject/resource, fixed service +principal, action and nonempty `context.purpose`. The HTTP decoder and task registry +loader preserve raw JSON numeric representations. Registry facts loaded through +the old lossy profile cannot enter the trusted path. Only the Approval-owner +helper canonicalizes native act data. Existing PDP full/submitted/approval-free +digests retain their published numeric profile through a digest-only legacy +projection; the signed structured binding retains the original numeric kinds. +Inputs unsupported by that existing PDP profile refuse; this proposal does not +weaken any consumer numeric admission or invent numeric restrictions. + +The same existing policy evaluator decides after those joins. Generic check, +batch and list entry points refuse task execution across all policy names. No +request-selected verified flag, provider origin or policy enable switch bypasses +the typed path. Missing approval can receive signed DENY for preapproval binding; +ALLOW requires authenticated matching original/fresh claims. Its expiry is exactly the immutable operation deadline, after validating that +it fits the source-selected 120s hard maximum and every caller/original/fresh +freshness/approval bound. At least the declared backend TTL plus the existing +5s cleanup reserve must still fit after source I/O and final evaluation. The +source maximum conservatively matches the existing 120s task-token cap; the +structural backend budget maximum is not a promise every budget can fit. In +particular backend TTL120 cannot fit this authority ceiling plus reserve. A +fractional operation deadline is retained exactly rather than rounded earlier. +Approval's default 30s observation freshness cannot cover the minimum30+5 +operation budget. A separate owner-admitted source configuration must explicitly +supply adequate original/fresh observation lifetime; fetching anew cannot renew +an original claim. The disposable reviewed proof uses backendTTL30, immutable +operationdeadline+60s and explicitly configured Approval freshness120. This is +an entire-operation authority interval, not a decision-to-start window. Platform +must retain native-entry and per-call deadline/cleanup gates; delays simply refuse. Existing source-owned signing covers the +original binding and new `provenance.approval_observation` receipt. The receipt +contains source/configuration identity, exact fetched-byte digest, native digest +and observation/validation/deadline timestamps. JSONL readback and outbox replay +preserve producer numeric signatures; no second signing algorithm is introduced. + +Packaging must copy the local owner helper before `go mod download`. Existing +ordinary image CI triggers include `third_party/**` so a helper update cannot +silently leave the old image. No build publication is invoked by this preparation. +Rollback selects the unchanged v1 package or removes the v2 admission; generic +fallback remains refused. Rotating/withdrawing source or signing trust is a +separate owner-admitted runtime operation. The earlier Platform positive proof +remains evidence only at its old ec21518e Access pin; its generic synthetic policy +is deliberately incompatible with the successor guarded source. + +## Producer normalization before Approval issue + +The retained registry enriches Secrets subject attributes. Consequently the +submitted approval-free digest is not the evaluated approval-free digest. This +is the existing `docs/request-enrichment.md` contract, not a reason to remove +registry facts or copy its enrichment rules into Secrets. + +The existing `/v1/check` typed v2 path accepts the exact original claim-free +proposal and returns a signed DENY containing the authoritative normalized +binding and original `submitted_request_digest`. For this v2 path it also emits +`approval_binding_digest` when claim-free, equal to evaluated `request_digest`. +That is a normalization receipt supplying facts only. It has no ALLOW lifetime +and cannot satisfy execution authorization. Before issue, Secrets authenticates +this receipt using its existing independent producer trust/policy/source pins, +checks original request/context correspondence, caller/deadline/current-time +bounds and normalized self-digests, then derives the native act and issue digest +from the authenticated producer binding. It does not supply expected registry +facts or expected digests to the producer. The Approval API records these exact +facts at issue; human approval follows its unchanged authenticated contract. + +The submitted proposal remains unchanged; the final request attaches only the +original issued claim. Final ALLOW rechecks independent source facts, native/PDP +correspondence, normalization and all temporal/caller conditions. A changed +registry cannot silently refresh or replace an approved act: a changed evaluated +digest/native target refuses even when the freshly fetched claim is still valid. +The source tests cover the retained registry, signed DENY/ALLOW separation, +original/evaluated digest distinction, unchanged context, source/registry pins, +registry drift, stale/refreshed claims and exact response/record parity. Earlier +empty-registry proofs remain explicitly scoped historical source fixtures. + +R3 retains precise decision `not_before`; it does not floor that lower bound to +mask Approval's current whole-second consumption receipt defect. Approval-owner +precision repair and full owner integration remain separate reviewed evidence. 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 index 0000000..736d9ee --- /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 index 0000000..8b584db --- /dev/null +++ b/examples/secrets-engine-service-task-execution-v2/policy_package.md @@ -0,0 +1,120 @@ +--- +id: secrets-engine.task-execution.service-pilot +name: Secrets admitted service task execution +namespace: secrets-engine:secret-catalog-lane +version: v2 +status: proposed +package: flexauth.secrets_engine.service_task_execution +allow_ttl: 120s +actions: [execute-ccr] +owner: team:platform-security +fixtures: [policy_fixtures.yaml] +caring: + profile: caring-0.4.0-rc2 + enforce: false +activation: + mode: local +--- + +# Trusted service task source successor + +This source package is evaluated exclusively by the typed v2 HTTP path. That +path requires exact TokenReview caller mapping, immutable numeric source +representations, a fixed authenticated Approval reader, separate original and +fresh claim validation and native/PDP correspondence before policy evaluation. +Its 120s declared TTL is a hard source ceiling, not a blanket grant. The typed +path derives actual expiry exactly from the immutable operation deadline and +requires the entire declared backend TTL plus the existing 5s cleanup reserve +within original/fresh claim, approval and caller bounds. Budgets that cannot fit +refuse; the 120s structural backend maximum is not guaranteed admissible. +Generic check, batch and list routes refuse task execution. Selecting this source +package alone cannot bypass those joins. No deployment or trust admission is +established by this package. The v1 package remains unchanged and DENY-only. + +```rego +default decision := {"effect": "deny", "reason": "service_execution_contract_unmatched"} + +decision := {"effect": "allow", "reason": "exact_task_execution_matched"} { + candidate_matches +} + +hash_ok(x) { is_string(x); regex.match("^sha256:[0-9a-f]{64}$", x) } + +candidate_matches { + input.action == "execute-ccr" + input.tenant == "tenant:platform" + input.subject.id == "secrets-engine" + input.subject.type == "service" + input.resource.system == "secrets-engine" + input.resource.type == "secret-catalog-lane" + input.context.human_control == true + c := input.context.execution_contract + c.contract_version == "SECRETS-EXEC-20261011-v1" + c.operation == "metadata-apply-ccr" + c.execution_actor == "service:secrets-engine" + regex.match("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", c.operation_id) + c.ccr_id != c.execution_grant_ccr_id + hash_ok(c.ccr_immutable_sha256) + hash_ok(c.grant_immutable_sha256) + hash_ok(c.catalog_sha256) + hash_ok(c.expected_before) + hash_ok(c.expected_after) + c.backend_budget.ttl_seconds <= 120 + c.backend_budget.ttl_seconds >= 30 + c.backend_budget.uses == 12 + count(c.operations) == 6 + c.operations[0].method == "GET" + c.operations[1].method == "PUT" + c.operations[2].method == "GET" + c.operations[3].method == "GET" + c.operations[4].method == "PUT" + c.operations[5].method == "GET" + regex.match("^sys/policies/acl/[A-Za-z0-9_-]+$", c.operations[0].path) + regex.match("^auth/[A-Za-z0-9_-]+/role/[A-Za-z0-9_-]+$", c.operations[3].path) + c.operations[0].path == c.operations[1].path + c.operations[1].path == c.operations[2].path + c.operations[3].path == c.operations[4].path + c.operations[4].path == c.operations[5].path + every_operation_hash_valid + b := input.context.service_actor_binding + count(b) == 5 + b.version == "proposed-service-task-caller/v1" + b.transport_principal == "system:serviceaccount:secrets-engine:secrets-engine" + b.subject_id == input.subject.id + b.subject_type == "service" + b.tenant == input.tenant + not input.context.task_execution_projection + not input.context.initiator + not input.context.represented_principal + not input.context.delegation + input.resource.attributes.stage == "prod" + time.parse_rfc3339_ns(c.operation_deadline) > time.now_ns() + time.parse_rfc3339_ns(c.operation_deadline) <= time.now_ns() + 120000000000 + time.parse_rfc3339_ns(c.operation_deadline) >= time.now_ns() + (c.backend_budget.ttl_seconds + 5) * 1000000000 + input.context.approval.schema_version == "0.1" + input.context.approval.kind == "approval-claim" + input.context.approval.issuer == "approval-engine" + input.context.approval.valid_now == true + input.context.approval.consumed == false + input.context.approval.reason_code == "ok" + input.context.approval.binding.pdp_path == true + hash_ok(input.context.approval.binding.pdp_digest) + time.parse_rfc3339_ns(input.context.approval.freshness.not_after) > time.now_ns() + time.parse_rfc3339_ns(input.context.approval.validity.expires_at) >= time.parse_rfc3339_ns(c.operation_deadline) + time.parse_rfc3339_ns(input.context.approval.freshness.not_after) >= time.parse_rfc3339_ns(c.operation_deadline) + input.context.approval.binding.human_control == true +} + +every_operation_hash_valid { + count([x | x := input.context.execution_contract.operations[_]; hash_ok(x.payload_sha256)]) == 6 +} +``` + +```rego test +import data.flexauth.secrets_engine.service_task_execution + +test_unsigned_flags_cannot_enable { + service_task_execution.decision.effect == "deny" + with input as {"context": {"runtime_admission": true, "verified": true}} +} +``` 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 index 0000000..68ee07d --- /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 index 227d417..86a6104 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/netkingdom/access-engine go 1.26.7 require ( + github.com/netkingdom/approval-engine/adapters/go/approvalfact v0.0.0 github.com/open-policy-agent/opa v1.4.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -39,3 +40,5 @@ require ( google.golang.org/protobuf v1.36.6 // indirect sigs.k8s.io/yaml v1.4.0 // indirect ) + +replace github.com/netkingdom/approval-engine/adapters/go/approvalfact => ./third_party/approvalfact diff --git a/internal/audit/log.go b/internal/audit/log.go index 7ee82e9..550a59c 100644 --- a/internal/audit/log.go +++ b/internal/audit/log.go @@ -8,6 +8,7 @@ import ( "path/filepath" "sync" + "github.com/netkingdom/access-engine/internal/sign" "github.com/netkingdom/access-engine/pkg/api" ) @@ -67,8 +68,8 @@ func (l *JSONLDecisionLog) ReadAll() ([]api.DecisionEnvelope, error) { if scanner.Text() == "" { continue } - var decision api.DecisionEnvelope - if err := json.Unmarshal(scanner.Bytes(), &decision); err != nil { + decision, err := sign.DecodeEnvelope(scanner.Bytes()) + if err != nil { return nil, fmt.Errorf("unmarshal decision log line %d: %w", len(decisions)+1, err) } decisions = append(decisions, decision) diff --git a/internal/decision/engine.go b/internal/decision/engine.go index ae4951a..8a37c83 100644 --- a/internal/decision/engine.go +++ b/internal/decision/engine.go @@ -158,6 +158,10 @@ func (e *Engine) decide(ctx context.Context, request api.CheckRequest) (api.Deci func (e *Engine) IsServicePilot() bool { return e.policy.Metadata.ID == taskexecution.ServicePilotPackageID && e.policy.Metadata.Version == taskexecution.ServicePilotPackageVersion } +func (e *Engine) IsTrustedServicePilot() bool { + return e.policy.Metadata.ID == taskexecution.ServicePilotPackageID && e.policy.Metadata.Version == taskexecution.TrustedServicePilotPackageVersion +} +func (e *Engine) IsTaskPin() bool { return e.IsServicePilot() || e.IsTrustedServicePilot() } // ErrServicePilotUnavailable withholds any result that is not this B1 slice's // signed DENY. Provider joins and executable eligibility remain unimplemented. @@ -189,7 +193,7 @@ func (e *Engine) CheckServicePilot(ctx context.Context, input taskexecution.Serv // Check evaluates one subject/action/resource request. The decision is // released only after its required record commits (GH-DEC-2026-024). func (e *Engine) Check(ctx context.Context, request api.CheckRequest) (api.DecisionEnvelope, error) { - if e.IsServicePilot() || request.Action == "execute-ccr" { + if e.IsTaskPin() || request.Action == "execute-ccr" { return api.DecisionEnvelope{}, ErrServicePilotUnavailable } decision, err := e.decide(ctx, request) @@ -205,7 +209,7 @@ func (e *Engine) Check(ctx context.Context, request api.CheckRequest) (api.Decis // BatchCheck evaluates one subject/action/context tuple against resources in // request order, and commits the whole batch in one durable write. func (e *Engine) BatchCheck(ctx context.Context, request api.BatchCheckRequest) ([]api.DecisionEnvelope, error) { - if e.IsServicePilot() || request.Action == "execute-ccr" { + if e.IsTaskPin() || request.Action == "execute-ccr" { return nil, ErrServicePilotUnavailable } decisions := make([]api.DecisionEnvelope, 0, len(request.Resources)) @@ -232,7 +236,7 @@ func (e *Engine) BatchCheck(ctx context.Context, request api.BatchCheckRequest) // ListAllowed evaluates candidate resources and returns only allow decisions. func (e *Engine) ListAllowed(ctx context.Context, request ListAllowedRequest) ([]api.DecisionEnvelope, error) { - if e.IsServicePilot() || request.Action == "execute-ccr" { + if e.IsTaskPin() || request.Action == "execute-ccr" { return nil, ErrServicePilotUnavailable } candidates := e.store.ResourceRefs(request.System, request.ResourceType) diff --git a/internal/decision/trusted_service.go b/internal/decision/trusted_service.go new file mode 100644 index 0000000..e9ceb4b --- /dev/null +++ b/internal/decision/trusted_service.go @@ -0,0 +1,193 @@ +package decision + +import ( + "context" + "encoding/json" + "github.com/netkingdom/access-engine/internal/callerauth" + "github.com/netkingdom/access-engine/internal/sign" + "github.com/netkingdom/access-engine/internal/taskexecution" + "github.com/netkingdom/access-engine/pkg/api" + "github.com/netkingdom/approval-engine/adapters/go/approvalfact" + "time" +) + +// legacyDigestProjection preserves the published PDP digest profile while the +// signed structured binding retains original numeric kinds. It is used only +// for digest material, never to reconstruct an Approval-native act. +func legacyDigestProjection(request api.CheckRequest) (api.CheckRequest, error) { + raw, err := json.Marshal(request) + if err != nil { + return api.CheckRequest{}, err + } + var projected api.CheckRequest + err = json.Unmarshal(raw, &projected) + return projected, err +} +func nativeServiceAct(request api.CheckRequest) ([]byte, error) { + purpose, ok := request.Context["purpose"].(string) + if !ok || purpose == "" || request.Subject.ID != "secrets-engine" || request.Subject.Type != "service" { + return nil, taskexecution.ErrServiceInput + } + if principal, exists := request.Subject.Attributes["principal"]; exists && principal != "secrets-engine" { + return nil, taskexecution.ErrServiceInput + } + // All map numbers originate from the number-preserving request/registry + // profile. This raw draft is canonicalized only by the Approval owner helper. + return json.Marshal(map[string]any{"action": request.Action, "actor": request.Subject.ID, "principal": "secrets-engine", "purpose": purpose, "target": request.Resource}) +} +func (e *Engine) signTaskEnvelope(envelope *api.DecisionEnvelope) error { + e.mu.RLock() + key, kid := e.signingKey, e.signingKID + e.mu.RUnlock() + if len(key) == 0 { + return ErrServicePilotUnavailable + } + return sign.Sign(envelope, key, kid) +} + +// CheckTrustedService is the only v2 task evaluation path. Its source reader +// and immutable service input are server-created, not HTTP validation flags. +// It never consumes an approval or causes a protected backend effect. +func (e *Engine) CheckTrustedService(ctx context.Context, input taskexecution.ServiceInput, source *taskexecution.ApprovalReader) (api.DecisionEnvelope, error) { + if !e.IsTrustedServicePilot() || !input.NumberPreserved() || !source.Configured() { + return api.DecisionEnvelope{}, ErrServicePilotUnavailable + } + now := e.now() + submitted, err := input.RequestAt(now) + if err != nil { + return api.DecisionEnvelope{}, err + } + normalized, facts := e.normalizeRequest(submitted) + if (facts.resourceFound || facts.subjectFound) && !e.store.NumbersPreserved() { + return api.DecisionEnvelope{}, ErrServicePilotUnavailable + } + original, err := legacyDigestProjection(submitted) + if err != nil { + return api.DecisionEnvelope{}, err + } + evaluated, err := legacyDigestProjection(normalized) + if err != nil { + return api.DecisionEnvelope{}, err + } + native, err := nativeServiceAct(normalized) + if err != nil { + return api.DecisionEnvelope{}, err + } + ctx = callerauth.WithRecord(ctx, input.Caller()) + var approvalEvidence *api.ApprovalObservationProvenance + deadline := input.Caller().NotAfter + contract := submitted.Context["execution_contract"].(map[string]any) + operationEnd, err := time.Parse(time.RFC3339Nano, contract["operation_deadline"].(string)) + if err != nil { + return api.DecisionEnvelope{}, taskexecution.ErrServiceInput + } + if operationEnd.Before(deadline) { + deadline = operationEnd + } + carried, hasClaim := submitted.Context[api.ApprovalContextKey] + if hasClaim { + claim, ok := carried.(map[string]any) + if !ok { + return api.DecisionEnvelope{}, taskexecution.ErrApprovalSource + } + id, ok := claim["approval_id"].(string) + if !ok { + return api.DecisionEnvelope{}, taskexecution.ErrApprovalSource + } + originalRaw, err := json.Marshal(carried) + if err != nil { + return api.DecisionEnvelope{}, taskexecution.ErrApprovalSource + } + observed, err := source.Observe(ctx, id) + if err != nil { + return api.DecisionEnvelope{}, err + } + // One validation instant AFTER network I/O; a fresh read cannot rescue the + // original carried claim's freshness, source expiry or operation deadline. + now = e.now() + if _, err := input.RequestAt(now); err != nil { + return api.DecisionEnvelope{}, err + } + verified, err := observed.Validate(originalRaw, approvalfact.Expected{ApprovalID: id, NativeBindingJSON: native, PDPDigest: api.ApprovalBindingDigest(evaluated)}, now) + if err != nil { + return api.DecisionEnvelope{}, err + } + factEnd, err := verified.Deadline() + if err != nil { + return api.DecisionEnvelope{}, err + } + if factEnd.Before(deadline) { + deadline = factEnd + } + approvalEvidence, err = verified.Provenance() + if err != nil { + return api.DecisionEnvelope{}, err + } + } + expectation, err := e.policy.EvaluatePreservingNumbers(ctx, normalized) + if err != nil { + return api.DecisionEnvelope{}, err + } + if !hasClaim && expectation.Effect == api.DecisionEffectAllow { + return api.DecisionEnvelope{}, ErrServicePilotUnavailable + } + now = e.now() + if _, err := input.RequestAt(now); err != nil { + return api.DecisionEnvelope{}, err + } + envelope := e.envelope(ctx, normalized, submitted, expectation, facts) + envelope.Binding.RequestDigest = api.RequestDigest(evaluated) + envelope.Binding.SubmittedRequestDigest = api.RequestDigest(original) + // Claim-free signed DENY is the producer-owned normalization receipt used + // before Approval issue. It supplies facts only, never execution authority. + envelope.Binding.ApprovalBindingDigest = api.ApprovalBindingDigest(evaluated) + envelope.Provenance.ApprovalObservation = approvalEvidence + if envelope.Effect == api.DecisionEffectAllow { + if approvalEvidence == nil || envelope.Lifetime == nil { + return api.DecisionEnvelope{}, ErrServicePilotUnavailable + } + lifetime, err := trustedTaskLifetime(contract, operationEnd, deadline, now, e.policy.Metadata.AllowTTL) + if err != nil { + return api.DecisionEnvelope{}, err + } + envelope.Lifetime = lifetime + } + if err := e.signTaskEnvelope(&envelope); err != nil { + return api.DecisionEnvelope{}, err + } + if err := e.recordDecisions([]api.DecisionEnvelope{envelope}); err != nil { + return api.DecisionEnvelope{}, err + } + return envelope, nil +} + +// The source ceiling is deliberately no larger than the existing task-token +// ceiling. Structural backend budgets do not guarantee an executable window. +const trustedTaskMaxAuthority = 120 * time.Second +const trustedTaskCleanupReserve = 5 * time.Second + +// trustedTaskLifetime authorizes the entire exact operation, not merely its +// start. It cannot extend any caller, original/fresh fact, or approval bound. +func trustedTaskLifetime(contract map[string]any, operationEnd, authorityEnd, now time.Time, declared string) (*api.DecisionLifetime, error) { + maximum, err := api.ParseAllowTTL(declared) + if err != nil || maximum <= 0 || maximum > trustedTaskMaxAuthority || now.IsZero() || operationEnd.After(authorityEnd) || !operationEnd.After(now) || operationEnd.Sub(now) > maximum { + return nil, ErrServicePilotUnavailable + } + budget, ok := contract["backend_budget"].(map[string]any) + if !ok { + return nil, ErrServicePilotUnavailable + } + number, ok := budget["ttl_seconds"].(json.Number) + if !ok { + return nil, ErrServicePilotUnavailable + } + seconds, err := number.Int64() + if err != nil || seconds < 30 || seconds > 120 { + return nil, ErrServicePilotUnavailable + } + remaining := operationEnd.Sub(now) + if remaining < time.Duration(seconds)*time.Second+trustedTaskCleanupReserve { + return nil, ErrServicePilotUnavailable + } + return &api.DecisionLifetime{Kind: api.DecisionLifetimeTTL, TTL: remaining.String(), NotBefore: now.UTC().Format(time.RFC3339Nano), ExpiresAt: operationEnd.UTC().Format(time.RFC3339Nano)}, nil +} diff --git a/internal/decision/trusted_service_time_test.go b/internal/decision/trusted_service_time_test.go new file mode 100644 index 0000000..3120cca --- /dev/null +++ b/internal/decision/trusted_service_time_test.go @@ -0,0 +1,56 @@ +package decision + +import ( + "encoding/json" + "testing" + "time" +) + +func TestTrustedTaskWholeOperationLifetime(t *testing.T) { + now := time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC) + contract := func(ttl string) map[string]any { + return map[string]any{"backend_budget": map[string]any{"ttl_seconds": json.Number(ttl), "uses": json.Number("12")}} + } + end := now.Add(60 * time.Second) + lifetime, err := trustedTaskLifetime(contract("30"), end, now.Add(120*time.Second), now, "120s") + if err != nil { + t.Fatal(err) + } + if lifetime.ExpiresAt != end.Format(time.RFC3339) || lifetime.TTL != "1m0s" || lifetime.NotBefore != now.Format(time.RFC3339) { + t.Fatal("lifetime not derived from exact operation") + } + fractional := end.Add(123456789 * time.Nanosecond) + lifetime, err = trustedTaskLifetime(contract("30"), fractional, fractional, now, "120s") + if err != nil || lifetime.ExpiresAt != fractional.Format(time.RFC3339Nano) { + t.Fatal("exact fractional deadline shortened or extended") + } + cases := []struct { + name string + ttl string + end, authority, time time.Time + maximum string + }{ + {"old-15s-conflict", "30", end, end, now, "15s"}, + {"insufficient-cleanup-reserve", "30", now.Add(35*time.Second - time.Nanosecond), end, now, "120s"}, + {"elapsed-consume-setup", "30", end, end, now.Add(26 * time.Second), "120s"}, + {"expired-operation", "30", now, now, now, "120s"}, + {"source-hard-maximum", "30", now.Add(120*time.Second + time.Nanosecond), now.Add(130 * time.Second), now, "120s"}, + {"attempted-policy-cap-bypass", "30", end, end, now, "121s"}, + {"shorter-original-or-fresh-fact", "30", end, end.Add(-time.Nanosecond), now, "120s"}, + {"structural-120-cannot-fit", "120", now.Add(120 * time.Second), now.Add(120 * time.Second), now, "120s"}, + {"noninteger-structural-ttl", "30.0", end, end, now, "120s"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if _, err := trustedTaskLifetime(contract(c.ttl), c.end, c.authority, c.time, c.maximum); err == nil { + t.Fatal("invalid temporal authority accepted") + } + }) + } + if _, err := trustedTaskLifetime(contract("30"), now.Add(35*time.Second), end, now, "120s"); err != nil { + t.Fatal("exact required lifetime refused") + } + if _, err := trustedTaskLifetime(contract("115"), now.Add(120*time.Second), now.Add(120*time.Second), now, "120s"); err != nil { + t.Fatal("max fitting declared budget refused") + } +} diff --git a/internal/emission/delivery.go b/internal/emission/delivery.go index 0f208ba..e138642 100644 --- a/internal/emission/delivery.go +++ b/internal/emission/delivery.go @@ -112,7 +112,9 @@ func (o *Outbox) nextEntry() (entry, int64, error) { return entry{}, 0, err } var e entry - if err := json.Unmarshal(line, &e); err != nil { + decoder := json.NewDecoder(bytes.NewReader(line)) + decoder.UseNumber() + if err := decoder.Decode(&e); err != nil { return entry{}, 0, err } if e.Seq != o.cursor.Seq+1 || e.Event.Source != o.source { diff --git a/internal/emission/numeric_replay_test.go b/internal/emission/numeric_replay_test.go new file mode 100644 index 0000000..e4c433f --- /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 index f4c15c9..b3ab758 100644 --- a/internal/emission/outbox.go +++ b/internal/emission/outbox.go @@ -136,7 +136,9 @@ func (o *Outbox) load() error { return os.Truncate(o.path, o.size) } var e entry - if err := json.Unmarshal(data[good:good+end], &e); err != nil { + decoder := json.NewDecoder(bytes.NewReader(data[good : good+end])) + decoder.UseNumber() + if err := decoder.Decode(&e); err != nil { return fmt.Errorf("outbox line %d is corrupt: %w", line, err) } if e.Seq != o.seq+1 || e.Event.Source != o.source { diff --git a/internal/policy/package.go b/internal/policy/package.go index 82e5ffe..52fb23e 100644 --- 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) runFixtures(ctx context.Context) []FixtureResult { } func (p *Package) evaluateDecision(ctx context.Context, request api.CheckRequest) (api.DecisionExpectation, error) { + return p.evaluateDecisionProfile(ctx, request, false) +} + +// EvaluatePreservingNumbers feeds the same evaluator the task profile's exact +// numeric values. Ordinary evaluation behavior and canonical contracts stay put. +func (p *Package) EvaluatePreservingNumbers(ctx context.Context, request api.CheckRequest) (api.DecisionExpectation, error) { + return p.evaluateDecisionProfile(ctx, request, true) +} +func (p *Package) evaluateDecisionProfile(ctx context.Context, request api.CheckRequest, preserve bool) (api.DecisionExpectation, error) { input, err := toRegoInput(request) + if preserve { + raw, e := json.Marshal(request) + if e != nil { + return api.DecisionExpectation{}, e + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + err = decoder.Decode(&input) + } if err != nil { return api.DecisionExpectation{}, err } diff --git a/internal/registry/numeric_source_test.go b/internal/registry/numeric_source_test.go new file mode 100644 index 0000000..8b1bc30 --- /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 index 6ea9de8..09c160a 100644 --- 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 @@ import ( "sort" "github.com/netkingdom/access-engine/pkg/api" + "github.com/netkingdom/approval-engine/adapters/go/approvalfact" ) // Store is a deterministic local registry for standalone development and tests. type Store struct { + numbersPreserved bool systems map[string]api.ProtectedSystemManifest resourceManifests map[string]api.ResourceManifest resources map[string]api.Resource @@ -66,6 +69,32 @@ func LoadFile(path string) (*Store, error) { return store, nil } +// LoadFilePreservingNumbers is the task-source profile: owner registry numeric +// representations stay intact for native act correspondence. Ordinary loaders +// and their existing snapshot/digest behavior remain unchanged. +func LoadFilePreservingNumbers(path string) (*Store, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + if _, err := approvalfact.DecodeJSON(raw); err != nil { + return nil, err + } + var snapshot Snapshot + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + decoder.DisallowUnknownFields() + if err := decoder.Decode(&snapshot); err != nil { + return nil, err + } + store := NewStore() + if err := store.ApplySnapshot(snapshot); err != nil { + return nil, err + } + store.numbersPreserved = true + return store, nil +} + // SaveFile writes the store as deterministic pretty-printed JSON. func (s *Store) SaveFile(path string) error { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { @@ -380,3 +409,6 @@ func sortedValues[T any](items map[string]T) []T { } return out } + +// NumbersPreserved reports the source profile of file-backed registry facts. +func (s *Store) NumbersPreserved() bool { return s != nil && s.numbersPreserved } diff --git a/internal/sign/numeric_decode_test.go b/internal/sign/numeric_decode_test.go new file mode 100644 index 0000000..89afc9b --- /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 index fc607f7..011f8f7 100644 --- 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 @@ func Verify(envelope api.DecisionEnvelope, keys map[string]ed25519.PublicKey) er } return nil } + +// DecodeEnvelope preserves JSON numeric kinds/lexemes emitted by task inputs. +// Legacy producer envelopes remain byte-compatible. It does not invent another +// signature canonicalization: CanonicalBytes still calls typed json.Marshal. +func DecodeEnvelope(raw []byte) (api.DecisionEnvelope, error) { + var envelope api.DecisionEnvelope + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + decoder.DisallowUnknownFields() + if err := decoder.Decode(&envelope); err != nil { + return envelope, err + } + if _, err := decoder.Token(); err != io.EOF { + return api.DecisionEnvelope{}, fmt.Errorf("trailing envelope data") + } + return envelope, nil +} diff --git a/internal/taskexecution/approval_source.go b/internal/taskexecution/approval_source.go new file mode 100644 index 0000000..e5fb1fc --- /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 index 0000000..553b387 --- /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 index 300d856..a6ab6b6 100644 --- a/internal/taskexecution/service_input.go +++ b/internal/taskexecution/service_input.go @@ -7,6 +7,7 @@ import ( "errors" "github.com/netkingdom/access-engine/internal/callerauth" "github.com/netkingdom/access-engine/pkg/api" + "github.com/netkingdom/approval-engine/adapters/go/approvalfact" "io" "time" ) @@ -15,6 +16,7 @@ var ErrServiceInput = errors.New("service task execution input refused") const ServicePilotPackageID = "secrets-engine.task-execution.service-pilot" const ServicePilotPackageVersion = "v1" +const TrustedServicePilotPackageVersion = "v2" const ServicePilotMappingVersion = "proposed-service-task-caller/v1" const ServicePilotPrincipal = "system:serviceaccount:secrets-engine:secrets-engine" @@ -24,7 +26,10 @@ const ServicePilotPrincipal = "system:serviceaccount:secrets-engine:secrets-engi // It installs the explicit // proposed fixed subject-to-transport mapping, never a request-selected mapping. // No constructor parameter can select warn/disabled mode or another subject. -type ServicePilotInputs struct{ auth *callerauth.Authenticator } +type ServicePilotInputs struct { + auth *callerauth.Authenticator + preserveNumbers bool +} func NewServicePilotInputs(reviewer callerauth.TokenReviewer) (*ServicePilotInputs, error) { a, e := callerauth.New(callerauth.ModeEnforce, reviewer, "flex-auth", map[string]string{"secrets-engine": ServicePilotPrincipal}, nil) @@ -34,12 +39,24 @@ func NewServicePilotInputs(reviewer callerauth.TokenReviewer) (*ServicePilotInpu return &ServicePilotInputs{auth: a}, nil } +// NewTrustedServiceInputs preserves numeric representations only for the exact +// v2 task path; it grants no Approval-source trust or executable eligibility. +func NewTrustedServiceInputs(reviewer callerauth.TokenReviewer) (*ServicePilotInputs, error) { + input, err := NewServicePilotInputs(reviewer) + if err != nil { + return nil, err + } + input.preserveNumbers = true + return input, nil +} + // ServiceInput cannot be fabricated through exported fields. It proves only // source input preparation through TokenReview, not approval provenance/consume // or execution authority. Default zero value is not an authenticated input. type ServiceInput struct { - serialized []byte - caller callerauth.Record + serialized []byte + caller callerauth.Record + preserveNumbers bool } func (s ServiceInput) Request() (api.CheckRequest, error) { @@ -47,7 +64,11 @@ func (s ServiceInput) Request() (api.CheckRequest, error) { return api.CheckRequest{}, ErrServiceInput } var r api.CheckRequest - if json.Unmarshal(s.serialized, &r) != nil { + decoder := json.NewDecoder(bytes.NewReader(s.serialized)) + if s.preserveNumbers { + decoder.UseNumber() + } + if decoder.Decode(&r) != nil { return r, ErrServiceInput } return r, nil @@ -142,5 +163,27 @@ func (s *ServicePilotInputs) Prepare(ctx context.Context, authorization string, if err != nil { return refuse() } - return ServiceInput{serialized: raw, caller: record}, nil + return ServiceInput{serialized: raw, caller: record, preserveNumbers: s.preserveNumbers}, nil +} + +func (s ServiceInput) NumberPreserved() bool { return s.preserveNumbers } + +// DecodeTrustedServiceRequest preserves numbers in the exact new task profile. +// Owner helper validates strict Unicode/JSON; this is not a new canonicalizer. +func DecodeTrustedServiceRequest(reader io.Reader) (api.CheckRequest, error) { + raw, err := io.ReadAll(io.LimitReader(reader, 524289)) + if err != nil || len(raw) > 524288 { + return api.CheckRequest{}, ErrServiceInput + } + if _, err := approvalfact.DecodeJSON(raw); err != nil { + return api.CheckRequest{}, ErrServiceInput + } + var request api.CheckRequest + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + decoder.DisallowUnknownFields() + if decoder.Decode(&request) != nil { + return api.CheckRequest{}, ErrServiceInput + } + return request, nil } diff --git a/pkg/api/canonical.go b/pkg/api/canonical.go index 3baa784..ae11a2d 100644 --- a/pkg/api/canonical.go +++ b/pkg/api/canonical.go @@ -635,16 +635,30 @@ type Obligation struct { // DecisionProvenance captures evaluator and policy provenance. type DecisionProvenance struct { - Evaluator string `json:"evaluator" yaml:"evaluator"` - Mode string `json:"mode" yaml:"mode"` - PolicyPackage string `json:"policy_package,omitempty" yaml:"policy_package,omitempty"` - PolicyVersion string `json:"policy_version,omitempty" yaml:"policy_version,omitempty"` - PolicyPackageDigest string `json:"policy_package_digest,omitempty" yaml:"policy_package_digest,omitempty"` - RegistrySnapshotDigest string `json:"registry_snapshot_digest,omitempty" yaml:"registry_snapshot_digest,omitempty"` - DirectoryETag string `json:"directory_etag,omitempty" yaml:"directory_etag,omitempty"` - InputClaimDigests map[string]string `json:"input_claim_digests,omitempty" yaml:"input_claim_digests,omitempty"` - DecisionTime string `json:"decision_time,omitempty" yaml:"decision_time,omitempty"` - Caller *CallerProvenance `json:"caller,omitempty" yaml:"caller,omitempty"` + Evaluator string `json:"evaluator" yaml:"evaluator"` + Mode string `json:"mode" yaml:"mode"` + PolicyPackage string `json:"policy_package,omitempty" yaml:"policy_package,omitempty"` + PolicyVersion string `json:"policy_version,omitempty" yaml:"policy_version,omitempty"` + PolicyPackageDigest string `json:"policy_package_digest,omitempty" yaml:"policy_package_digest,omitempty"` + RegistrySnapshotDigest string `json:"registry_snapshot_digest,omitempty" yaml:"registry_snapshot_digest,omitempty"` + DirectoryETag string `json:"directory_etag,omitempty" yaml:"directory_etag,omitempty"` + InputClaimDigests map[string]string `json:"input_claim_digests,omitempty" yaml:"input_claim_digests,omitempty"` + DecisionTime string `json:"decision_time,omitempty" yaml:"decision_time,omitempty"` + ApprovalObservation *ApprovalObservationProvenance `json:"approval_observation,omitempty" yaml:"approval_observation,omitempty"` + Caller *CallerProvenance `json:"caller,omitempty" yaml:"caller,omitempty"` +} + +// ApprovalObservationProvenance binds fresh server-owned fact evidence without +// rewriting the original carried claim/context or either PDP request digest. +type ApprovalObservationProvenance struct { + SourceID string `json:"source_id"` + SourceRevision string `json:"source_revision"` + ConfigurationDigest string `json:"configuration_digest"` + ClaimDigest string `json:"claim_digest"` + NativeBindingDigest string `json:"native_binding_digest"` + ObservedAt string `json:"observed_at"` + NotAfter string `json:"not_after"` + ValidatedAt string `json:"validated_at"` } // CallerProvenance records how the request was authenticated to flex-auth. diff --git a/third_party/approvalfact/README.md b/third_party/approvalfact/README.md new file mode 100644 index 0000000..fdffd1f --- /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 index 0000000..a88f5b9 --- /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 index 0000000..06f14b5 --- /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 index 0000000..169643f --- /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 index 0000000..bcb1783 --- /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 index c31ff92..dca8757 100644 --- a/workplans/FLEX-WP-0024-decision-envelope-authenticity.md +++ b/workplans/FLEX-WP-0024-decision-envelope-authenticity.md @@ -239,3 +239,36 @@ source evidence only. B2 depends on independently reviewed Approval fact contrac and canonical vectors; B3 additionally needs exact source/normalization numeric representation and full trusted provider joins. No current policy false gate, production trust, native deployment or protected effect is enabled by B1. + +2026-10-16 B2/B3 source successor preparation (separate from frozen B1): the +isolated v2 source path now joins the fixed authenticated Approval reader, +unchanged Approval-owner canonical/fact helper, original/fresh correspondence, +raw numeric request/registry source retention, same policy evaluator and signed +source provenance. Disposable actual HTTP and production serve subprocess prove +synthetic signed ALLOW and fail-closed negative joins with exact response/record +parity. See `docs/trusted-service-task-source.md`. T06 remains `progress`: this +source proposal requires independent cross-owner review and full consumer/Platform +successor integration, then separately admitted runtime custody, source trust, +image provenance and rollout. No source-only proof establishes those runtime gates. + + +2026-10-16 B2/B3 R2 temporal correction (R1 retained): whole-stack review exposed +that R1's 15s ALLOW and default Approval freshness30 cannot fit the declared +minimum backendTTL30 plus existing cleanupreserve5. The v2 successor now derives +exact ALLOW expiry from the immutable operationdeadline, requiring all original/ +fresh approval/freshness/caller bounds, a conservative source maximum120 and +remaining declaredTTL+5 after source I/O/evaluation. No consumer/native deadline +or cleanup guard is relaxed. Max structural budgets that cannot fit refuse. +The fixture declares backendTTL30/deadline60 and explicit source freshness120; +owner runtime source freshness configuration and full native integration remain +separate reviewed gates. See docs/trusted-service-task-source.md. + +2026-10-16 B2/B3 R3 follows actual retained-registry integration failure: subject +enrichment changed evaluated approval-free digest from the original proposal. +The existing signed claim-free DENY now explicitly carries v2 approvalfree digest +(equal evaluated requestdigest), enabling producer-owned preapproval facts under +the already published enrichment contract. The final original proposal adds only +the issued claim; registry drift/refreshed facts cannot silently rebind it. +Retained-registry HTTP and production serve proofs and negative joins pass. +R3 preserves precise notbefore pending separate Approval-owner timestamp fidelity +repair; no rounding waiver, copied registry algorithm or empty-registry workaround.