Bind trusted task approval to exact producer facts and lifetime
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a140c4-354a-7823-8f0d-8f717397f786
This commit is contained in:
parent
4f6946bbb3
commit
2b064eee51
31 changed files with 2170 additions and 30 deletions
|
|
@ -19,6 +19,7 @@ on:
|
|||
- "cmd/**"
|
||||
- "internal/**"
|
||||
- "pkg/**"
|
||||
- "third_party/**"
|
||||
- "examples/**"
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
430
cmd/access-engine/trusted_service_http_test.go
Normal file
430
cmd/access-engine/trusted_service_http_test.go
Normal file
|
|
@ -0,0 +1,430 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/netkingdom/access-engine/internal/audit"
|
||||
"github.com/netkingdom/access-engine/internal/callerauth"
|
||||
"github.com/netkingdom/access-engine/internal/decision"
|
||||
"github.com/netkingdom/access-engine/internal/policy"
|
||||
"github.com/netkingdom/access-engine/internal/registry"
|
||||
"github.com/netkingdom/access-engine/internal/sign"
|
||||
"github.com/netkingdom/access-engine/internal/taskexecution"
|
||||
"github.com/netkingdom/access-engine/pkg/api"
|
||||
"github.com/netkingdom/approval-engine/adapters/go/approvalfact"
|
||||
)
|
||||
|
||||
func trustedRequest(t *testing.T, now time.Time) api.CheckRequest {
|
||||
t.Helper()
|
||||
r := pilotRequest(now)
|
||||
r.Context["execution_contract"].(map[string]any)["operation_deadline"] = now.Add(60 * time.Second).UTC().Truncate(time.Second).Format(time.RFC3339)
|
||||
r.Subject.Tenant = r.Tenant
|
||||
r.Resource.Tenant = r.Tenant
|
||||
r.Resource.Attributes["numeric_fixture"] = json.Number("1.0")
|
||||
c := r.Context["execution_contract"].(map[string]any)
|
||||
c["operation_id"] = "11111111-2222-4333-8444-555555555555"
|
||||
c["ccr_id"] = "synthetic-change"
|
||||
c["execution_grant_ccr_id"] = "synthetic-grant"
|
||||
hash := "sha256:" + strings.Repeat("1", 64)
|
||||
for _, key := range []string{"ccr_immutable_sha256", "grant_immutable_sha256", "catalog_sha256", "expected_before", "expected_after"} {
|
||||
c[key] = hash
|
||||
}
|
||||
c["backend_budget"] = map[string]any{"ttl_seconds": json.Number("30"), "uses": json.Number("12")}
|
||||
var ops []any
|
||||
for i, method := range []string{"GET", "PUT", "GET", "GET", "PUT", "GET"} {
|
||||
path := "sys/policies/acl/synthetic"
|
||||
if i >= 3 {
|
||||
path = "auth/synthetic/role/synthetic"
|
||||
}
|
||||
ops = append(ops, map[string]any{"method": method, "path": path, "payload_sha256": hash})
|
||||
}
|
||||
c["operations"] = ops
|
||||
delete(r.Context, "approval")
|
||||
raw, _ := json.Marshal(map[string]any{"action": r.Action, "actor": r.Subject.ID, "principal": "secrets-engine", "purpose": "synthetic source-only proof", "target": r.Resource})
|
||||
r.Context["purpose"] = "synthetic source-only proof"
|
||||
_, digest, err := approvalfact.CanonicalBinding(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bindingValue, err := approvalfact.DecodeJSON(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
binding := bindingValue.(map[string]any)
|
||||
// PDP profile intentionally retains the published legacy numeric projection.
|
||||
encoded, _ := json.Marshal(r)
|
||||
var legacy api.CheckRequest
|
||||
json.Unmarshal(encoded, &legacy)
|
||||
binding["digest"] = digest
|
||||
binding["pdp_digest"] = api.ApprovalBindingDigest(legacy)
|
||||
binding["pdp_path"] = true
|
||||
binding["human_control"] = true
|
||||
r.Context["approval"] = map[string]any{"schema_version": "0.1", "kind": "approval-claim", "issuer": "approval-engine", "approval_id": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", "state": "valid", "valid_now": true, "consumed": false, "reason_code": "ok", "binding": binding, "freshness": map[string]any{"observed_at": now.Add(-time.Second).Format(time.RFC3339Nano), "ttl_seconds": json.Number("120"), "not_after": now.Add(119 * time.Second).Format(time.RFC3339Nano)}, "validity": map[string]any{"not_before": now.Add(-time.Hour).Format(time.RFC3339Nano), "expires_at": now.Add(time.Hour).Format(time.RFC3339Nano)}}
|
||||
return r
|
||||
}
|
||||
func TestTrustedServiceActualHTTPPositiveAndRefusals(t *testing.T) { testTrustedServiceHTTP(t, false) }
|
||||
func TestTrustedServiceRetainedRegistryPreapprovalAndRefusals(t *testing.T) {
|
||||
testTrustedServiceHTTP(t, true)
|
||||
}
|
||||
func testTrustedServiceHTTP(t *testing.T, retained bool) {
|
||||
now := time.Now().UTC()
|
||||
folder := t.TempDir()
|
||||
reviewer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/apis/authentication.k8s.io/v1/tokenreviews" || r.Header.Get("Authorization") != "Bearer synthetic-reviewer" {
|
||||
w.WriteHeader(403)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{"status": map[string]any{"authenticated": true, "audiences": []string{"flex-auth"}, "user": map[string]any{"username": taskexecution.ServicePilotPrincipal}}})
|
||||
}))
|
||||
defer reviewer.Close()
|
||||
ca := filepath.Join(folder, "review-ca")
|
||||
token := filepath.Join(folder, "review-token")
|
||||
os.WriteFile(ca, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: reviewer.Certificate().Raw}), 0600)
|
||||
os.WriteFile(token, []byte("synthetic-reviewer"), 0600)
|
||||
original := trustedRequest(t, now)
|
||||
if retained {
|
||||
delete(original.Context, "approval")
|
||||
}
|
||||
claimRaw, _ := json.Marshal(original.Context["approval"])
|
||||
mode := "ok"
|
||||
reads := 0
|
||||
provider := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
reads++
|
||||
if r.URL.Path != "/v1/approvals/aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee/claim" || r.Header.Get("Authorization") != "Bearer synthetic-approval-reader" {
|
||||
w.WriteHeader(403)
|
||||
return
|
||||
}
|
||||
if mode == "redirect" {
|
||||
http.Redirect(w, r, reviewer.URL, 302)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch mode {
|
||||
case "duplicate":
|
||||
io.WriteString(w, `{"state":"valid","state":"invalid"}`)
|
||||
case "changed":
|
||||
v, _ := approvalfact.DecodeJSON(claimRaw)
|
||||
v.(map[string]any)["consumed"] = true
|
||||
json.NewEncoder(w).Encode(v)
|
||||
case "short-fresh":
|
||||
v, _ := approvalfact.DecodeJSON(claimRaw)
|
||||
fresh := v.(map[string]any)["freshness"].(map[string]any)
|
||||
fresh["ttl_seconds"] = json.Number("20")
|
||||
fresh["not_after"] = now.Add(19 * time.Second).Format(time.RFC3339Nano)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
case "expired":
|
||||
v, _ := approvalfact.DecodeJSON(claimRaw)
|
||||
v.(map[string]any)["freshness"].(map[string]any)["not_after"] = now.Add(-time.Second).Format(time.RFC3339Nano)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
default:
|
||||
w.Write(claimRaw)
|
||||
}
|
||||
}))
|
||||
defer provider.Close()
|
||||
pca := filepath.Join(folder, "approval-ca")
|
||||
ptoken := filepath.Join(folder, "approval-token")
|
||||
os.WriteFile(pca, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: provider.Certificate().Raw}), 0600)
|
||||
os.WriteFile(ptoken, []byte("synthetic-approval-reader"), 0440)
|
||||
source, err := taskexecution.NewApprovalReader(taskexecution.ApprovalSourceConfig{SourceID: "synthetic-approval", SourceRevision: "test-source-revision", Origin: provider.URL, StoreTenant: "tenant:platform", CAFile: pca, TokenFile: ptoken})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pkg, err := policy.LoadAndValidateFile(context.Background(), filepath.Join("..", "..", "examples", "secrets-engine-service-task-execution-v2", "policy_package.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := registry.NewStore()
|
||||
if retained {
|
||||
store, err = registry.LoadFilePreservingNumbers(filepath.Join("..", "..", "examples", "secrets-engine", "registry_snapshot.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
engine, err := decision.NewEngine(store, pkg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{19}, 32))
|
||||
engine.SetEnvelopeSigningKey(key, "synthetic-v2")
|
||||
record := filepath.Join(folder, "record.jsonl")
|
||||
engine.SetDecisionLog(audit.NewJSONLDecisionLog(record))
|
||||
inputs, err := buildServicePilotInputs(engine, callerauth.ModeEnforce, "flex-auth", reviewer.URL, token, ca, map[string]string{"secrets-engine": taskexecution.ServicePilotPrincipal})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewServer(newServeMuxWithTaskFacts(engine, callerauth.Disabled(), inputs, source))
|
||||
defer server.Close()
|
||||
post := func(request api.CheckRequest) (int, []byte) {
|
||||
t.Helper()
|
||||
raw, _ := json.Marshal(request)
|
||||
req, _ := http.NewRequest("POST", server.URL+"/v1/check", bytes.NewReader(raw))
|
||||
req.Header.Set("Authorization", "Bearer "+pilotToken("valid", now))
|
||||
response, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return response.StatusCode, body
|
||||
}
|
||||
var pre api.DecisionEnvelope
|
||||
if retained {
|
||||
preStatus, preRaw := post(original)
|
||||
if preStatus != 200 {
|
||||
t.Fatalf("preapproval status%d", preStatus)
|
||||
}
|
||||
pre, err = sign.DecodeEnvelope(preRaw)
|
||||
if err != nil || sign.Verify(pre, map[string]ed25519.PublicKey{"synthetic-v2": key.Public().(ed25519.PublicKey)}) != nil {
|
||||
t.Fatal("preapproval not authenticated")
|
||||
}
|
||||
if pre.Effect != api.DecisionEffectDeny || pre.Lifetime != nil || pre.Binding.ApprovalBindingDigest == "" || pre.Binding.ApprovalBindingDigest != pre.Binding.RequestDigest {
|
||||
t.Fatal("preapproval receipt is execution authority or lacks exactclaimfree digest")
|
||||
}
|
||||
if pre.Diagnostics["registry_subject"] != true || pre.Provenance.RegistrySnapshotDigest != store.Digest() {
|
||||
t.Fatal("retained registry not applied")
|
||||
}
|
||||
rawOriginal, _ := json.Marshal(original)
|
||||
var legacyPre api.CheckRequest
|
||||
json.Unmarshal(rawOriginal, &legacyPre)
|
||||
if pre.Binding.SubmittedRequestDigest != api.RequestDigest(legacyPre) || pre.Binding.RequestDigest == api.RequestDigest(legacyPre) {
|
||||
t.Fatal("original/evaluated retained registry distinction lost")
|
||||
}
|
||||
carried := trustedRequest(t, now).Context["approval"].(map[string]any)
|
||||
native := map[string]any{"action": pre.Binding.Action, "actor": pre.Binding.Subject.ID, "principal": "secrets-engine", "purpose": pre.Binding.Context["purpose"], "target": pre.Binding.Resource}
|
||||
nativeRaw, _ := json.Marshal(native)
|
||||
_, nativeDigest, err := approvalfact.CanonicalBinding(nativeRaw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
native["digest"] = nativeDigest
|
||||
native["pdp_digest"] = pre.Binding.ApprovalBindingDigest
|
||||
native["pdp_path"] = true
|
||||
native["human_control"] = true
|
||||
carried["binding"] = native
|
||||
original.Context["approval"] = carried
|
||||
claimRaw, _ = json.Marshal(carried)
|
||||
originalBytes, _ := json.Marshal(original)
|
||||
original, err = taskexecution.DecodeTrustedServiceRequest(bytes.NewReader(originalBytes))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
}
|
||||
status, body := post(original)
|
||||
if status != 200 {
|
||||
t.Fatalf("positive status %d: %s", status, body)
|
||||
}
|
||||
envelope, err := sign.DecodeEnvelope(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("actual HTTP source policy pin: %s %s %s", pkg.Metadata.ID, pkg.Metadata.Version, pkg.Digest())
|
||||
if envelope.Effect != api.DecisionEffectAllow || sign.Verify(envelope, map[string]ed25519.PublicKey{"synthetic-v2": key.Public().(ed25519.PublicKey)}) != nil {
|
||||
t.Fatalf("not signed ALLOW: %s", body)
|
||||
}
|
||||
if envelope.Provenance.ApprovalObservation == nil || envelope.Provenance.ApprovalObservation.SourceID != "synthetic-approval" || reads != 1 {
|
||||
t.Fatal("missing authenticated observation provenance")
|
||||
}
|
||||
legacyRaw, _ := json.Marshal(original)
|
||||
var legacyRequest api.CheckRequest
|
||||
json.Unmarshal(legacyRaw, &legacyRequest)
|
||||
if envelope.Binding.SubmittedRequestDigest != api.RequestDigest(legacyRequest) {
|
||||
t.Fatal("original submitted digest changed")
|
||||
}
|
||||
if retained {
|
||||
if envelope.Binding.RequestDigest == api.RequestDigest(legacyRequest) || envelope.Binding.ApprovalBindingDigest != pre.Binding.ApprovalBindingDigest {
|
||||
t.Fatal("normalized approval correspondence changed")
|
||||
}
|
||||
} else if envelope.Binding.RequestDigest != api.RequestDigest(legacyRequest) || envelope.Binding.ApprovalBindingDigest != api.ApprovalBindingDigest(legacyRequest) {
|
||||
t.Fatal("published digest profile changed")
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(envelope.Binding.Context, original.Context) {
|
||||
t.Fatal("original context changed")
|
||||
}
|
||||
if envelope.Lifetime.ExpiresAt != original.Context["execution_contract"].(map[string]any)["operation_deadline"] {
|
||||
t.Fatal("decision does not bound entire exact operation")
|
||||
}
|
||||
if envelope.Binding.Resource.Attributes["numeric_fixture"] != json.Number("1.0") {
|
||||
t.Fatal("native numeric kind lost")
|
||||
}
|
||||
recorded, err := os.ReadFile(record)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
records, err := audit.NewJSONLDecisionLog(record).ReadAll()
|
||||
expectedCount := 1
|
||||
if retained {
|
||||
expectedCount = 2
|
||||
}
|
||||
if err != nil || len(records) != expectedCount || !reflect.DeepEqual(records[len(records)-1], envelope) {
|
||||
t.Fatal("returned record parity")
|
||||
}
|
||||
if retained && !reflect.DeepEqual(records[0], pre) {
|
||||
t.Fatal("preapproval record parity")
|
||||
}
|
||||
for _, failure := range []string{"redirect", "duplicate", "changed", "expired", "short-fresh"} {
|
||||
t.Run(failure, func(t *testing.T) {
|
||||
mode = failure
|
||||
status, _ := post(original)
|
||||
if status == 200 {
|
||||
t.Fatal("bad source admitted")
|
||||
}
|
||||
})
|
||||
}
|
||||
mode = "ok"
|
||||
if retained {
|
||||
t.Run("registry-drift-cannot-rebind-approved-act", func(t *testing.T) {
|
||||
subject, _ := store.Subject("secrets-engine")
|
||||
originalSubject := subject
|
||||
subject.Roles = append(append([]api.CanonicalRole{}, subject.Roles...), api.CanonicalRole("synthetic-owner-drift"))
|
||||
if err := store.PutSubject(subject); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.PutSubject(originalSubject)
|
||||
if status, _ := post(original); status == 200 {
|
||||
t.Fatal("changed registry silently rebound original/fresh claim")
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("original-stale", func(t *testing.T) {
|
||||
raw, _ := json.Marshal(original)
|
||||
r, err := taskexecution.DecodeTrustedServiceRequest(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fresh := r.Context["approval"].(map[string]any)["freshness"].(map[string]any)
|
||||
fresh["observed_at"] = now.Add(-121 * time.Second).Format(time.RFC3339Nano)
|
||||
fresh["not_after"] = now.Add(-time.Second).Format(time.RFC3339Nano)
|
||||
status, _ := post(r)
|
||||
if status == 200 {
|
||||
t.Fatal("fresh provider rescued stale original")
|
||||
}
|
||||
})
|
||||
t.Run("native-number-kind", func(t *testing.T) {
|
||||
raw, _ := json.Marshal(original)
|
||||
r, _ := taskexecution.DecodeTrustedServiceRequest(bytes.NewReader(raw))
|
||||
r.Resource.Attributes["numeric_fixture"] = json.Number("1")
|
||||
status, _ := post(r)
|
||||
if status == 200 {
|
||||
t.Fatal("changed native integer/float authority accepted")
|
||||
}
|
||||
})
|
||||
after, _ := os.ReadFile(record)
|
||||
if !bytes.Equal(after, recorded) {
|
||||
t.Fatal("refused calls recorded executable envelopes")
|
||||
}
|
||||
// Exercise actual production CLI configuration with no ambient credentials.
|
||||
seedPath := filepath.Join(folder, "cli-seed")
|
||||
os.WriteFile(seedPath, []byte(base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{19}, 32))), 0440)
|
||||
sourcePath := filepath.Join(folder, "approval-source.json")
|
||||
sourceJSON, _ := json.Marshal(taskexecution.ApprovalSourceConfig{SourceID: "synthetic-approval", SourceRevision: "test-source-revision", Origin: provider.URL, StoreTenant: "tenant:platform", CAFile: pca, TokenFile: ptoken})
|
||||
os.WriteFile(sourcePath, sourceJSON, 0600)
|
||||
registryPath := filepath.Join(folder, "registry.json")
|
||||
registryBytes := []byte(`{}`)
|
||||
if retained {
|
||||
registryBytes, err = os.ReadFile(filepath.Join("..", "..", "examples", "secrets-engine", "registry_snapshot.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
os.WriteFile(registryPath, registryBytes, 0600)
|
||||
policyPath, _ := filepath.Abs(filepath.Join("..", "..", "examples", "secrets-engine-service-task-execution-v2", "policy_package.md"))
|
||||
cliRecord := filepath.Join(folder, "cli-record.jsonl")
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
address := listener.Addr().String()
|
||||
listener.Close()
|
||||
args := []string{"serve", "--addr", address, "--registry", registryPath, "--policy", policyPath, "--log", cliRecord, "--caller-auth-mode", "enforce", "--caller-audience", "flex-auth", "--caller-kubernetes-url", reviewer.URL, "--caller-reviewer-token-file", token, "--caller-ca-file", ca, "--caller-binding", "secrets-engine=" + taskexecution.ServicePilotPrincipal, "--envelope-signing-key-file", seedPath, "--envelope-signing-kid", "synthetic-v2", "--approval-source-file", sourcePath}
|
||||
argsJSON, _ := json.Marshal(args)
|
||||
child := exec.Command(os.Args[0], "-test.run=^TestServicePilotChildCLI$")
|
||||
child.Env = []string{"ACCESS_B1_SERVER_ARGS=" + string(argsJSON)}
|
||||
child.Stdout = io.Discard
|
||||
child.Stderr = io.Discard
|
||||
if err := child.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { child.Process.Kill(); child.Wait() }()
|
||||
ready := false
|
||||
for attempt := 0; attempt < 100; attempt++ {
|
||||
response, err := http.Get("http://" + address + "/healthz")
|
||||
if err == nil {
|
||||
response.Body.Close()
|
||||
ready = true
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if !ready {
|
||||
t.Fatal("trusted CLI not ready")
|
||||
}
|
||||
raw, _ := json.Marshal(original)
|
||||
req, _ := http.NewRequest("POST", "http://"+address+"/v1/check", bytes.NewReader(raw))
|
||||
req.Header.Set("Authorization", "Bearer "+pilotToken("valid", now))
|
||||
response, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
wire, _ := io.ReadAll(response.Body)
|
||||
cliEnvelope, err := sign.DecodeEnvelope(wire)
|
||||
if response.StatusCode != 200 || err != nil || cliEnvelope.Effect != api.DecisionEffectAllow || sign.Verify(cliEnvelope, map[string]ed25519.PublicKey{"synthetic-v2": key.Public().(ed25519.PublicKey)}) != nil {
|
||||
t.Fatalf("CLI did not return signed trusted ALLOW: %s", wire)
|
||||
}
|
||||
cliRecords, err := audit.NewJSONLDecisionLog(cliRecord).ReadAll()
|
||||
if err != nil || len(cliRecords) != 1 || !reflect.DeepEqual(cliRecords[0], cliEnvelope) {
|
||||
t.Fatal("CLI response/readback record mismatch")
|
||||
}
|
||||
if !reflect.DeepEqual(cliEnvelope.Binding.Context, original.Context) {
|
||||
t.Fatal("CLI changed original numeric context")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestTrustedServiceMissingAndGenericAdmissionRefuse(t *testing.T) {
|
||||
pkg, err := policy.LoadAndValidateFile(context.Background(), filepath.Join("..", "..", "examples", "secrets-engine-service-task-execution-v2", "policy_package.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
engine, err := decision.NewEngine(registry.NewStore(), pkg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := trustedRequest(t, time.Now().UTC())
|
||||
if _, err := engine.Check(context.Background(), request); err == nil {
|
||||
t.Fatal("generic check bypass")
|
||||
}
|
||||
if _, err := engine.BatchCheck(context.Background(), api.BatchCheckRequest{Action: "execute-ccr"}); err == nil {
|
||||
t.Fatal("generic batch bypass")
|
||||
}
|
||||
if _, err := engine.ListAllowed(context.Background(), decision.ListAllowedRequest{Action: "execute-ccr"}); err == nil {
|
||||
t.Fatal("generic list bypass")
|
||||
}
|
||||
if _, err := engine.CheckTrustedService(context.Background(), taskexecution.ServiceInput{}, nil); err == nil {
|
||||
t.Fatal("missing typed input and fixed source accepted")
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
raw, _ := json.Marshal(request)
|
||||
newServeMux(engine).ServeHTTP(recorder, httptest.NewRequest("POST", "/v1/check", bytes.NewReader(raw)))
|
||||
if recorder.Code != 503 {
|
||||
t.Fatal("unwired typed path accepted")
|
||||
}
|
||||
}
|
||||
108
docs/trusted-service-task-source.md
Normal file
108
docs/trusted-service-task-source.md
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
# Trusted service task source proposal (v2)
|
||||
|
||||
The separate `secrets-engine.task-execution.service-pilot` v2 source package can
|
||||
return ALLOW only through the existing `/v1/check` typed task path. The original
|
||||
v1 package remains unchanged and DENY-only. No chart, runtime, source identity,
|
||||
credential custody or native execution admission is enabled by these sources.
|
||||
|
||||
The server requires enforced TokenReview, audience `flex-auth`, the exact retained
|
||||
Secrets service-account mapping, mounted envelope signing configuration and an
|
||||
owner-installed `--approval-source-file`. This public configuration contains
|
||||
`source_id`, `source_revision`, fixed HTTPS `origin`, `store_tenant` exactly
|
||||
`tenant:platform`, absolute `ca_file` and absolute mounted `token_file`. The
|
||||
credential must independently carry existing Approval `approval:read` authority
|
||||
for that store. Source labels describe the owner-admitted configuration; they do
|
||||
not perform its admission. No key or token value belongs in the configuration.
|
||||
|
||||
The reader sends one authenticated GET to the fixed
|
||||
`/v1/approvals/{UUID}/claim` endpoint, accepts only bounded strict JSON, uses only
|
||||
the configured CA, refuses redirects and environmental proxies, and has a total
|
||||
three-second HTTP timeout. It neither creates nor approves nor consumes. Mounted
|
||||
bearer files must be regular, bounded and have no group write or other access;
|
||||
0440 is supported. Public configuration/CA files also refuse group/other writes.
|
||||
Errors do not include response bodies, credentials or transport diagnostics.
|
||||
|
||||
The unchanged Approval-owner pure data helper is copied with exact source pins
|
||||
under `third_party/approvalfact`. The fixed reader establishes provider origin;
|
||||
the helper establishes schema and semantic correspondence. Both original and
|
||||
fresh claims must independently be currently valid, unconsumed, human-controlled
|
||||
PDP-path claims for the exact UUID, native five-field act and Access approval-free
|
||||
request digest. Nonfreshness authority must match exactly. The freshly fetched
|
||||
claim never replaces the submitted claim/context or renews original freshness.
|
||||
|
||||
The native act derives from the normalized subject/resource, fixed service
|
||||
principal, action and nonempty `context.purpose`. The HTTP decoder and task registry
|
||||
loader preserve raw JSON numeric representations. Registry facts loaded through
|
||||
the old lossy profile cannot enter the trusted path. Only the Approval-owner
|
||||
helper canonicalizes native act data. Existing PDP full/submitted/approval-free
|
||||
digests retain their published numeric profile through a digest-only legacy
|
||||
projection; the signed structured binding retains the original numeric kinds.
|
||||
Inputs unsupported by that existing PDP profile refuse; this proposal does not
|
||||
weaken any consumer numeric admission or invent numeric restrictions.
|
||||
|
||||
The same existing policy evaluator decides after those joins. Generic check,
|
||||
batch and list entry points refuse task execution across all policy names. No
|
||||
request-selected verified flag, provider origin or policy enable switch bypasses
|
||||
the typed path. Missing approval can receive signed DENY for preapproval binding;
|
||||
ALLOW requires authenticated matching original/fresh claims. Its expiry is exactly the immutable operation deadline, after validating that
|
||||
it fits the source-selected 120s hard maximum and every caller/original/fresh
|
||||
freshness/approval bound. At least the declared backend TTL plus the existing
|
||||
5s cleanup reserve must still fit after source I/O and final evaluation. The
|
||||
source maximum conservatively matches the existing 120s task-token cap; the
|
||||
structural backend budget maximum is not a promise every budget can fit. In
|
||||
particular backend TTL120 cannot fit this authority ceiling plus reserve. A
|
||||
fractional operation deadline is retained exactly rather than rounded earlier.
|
||||
Approval's default 30s observation freshness cannot cover the minimum30+5
|
||||
operation budget. A separate owner-admitted source configuration must explicitly
|
||||
supply adequate original/fresh observation lifetime; fetching anew cannot renew
|
||||
an original claim. The disposable reviewed proof uses backendTTL30, immutable
|
||||
operationdeadline+60s and explicitly configured Approval freshness120. This is
|
||||
an entire-operation authority interval, not a decision-to-start window. Platform
|
||||
must retain native-entry and per-call deadline/cleanup gates; delays simply refuse. Existing source-owned signing covers the
|
||||
original binding and new `provenance.approval_observation` receipt. The receipt
|
||||
contains source/configuration identity, exact fetched-byte digest, native digest
|
||||
and observation/validation/deadline timestamps. JSONL readback and outbox replay
|
||||
preserve producer numeric signatures; no second signing algorithm is introduced.
|
||||
|
||||
Packaging must copy the local owner helper before `go mod download`. Existing
|
||||
ordinary image CI triggers include `third_party/**` so a helper update cannot
|
||||
silently leave the old image. No build publication is invoked by this preparation.
|
||||
Rollback selects the unchanged v1 package or removes the v2 admission; generic
|
||||
fallback remains refused. Rotating/withdrawing source or signing trust is a
|
||||
separate owner-admitted runtime operation. The earlier Platform positive proof
|
||||
remains evidence only at its old ec21518e Access pin; its generic synthetic policy
|
||||
is deliberately incompatible with the successor guarded source.
|
||||
|
||||
## Producer normalization before Approval issue
|
||||
|
||||
The retained registry enriches Secrets subject attributes. Consequently the
|
||||
submitted approval-free digest is not the evaluated approval-free digest. This
|
||||
is the existing `docs/request-enrichment.md` contract, not a reason to remove
|
||||
registry facts or copy its enrichment rules into Secrets.
|
||||
|
||||
The existing `/v1/check` typed v2 path accepts the exact original claim-free
|
||||
proposal and returns a signed DENY containing the authoritative normalized
|
||||
binding and original `submitted_request_digest`. For this v2 path it also emits
|
||||
`approval_binding_digest` when claim-free, equal to evaluated `request_digest`.
|
||||
That is a normalization receipt supplying facts only. It has no ALLOW lifetime
|
||||
and cannot satisfy execution authorization. Before issue, Secrets authenticates
|
||||
this receipt using its existing independent producer trust/policy/source pins,
|
||||
checks original request/context correspondence, caller/deadline/current-time
|
||||
bounds and normalized self-digests, then derives the native act and issue digest
|
||||
from the authenticated producer binding. It does not supply expected registry
|
||||
facts or expected digests to the producer. The Approval API records these exact
|
||||
facts at issue; human approval follows its unchanged authenticated contract.
|
||||
|
||||
The submitted proposal remains unchanged; the final request attaches only the
|
||||
original issued claim. Final ALLOW rechecks independent source facts, native/PDP
|
||||
correspondence, normalization and all temporal/caller conditions. A changed
|
||||
registry cannot silently refresh or replace an approved act: a changed evaluated
|
||||
digest/native target refuses even when the freshly fetched claim is still valid.
|
||||
The source tests cover the retained registry, signed DENY/ALLOW separation,
|
||||
original/evaluated digest distinction, unchanged context, source/registry pins,
|
||||
registry drift, stale/refreshed claims and exact response/record parity. Earlier
|
||||
empty-registry proofs remain explicitly scoped historical source fixtures.
|
||||
|
||||
R3 retains precise decision `not_before`; it does not floor that lower bound to
|
||||
mask Approval's current whole-second consumption receipt defect. Approval-owner
|
||||
precision repair and full owner integration remain separate reviewed evidence.
|
||||
|
|
@ -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}
|
||||
|
|
@ -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}}
|
||||
}
|
||||
```
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
# Synthetic source-validation fixture only; no estate registry admission.
|
||||
resources:
|
||||
- id: catalog:synthetic
|
||||
system: secrets-engine
|
||||
type: secret-catalog-lane
|
||||
attributes: {stage: prod}
|
||||
3
go.mod
3
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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
193
internal/decision/trusted_service.go
Normal file
193
internal/decision/trusted_service.go
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
package decision
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"github.com/netkingdom/access-engine/internal/callerauth"
|
||||
"github.com/netkingdom/access-engine/internal/sign"
|
||||
"github.com/netkingdom/access-engine/internal/taskexecution"
|
||||
"github.com/netkingdom/access-engine/pkg/api"
|
||||
"github.com/netkingdom/approval-engine/adapters/go/approvalfact"
|
||||
"time"
|
||||
)
|
||||
|
||||
// legacyDigestProjection preserves the published PDP digest profile while the
|
||||
// signed structured binding retains original numeric kinds. It is used only
|
||||
// for digest material, never to reconstruct an Approval-native act.
|
||||
func legacyDigestProjection(request api.CheckRequest) (api.CheckRequest, error) {
|
||||
raw, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return api.CheckRequest{}, err
|
||||
}
|
||||
var projected api.CheckRequest
|
||||
err = json.Unmarshal(raw, &projected)
|
||||
return projected, err
|
||||
}
|
||||
func nativeServiceAct(request api.CheckRequest) ([]byte, error) {
|
||||
purpose, ok := request.Context["purpose"].(string)
|
||||
if !ok || purpose == "" || request.Subject.ID != "secrets-engine" || request.Subject.Type != "service" {
|
||||
return nil, taskexecution.ErrServiceInput
|
||||
}
|
||||
if principal, exists := request.Subject.Attributes["principal"]; exists && principal != "secrets-engine" {
|
||||
return nil, taskexecution.ErrServiceInput
|
||||
}
|
||||
// All map numbers originate from the number-preserving request/registry
|
||||
// profile. This raw draft is canonicalized only by the Approval owner helper.
|
||||
return json.Marshal(map[string]any{"action": request.Action, "actor": request.Subject.ID, "principal": "secrets-engine", "purpose": purpose, "target": request.Resource})
|
||||
}
|
||||
func (e *Engine) signTaskEnvelope(envelope *api.DecisionEnvelope) error {
|
||||
e.mu.RLock()
|
||||
key, kid := e.signingKey, e.signingKID
|
||||
e.mu.RUnlock()
|
||||
if len(key) == 0 {
|
||||
return ErrServicePilotUnavailable
|
||||
}
|
||||
return sign.Sign(envelope, key, kid)
|
||||
}
|
||||
|
||||
// CheckTrustedService is the only v2 task evaluation path. Its source reader
|
||||
// and immutable service input are server-created, not HTTP validation flags.
|
||||
// It never consumes an approval or causes a protected backend effect.
|
||||
func (e *Engine) CheckTrustedService(ctx context.Context, input taskexecution.ServiceInput, source *taskexecution.ApprovalReader) (api.DecisionEnvelope, error) {
|
||||
if !e.IsTrustedServicePilot() || !input.NumberPreserved() || !source.Configured() {
|
||||
return api.DecisionEnvelope{}, ErrServicePilotUnavailable
|
||||
}
|
||||
now := e.now()
|
||||
submitted, err := input.RequestAt(now)
|
||||
if err != nil {
|
||||
return api.DecisionEnvelope{}, err
|
||||
}
|
||||
normalized, facts := e.normalizeRequest(submitted)
|
||||
if (facts.resourceFound || facts.subjectFound) && !e.store.NumbersPreserved() {
|
||||
return api.DecisionEnvelope{}, ErrServicePilotUnavailable
|
||||
}
|
||||
original, err := legacyDigestProjection(submitted)
|
||||
if err != nil {
|
||||
return api.DecisionEnvelope{}, err
|
||||
}
|
||||
evaluated, err := legacyDigestProjection(normalized)
|
||||
if err != nil {
|
||||
return api.DecisionEnvelope{}, err
|
||||
}
|
||||
native, err := nativeServiceAct(normalized)
|
||||
if err != nil {
|
||||
return api.DecisionEnvelope{}, err
|
||||
}
|
||||
ctx = callerauth.WithRecord(ctx, input.Caller())
|
||||
var approvalEvidence *api.ApprovalObservationProvenance
|
||||
deadline := input.Caller().NotAfter
|
||||
contract := submitted.Context["execution_contract"].(map[string]any)
|
||||
operationEnd, err := time.Parse(time.RFC3339Nano, contract["operation_deadline"].(string))
|
||||
if err != nil {
|
||||
return api.DecisionEnvelope{}, taskexecution.ErrServiceInput
|
||||
}
|
||||
if operationEnd.Before(deadline) {
|
||||
deadline = operationEnd
|
||||
}
|
||||
carried, hasClaim := submitted.Context[api.ApprovalContextKey]
|
||||
if hasClaim {
|
||||
claim, ok := carried.(map[string]any)
|
||||
if !ok {
|
||||
return api.DecisionEnvelope{}, taskexecution.ErrApprovalSource
|
||||
}
|
||||
id, ok := claim["approval_id"].(string)
|
||||
if !ok {
|
||||
return api.DecisionEnvelope{}, taskexecution.ErrApprovalSource
|
||||
}
|
||||
originalRaw, err := json.Marshal(carried)
|
||||
if err != nil {
|
||||
return api.DecisionEnvelope{}, taskexecution.ErrApprovalSource
|
||||
}
|
||||
observed, err := source.Observe(ctx, id)
|
||||
if err != nil {
|
||||
return api.DecisionEnvelope{}, err
|
||||
}
|
||||
// One validation instant AFTER network I/O; a fresh read cannot rescue the
|
||||
// original carried claim's freshness, source expiry or operation deadline.
|
||||
now = e.now()
|
||||
if _, err := input.RequestAt(now); err != nil {
|
||||
return api.DecisionEnvelope{}, err
|
||||
}
|
||||
verified, err := observed.Validate(originalRaw, approvalfact.Expected{ApprovalID: id, NativeBindingJSON: native, PDPDigest: api.ApprovalBindingDigest(evaluated)}, now)
|
||||
if err != nil {
|
||||
return api.DecisionEnvelope{}, err
|
||||
}
|
||||
factEnd, err := verified.Deadline()
|
||||
if err != nil {
|
||||
return api.DecisionEnvelope{}, err
|
||||
}
|
||||
if factEnd.Before(deadline) {
|
||||
deadline = factEnd
|
||||
}
|
||||
approvalEvidence, err = verified.Provenance()
|
||||
if err != nil {
|
||||
return api.DecisionEnvelope{}, err
|
||||
}
|
||||
}
|
||||
expectation, err := e.policy.EvaluatePreservingNumbers(ctx, normalized)
|
||||
if err != nil {
|
||||
return api.DecisionEnvelope{}, err
|
||||
}
|
||||
if !hasClaim && expectation.Effect == api.DecisionEffectAllow {
|
||||
return api.DecisionEnvelope{}, ErrServicePilotUnavailable
|
||||
}
|
||||
now = e.now()
|
||||
if _, err := input.RequestAt(now); err != nil {
|
||||
return api.DecisionEnvelope{}, err
|
||||
}
|
||||
envelope := e.envelope(ctx, normalized, submitted, expectation, facts)
|
||||
envelope.Binding.RequestDigest = api.RequestDigest(evaluated)
|
||||
envelope.Binding.SubmittedRequestDigest = api.RequestDigest(original)
|
||||
// Claim-free signed DENY is the producer-owned normalization receipt used
|
||||
// before Approval issue. It supplies facts only, never execution authority.
|
||||
envelope.Binding.ApprovalBindingDigest = api.ApprovalBindingDigest(evaluated)
|
||||
envelope.Provenance.ApprovalObservation = approvalEvidence
|
||||
if envelope.Effect == api.DecisionEffectAllow {
|
||||
if approvalEvidence == nil || envelope.Lifetime == nil {
|
||||
return api.DecisionEnvelope{}, ErrServicePilotUnavailable
|
||||
}
|
||||
lifetime, err := trustedTaskLifetime(contract, operationEnd, deadline, now, e.policy.Metadata.AllowTTL)
|
||||
if err != nil {
|
||||
return api.DecisionEnvelope{}, err
|
||||
}
|
||||
envelope.Lifetime = lifetime
|
||||
}
|
||||
if err := e.signTaskEnvelope(&envelope); err != nil {
|
||||
return api.DecisionEnvelope{}, err
|
||||
}
|
||||
if err := e.recordDecisions([]api.DecisionEnvelope{envelope}); err != nil {
|
||||
return api.DecisionEnvelope{}, err
|
||||
}
|
||||
return envelope, nil
|
||||
}
|
||||
|
||||
// The source ceiling is deliberately no larger than the existing task-token
|
||||
// ceiling. Structural backend budgets do not guarantee an executable window.
|
||||
const trustedTaskMaxAuthority = 120 * time.Second
|
||||
const trustedTaskCleanupReserve = 5 * time.Second
|
||||
|
||||
// trustedTaskLifetime authorizes the entire exact operation, not merely its
|
||||
// start. It cannot extend any caller, original/fresh fact, or approval bound.
|
||||
func trustedTaskLifetime(contract map[string]any, operationEnd, authorityEnd, now time.Time, declared string) (*api.DecisionLifetime, error) {
|
||||
maximum, err := api.ParseAllowTTL(declared)
|
||||
if err != nil || maximum <= 0 || maximum > trustedTaskMaxAuthority || now.IsZero() || operationEnd.After(authorityEnd) || !operationEnd.After(now) || operationEnd.Sub(now) > maximum {
|
||||
return nil, ErrServicePilotUnavailable
|
||||
}
|
||||
budget, ok := contract["backend_budget"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, ErrServicePilotUnavailable
|
||||
}
|
||||
number, ok := budget["ttl_seconds"].(json.Number)
|
||||
if !ok {
|
||||
return nil, ErrServicePilotUnavailable
|
||||
}
|
||||
seconds, err := number.Int64()
|
||||
if err != nil || seconds < 30 || seconds > 120 {
|
||||
return nil, ErrServicePilotUnavailable
|
||||
}
|
||||
remaining := operationEnd.Sub(now)
|
||||
if remaining < time.Duration(seconds)*time.Second+trustedTaskCleanupReserve {
|
||||
return nil, ErrServicePilotUnavailable
|
||||
}
|
||||
return &api.DecisionLifetime{Kind: api.DecisionLifetimeTTL, TTL: remaining.String(), NotBefore: now.UTC().Format(time.RFC3339Nano), ExpiresAt: operationEnd.UTC().Format(time.RFC3339Nano)}, nil
|
||||
}
|
||||
56
internal/decision/trusted_service_time_test.go
Normal file
56
internal/decision/trusted_service_time_test.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package decision
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTrustedTaskWholeOperationLifetime(t *testing.T) {
|
||||
now := time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC)
|
||||
contract := func(ttl string) map[string]any {
|
||||
return map[string]any{"backend_budget": map[string]any{"ttl_seconds": json.Number(ttl), "uses": json.Number("12")}}
|
||||
}
|
||||
end := now.Add(60 * time.Second)
|
||||
lifetime, err := trustedTaskLifetime(contract("30"), end, now.Add(120*time.Second), now, "120s")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lifetime.ExpiresAt != end.Format(time.RFC3339) || lifetime.TTL != "1m0s" || lifetime.NotBefore != now.Format(time.RFC3339) {
|
||||
t.Fatal("lifetime not derived from exact operation")
|
||||
}
|
||||
fractional := end.Add(123456789 * time.Nanosecond)
|
||||
lifetime, err = trustedTaskLifetime(contract("30"), fractional, fractional, now, "120s")
|
||||
if err != nil || lifetime.ExpiresAt != fractional.Format(time.RFC3339Nano) {
|
||||
t.Fatal("exact fractional deadline shortened or extended")
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
ttl string
|
||||
end, authority, time time.Time
|
||||
maximum string
|
||||
}{
|
||||
{"old-15s-conflict", "30", end, end, now, "15s"},
|
||||
{"insufficient-cleanup-reserve", "30", now.Add(35*time.Second - time.Nanosecond), end, now, "120s"},
|
||||
{"elapsed-consume-setup", "30", end, end, now.Add(26 * time.Second), "120s"},
|
||||
{"expired-operation", "30", now, now, now, "120s"},
|
||||
{"source-hard-maximum", "30", now.Add(120*time.Second + time.Nanosecond), now.Add(130 * time.Second), now, "120s"},
|
||||
{"attempted-policy-cap-bypass", "30", end, end, now, "121s"},
|
||||
{"shorter-original-or-fresh-fact", "30", end, end.Add(-time.Nanosecond), now, "120s"},
|
||||
{"structural-120-cannot-fit", "120", now.Add(120 * time.Second), now.Add(120 * time.Second), now, "120s"},
|
||||
{"noninteger-structural-ttl", "30.0", end, end, now, "120s"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if _, err := trustedTaskLifetime(contract(c.ttl), c.end, c.authority, c.time, c.maximum); err == nil {
|
||||
t.Fatal("invalid temporal authority accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, err := trustedTaskLifetime(contract("30"), now.Add(35*time.Second), end, now, "120s"); err != nil {
|
||||
t.Fatal("exact required lifetime refused")
|
||||
}
|
||||
if _, err := trustedTaskLifetime(contract("115"), now.Add(120*time.Second), now.Add(120*time.Second), now, "120s"); err != nil {
|
||||
t.Fatal("max fitting declared budget refused")
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
53
internal/emission/numeric_replay_test.go
Normal file
53
internal/emission/numeric_replay_test.go
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
package emission
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"github.com/netkingdom/access-engine/internal/sign"
|
||||
"github.com/netkingdom/access-engine/pkg/api"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOutboxReplayPreservesProducerNumericSignature(t *testing.T) {
|
||||
folder := t.TempDir()
|
||||
outbox, err := Open(folder, "synthetic-source")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{21}, 32))
|
||||
envelope := api.DecisionEnvelope{ID: "decision:synthetic-numeric", Effect: api.DecisionEffectDeny, Resource: api.ResourceRef{ID: "synthetic", Type: "tenant", System: "tenant-engine"}}
|
||||
envelope.Binding = &api.DecisionBinding{Context: map[string]any{"float": json.Number("1.0"), "negative_zero": json.Number("-0.0"), "integer": json.Number("9007199254740993"), "exponent": json.Number("1e-07")}}
|
||||
if err := sign.Sign(&envelope, key, "synthetic-numeric"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := outbox.Append(envelope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outbox.Close()
|
||||
outbox, err = Open(folder, "synthetic-source")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer outbox.Close()
|
||||
entry, _, err := outbox.nextEntry()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wire, err := json.Marshal(entry.Event.Data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
replayed, err := sign.DecodeEnvelope(wire)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sign.Verify(replayed, map[string]ed25519.PublicKey{"synthetic-numeric": key.Public().(ed25519.PublicKey)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, _ := sign.CanonicalBytes(envelope)
|
||||
after, _ := sign.CanonicalBytes(replayed)
|
||||
if !bytes.Equal(before, after) {
|
||||
t.Fatal("replayed signed bytes changed")
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
36
internal/registry/numeric_source_test.go
Normal file
36
internal/registry/numeric_source_test.go
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
package registry
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTaskRegistryNumericSourceAndStrictDecode(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "snapshot.json")
|
||||
raw := []byte(`{"resource_manifests":[{"id":"synthetic","system":"secrets-engine","resources":[{"id":"catalog:synthetic","type":"secret-catalog-lane","attributes":{"float":1.0,"integer":9007199254740993,"negative_zero":-0.0}}]}]}`)
|
||||
os.WriteFile(path, raw, 0600)
|
||||
store, err := LoadFilePreservingNumbers(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !store.NumbersPreserved() {
|
||||
t.Fatal("missing source profile")
|
||||
}
|
||||
resource, ok := store.Resource("secrets-engine", "catalog:synthetic")
|
||||
if !ok {
|
||||
t.Fatal("missing resource")
|
||||
}
|
||||
for k, v := range map[string]string{"float": "1.0", "integer": "9007199254740993", "negative_zero": "-0.0"} {
|
||||
if resource.Attributes[k] != json.Number(v) {
|
||||
t.Fatalf("lost native representation: %s", k)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{`{} {}`, `{"subjects":[],"subjects":[]}`, `{"unknown":true}`} {
|
||||
os.WriteFile(path, []byte(raw), 0600)
|
||||
if _, err := LoadFilePreservingNumbers(path); err == nil {
|
||||
t.Fatal("invalid authoritative registry accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
54
internal/sign/numeric_decode_test.go
Normal file
54
internal/sign/numeric_decode_test.go
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
package sign
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"github.com/netkingdom/access-engine/pkg/api"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProducerNumericEnvelopeDecodeCompatibility(t *testing.T) {
|
||||
key := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{11}, 32))
|
||||
keys := map[string]ed25519.PublicKey{"synthetic-numeric": key.Public().(ed25519.PublicKey)}
|
||||
for _, raw := range []string{`1`, `1.0`, `-0.0`, `1e-07`, `1e+20`, `9007199254740993`, `1.7976931348623157e308`, `5e-324`, `true`, `"<>&\u2028\u2029 café"`, `[1,1.0,-0.0]`, `{"nested":1.0}`} {
|
||||
t.Run(raw, func(t *testing.T) {
|
||||
var value any
|
||||
decoder := json.NewDecoder(bytes.NewBufferString(raw))
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
original := api.DecisionEnvelope{Binding: &api.DecisionBinding{Context: map[string]any{"numeric": value}}}
|
||||
if err := Sign(&original, key, "synthetic-numeric"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wire, _ := json.Marshal(original)
|
||||
decoded, err := DecodeEnvelope(wire)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Verify(decoded, keys); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, _ := CanonicalBytes(original)
|
||||
after, _ := CanonicalBytes(decoded)
|
||||
if !bytes.Equal(before, after) {
|
||||
t.Fatal("producer canonical bytes changed")
|
||||
}
|
||||
})
|
||||
}
|
||||
// Existing float64 producer profile still verifies through the new decoder.
|
||||
legacy := api.DecisionEnvelope{Binding: &api.DecisionBinding{Context: map[string]any{"number": float64(1), "exponent": float64(1e-7)}}}
|
||||
Sign(&legacy, key, "synthetic-numeric")
|
||||
wire, _ := json.Marshal(legacy)
|
||||
decoded, err := DecodeEnvelope(wire)
|
||||
if err != nil || Verify(decoded, keys) != nil {
|
||||
t.Fatal("legacy producer compatibility failed")
|
||||
}
|
||||
for _, raw := range []string{`{} {}`, `{"new_unknown_authority":true}`} {
|
||||
if _, err := DecodeEnvelope([]byte(raw)); err == nil {
|
||||
t.Fatal("invalid envelope accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
215
internal/taskexecution/approval_source.go
Normal file
215
internal/taskexecution/approval_source.go
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
package taskexecution
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/netkingdom/access-engine/pkg/api"
|
||||
"github.com/netkingdom/approval-engine/adapters/go/approvalfact"
|
||||
)
|
||||
|
||||
var ErrApprovalSource = errors.New("trusted Approval source refused")
|
||||
var approvalUUID = regexp.MustCompile(`^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$`)
|
||||
|
||||
type ApprovalSourceConfig struct {
|
||||
SourceID string `json:"source_id"`
|
||||
SourceRevision string `json:"source_revision"`
|
||||
Origin string `json:"origin"`
|
||||
StoreTenant string `json:"store_tenant"`
|
||||
CAFile string `json:"ca_file"`
|
||||
TokenFile string `json:"token_file"`
|
||||
}
|
||||
|
||||
// ApprovalReader is installed from owner configuration, never an HTTP request.
|
||||
// It reads one fixed PIP endpoint; no policy, retry, consume or effect lives here.
|
||||
type ApprovalReader struct {
|
||||
config ApprovalSourceConfig
|
||||
configurationDigest string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewApprovalReader(config ApprovalSourceConfig) (*ApprovalReader, error) {
|
||||
u, err := url.Parse(config.Origin)
|
||||
if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") || strings.ContainsAny(config.Origin, "\r\n\t ") || config.SourceID == "" || config.SourceRevision == "" || config.StoreTenant != "tenant:platform" || !filepath.IsAbs(config.CAFile) || !filepath.IsAbs(config.TokenFile) {
|
||||
return nil, ErrApprovalSource
|
||||
}
|
||||
ca, err := readPublicApprovalFile(config.CAFile, 1048576)
|
||||
if err != nil {
|
||||
return nil, ErrApprovalSource
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(ca) {
|
||||
return nil, ErrApprovalSource
|
||||
}
|
||||
config.Origin = strings.TrimRight(config.Origin, "/")
|
||||
digest := api.CanonicalDigest(struct {
|
||||
Config ApprovalSourceConfig `json:"configuration"`
|
||||
CA string `json:"ca_sha256"`
|
||||
}{config, byteDigest(ca)})
|
||||
return &ApprovalReader{config: config, configurationDigest: digest, client: &http.Client{Timeout: 3 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, Transport: &http.Transport{Proxy: nil, DisableCompression: true, TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: pool}}}}, nil
|
||||
}
|
||||
func readPublicApprovalFile(path string, limit int64) ([]byte, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, ErrApprovalSource
|
||||
}
|
||||
defer file.Close()
|
||||
info, err := file.Stat()
|
||||
if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0022 != 0 {
|
||||
return nil, ErrApprovalSource
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(file, limit+1))
|
||||
if err != nil || int64(len(raw)) > limit {
|
||||
return nil, ErrApprovalSource
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
func LoadApprovalReader(path string) (*ApprovalReader, error) {
|
||||
if !filepath.IsAbs(path) {
|
||||
return nil, ErrApprovalSource
|
||||
}
|
||||
raw, err := readPublicApprovalFile(path, 65536)
|
||||
if err != nil {
|
||||
return nil, ErrApprovalSource
|
||||
}
|
||||
if _, err := approvalfact.DecodeJSON(raw); err != nil {
|
||||
return nil, ErrApprovalSource
|
||||
}
|
||||
var config ApprovalSourceConfig
|
||||
decoder := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
decoder.DisallowUnknownFields()
|
||||
if decoder.Decode(&config) != nil {
|
||||
return nil, ErrApprovalSource
|
||||
}
|
||||
return NewApprovalReader(config)
|
||||
}
|
||||
func (r *ApprovalReader) Configured() bool {
|
||||
return r != nil && r.client != nil && r.configurationDigest != ""
|
||||
}
|
||||
func byteDigest(raw []byte) string {
|
||||
h := sha256.Sum256(raw)
|
||||
return "sha256:" + hex.EncodeToString(h[:])
|
||||
}
|
||||
func readApprovalToken(path string) (string, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", ErrApprovalSource
|
||||
}
|
||||
defer file.Close()
|
||||
info, err := file.Stat()
|
||||
if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0027 != 0 {
|
||||
return "", ErrApprovalSource
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(file, 8193))
|
||||
if err != nil || len(raw) > 8192 {
|
||||
return "", ErrApprovalSource
|
||||
}
|
||||
token := strings.TrimSpace(string(raw))
|
||||
if token == "" || strings.ContainsAny(token, " \t\r\n") {
|
||||
return "", ErrApprovalSource
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// ApprovalObservation cannot be created by caller JSON or a public verified flag.
|
||||
// It is raw fact data plus the source/configuration used by this fixed reader.
|
||||
type ApprovalObservation struct {
|
||||
raw []byte
|
||||
sourceID, revision, configurationDigest string
|
||||
}
|
||||
|
||||
func (r *ApprovalReader) Observe(ctx context.Context, id string) (ApprovalObservation, error) {
|
||||
if !r.Configured() || !approvalUUID.MatchString(id) {
|
||||
return ApprovalObservation{}, ErrApprovalSource
|
||||
}
|
||||
token, err := readApprovalToken(r.config.TokenFile)
|
||||
if err != nil {
|
||||
return ApprovalObservation{}, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, r.config.Origin+"/v1/approvals/"+id+"/claim", nil)
|
||||
if err != nil {
|
||||
return ApprovalObservation{}, ErrApprovalSource
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
response, err := r.client.Do(req)
|
||||
if err != nil {
|
||||
return ApprovalObservation{}, ErrApprovalSource
|
||||
}
|
||||
defer response.Body.Close()
|
||||
types := response.Header.Values("Content-Type")
|
||||
if len(types) != 1 {
|
||||
return ApprovalObservation{}, ErrApprovalSource
|
||||
}
|
||||
media, parameters, err := mime.ParseMediaType(types[0])
|
||||
if err != nil || media != "application/json" || (parameters["charset"] != "" && !strings.EqualFold(parameters["charset"], "utf-8")) || (len(parameters) != 0 && (len(parameters) != 1 || parameters["charset"] == "")) || response.StatusCode != 200 || response.Header.Get("Content-Encoding") != "" {
|
||||
return ApprovalObservation{}, ErrApprovalSource
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(response.Body, 524289))
|
||||
if err != nil || len(raw) > 524288 {
|
||||
return ApprovalObservation{}, ErrApprovalSource
|
||||
}
|
||||
if _, err := approvalfact.DecodeJSON(raw); err != nil {
|
||||
return ApprovalObservation{}, ErrApprovalSource
|
||||
}
|
||||
return ApprovalObservation{raw: append([]byte(nil), raw...), sourceID: r.config.SourceID, revision: r.config.SourceRevision, configurationDigest: r.configurationDigest}, nil
|
||||
}
|
||||
|
||||
// VerifiedApprovalFacts establishes typed fact correspondence, not a decision.
|
||||
// Only an authenticated fixed-reader observation can construct this handle.
|
||||
type VerifiedApprovalFacts struct {
|
||||
original, fetched approvalfact.Fact
|
||||
evidence api.ApprovalObservationProvenance
|
||||
valid bool
|
||||
}
|
||||
|
||||
func (o ApprovalObservation) Validate(original []byte, expected approvalfact.Expected, now time.Time) (VerifiedApprovalFacts, error) {
|
||||
if len(o.raw) == 0 || o.sourceID == "" || o.revision == "" || o.configurationDigest == "" {
|
||||
return VerifiedApprovalFacts{}, ErrApprovalSource
|
||||
}
|
||||
originalFact, err := approvalfact.ValidateClaim(original, expected, now)
|
||||
if err != nil {
|
||||
return VerifiedApprovalFacts{}, ErrApprovalSource
|
||||
}
|
||||
fetched, err := approvalfact.ValidateClaim(o.raw, expected, now)
|
||||
if err != nil || approvalfact.MatchObserved(originalFact, fetched) != nil {
|
||||
return VerifiedApprovalFacts{}, ErrApprovalSource
|
||||
}
|
||||
limit := originalFact.Deadline()
|
||||
if fetched.Deadline().Before(limit) {
|
||||
limit = fetched.Deadline()
|
||||
}
|
||||
evidence := api.ApprovalObservationProvenance{SourceID: o.sourceID, SourceRevision: o.revision, ConfigurationDigest: o.configurationDigest, ClaimDigest: byteDigest(o.raw), NativeBindingDigest: fetched.NativeDigest(), ObservedAt: fetched.ObservedAt().UTC().Format(time.RFC3339Nano), NotAfter: limit.UTC().Format(time.RFC3339Nano), ValidatedAt: now.UTC().Format(time.RFC3339Nano)}
|
||||
return VerifiedApprovalFacts{original: originalFact, fetched: fetched, evidence: evidence, valid: true}, nil
|
||||
}
|
||||
func (f VerifiedApprovalFacts) Deadline() (time.Time, error) {
|
||||
if !f.valid {
|
||||
return time.Time{}, ErrApprovalSource
|
||||
}
|
||||
a := f.original.Deadline()
|
||||
if f.fetched.Deadline().Before(a) {
|
||||
a = f.fetched.Deadline()
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
func (f VerifiedApprovalFacts) Provenance() (*api.ApprovalObservationProvenance, error) {
|
||||
if !f.valid {
|
||||
return nil, ErrApprovalSource
|
||||
}
|
||||
copy := f.evidence
|
||||
return ©, nil
|
||||
}
|
||||
118
internal/taskexecution/approval_source_test.go
Normal file
118
internal/taskexecution/approval_source_test.go
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
package taskexecution
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/pem"
|
||||
"github.com/netkingdom/approval-engine/adapters/go/approvalfact"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFixedApprovalReaderRefusalBoundary(t *testing.T) {
|
||||
folder := t.TempDir()
|
||||
token := filepath.Join(folder, "token")
|
||||
ca := filepath.Join(folder, "ca")
|
||||
if err := os.WriteFile(token, []byte("synthetic-source-token"), 0440); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var mode atomic.Value
|
||||
mode.Store("ok")
|
||||
var foreignCalls atomic.Int64
|
||||
foreign := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
foreignCalls.Add(1)
|
||||
t.Error("credential escaped fixed origin")
|
||||
}))
|
||||
defer foreign.Close()
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer synthetic-source-token" || r.URL.Path != "/v1/approvals/aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee/claim" || r.Method != "GET" {
|
||||
t.Error("wrong auth/path/method")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch mode.Load().(string) {
|
||||
case "redirect":
|
||||
w.Header().Set("Location", foreign.URL)
|
||||
w.WriteHeader(302)
|
||||
return
|
||||
case "status":
|
||||
w.WriteHeader(403)
|
||||
case "large":
|
||||
w.Write([]byte(strings.Repeat(" ", 524289)))
|
||||
case "duplicate":
|
||||
w.Write([]byte(`{"a":true,"a":false}`))
|
||||
case "invalid-utf8":
|
||||
w.Write([]byte{'"', 255, '"'})
|
||||
case "media":
|
||||
w.Header().Set("Content-Type", "application/json; arbitrary=authority")
|
||||
w.Write([]byte(`{}`))
|
||||
case "duplicate-media":
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
w.Write([]byte(`{}`))
|
||||
case "compressed":
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
w.Write([]byte(`{}`))
|
||||
case "slow":
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
default:
|
||||
w.Write([]byte(`{}`))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
os.WriteFile(ca, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}), 0600)
|
||||
config := ApprovalSourceConfig{SourceID: "synthetic-source", SourceRevision: "synthetic-revision", Origin: server.URL, StoreTenant: "tenant:platform", CAFile: ca, TokenFile: token}
|
||||
reader, err := NewApprovalReader(config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := reader.Observe(context.Background(), "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"redirect", "status", "large", "duplicate", "invalid-utf8", "media", "duplicate-media", "compressed", "slow"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
mode.Store(name)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
if _, err := reader.Observe(ctx, "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"); err == nil {
|
||||
t.Fatal("invalid provider response admitted")
|
||||
}
|
||||
})
|
||||
}
|
||||
if foreignCalls.Load() != 0 {
|
||||
t.Fatal("redirect followed")
|
||||
}
|
||||
mode.Store("ok")
|
||||
for _, bad := range []string{"../consume", "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee/consume", ""} {
|
||||
if _, err := reader.Observe(context.Background(), bad); err == nil {
|
||||
t.Fatal("request selected route")
|
||||
}
|
||||
}
|
||||
for _, mode := range []os.FileMode{0460, 0644, 0620} {
|
||||
os.Chmod(token, mode)
|
||||
if _, err := reader.Observe(context.Background(), "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"); err == nil {
|
||||
t.Fatal("unsafe token permissions")
|
||||
}
|
||||
}
|
||||
for _, origin := range []string{"http://127.0.0.1", "https://user:password@localhost", "https://localhost/path", "https://localhost?query=1", "https://localhost#fragment"} {
|
||||
bad := config
|
||||
bad.Origin = origin
|
||||
if _, err := NewApprovalReader(bad); err == nil {
|
||||
t.Fatal("unsafe origin")
|
||||
}
|
||||
}
|
||||
bad := config
|
||||
bad.StoreTenant = "tenant:foreign"
|
||||
if _, err := NewApprovalReader(bad); err == nil {
|
||||
t.Fatal("foreign tenant configured")
|
||||
}
|
||||
if _, err := (ApprovalObservation{}).Validate([]byte(`{}`), approvalfact.Expected{}, time.Now()); err == nil {
|
||||
t.Fatal("zero observation admitted")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -635,16 +635,30 @@ type Obligation struct {
|
|||
|
||||
// DecisionProvenance captures evaluator and policy provenance.
|
||||
type DecisionProvenance struct {
|
||||
Evaluator string `json:"evaluator" yaml:"evaluator"`
|
||||
Mode string `json:"mode" yaml:"mode"`
|
||||
PolicyPackage string `json:"policy_package,omitempty" yaml:"policy_package,omitempty"`
|
||||
PolicyVersion string `json:"policy_version,omitempty" yaml:"policy_version,omitempty"`
|
||||
PolicyPackageDigest string `json:"policy_package_digest,omitempty" yaml:"policy_package_digest,omitempty"`
|
||||
RegistrySnapshotDigest string `json:"registry_snapshot_digest,omitempty" yaml:"registry_snapshot_digest,omitempty"`
|
||||
DirectoryETag string `json:"directory_etag,omitempty" yaml:"directory_etag,omitempty"`
|
||||
InputClaimDigests map[string]string `json:"input_claim_digests,omitempty" yaml:"input_claim_digests,omitempty"`
|
||||
DecisionTime string `json:"decision_time,omitempty" yaml:"decision_time,omitempty"`
|
||||
Caller *CallerProvenance `json:"caller,omitempty" yaml:"caller,omitempty"`
|
||||
Evaluator string `json:"evaluator" yaml:"evaluator"`
|
||||
Mode string `json:"mode" yaml:"mode"`
|
||||
PolicyPackage string `json:"policy_package,omitempty" yaml:"policy_package,omitempty"`
|
||||
PolicyVersion string `json:"policy_version,omitempty" yaml:"policy_version,omitempty"`
|
||||
PolicyPackageDigest string `json:"policy_package_digest,omitempty" yaml:"policy_package_digest,omitempty"`
|
||||
RegistrySnapshotDigest string `json:"registry_snapshot_digest,omitempty" yaml:"registry_snapshot_digest,omitempty"`
|
||||
DirectoryETag string `json:"directory_etag,omitempty" yaml:"directory_etag,omitempty"`
|
||||
InputClaimDigests map[string]string `json:"input_claim_digests,omitempty" yaml:"input_claim_digests,omitempty"`
|
||||
DecisionTime string `json:"decision_time,omitempty" yaml:"decision_time,omitempty"`
|
||||
ApprovalObservation *ApprovalObservationProvenance `json:"approval_observation,omitempty" yaml:"approval_observation,omitempty"`
|
||||
Caller *CallerProvenance `json:"caller,omitempty" yaml:"caller,omitempty"`
|
||||
}
|
||||
|
||||
// ApprovalObservationProvenance binds fresh server-owned fact evidence without
|
||||
// rewriting the original carried claim/context or either PDP request digest.
|
||||
type ApprovalObservationProvenance struct {
|
||||
SourceID string `json:"source_id"`
|
||||
SourceRevision string `json:"source_revision"`
|
||||
ConfigurationDigest string `json:"configuration_digest"`
|
||||
ClaimDigest string `json:"claim_digest"`
|
||||
NativeBindingDigest string `json:"native_binding_digest"`
|
||||
ObservedAt string `json:"observed_at"`
|
||||
NotAfter string `json:"not_after"`
|
||||
ValidatedAt string `json:"validated_at"`
|
||||
}
|
||||
|
||||
// CallerProvenance records how the request was authenticated to flex-auth.
|
||||
|
|
|
|||
55
third_party/approvalfact/README.md
vendored
Normal file
55
third_party/approvalfact/README.md
vendored
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# Approval fact data adapter
|
||||
|
||||
Provider-owned pure Go module for Access's Approval B2 source reader. No network,
|
||||
credentials, subprocess, policy evaluator, server or consume mutation exists here.
|
||||
Python `approval_engine.binding` remains the authoritative native format.
|
||||
|
||||
API:
|
||||
|
||||
- `CanonicalBinding(rawJSON)` returns native five-field canonical UTF-8 bytes
|
||||
and sha256 digest; extra binding metadata is excluded.
|
||||
- `ValidateClaim(rawClaim, Expected, now)` validates one exact human/PDP-path
|
||||
claim against independently owner-derived `Expected.NativeBindingJSON`, exact
|
||||
approval ID and actual Access approval-free PDP digest.
|
||||
- `MatchObserved(submitted, observed)` requires two validated facts at the same
|
||||
captured instant and identical nonfreshness authority facts. No request is
|
||||
rewritten. `Fact.RawJSON()` returns a detached original byte copy; deadlines
|
||||
preserve each input's own freshness and approval expiry.
|
||||
|
||||
`Fact` means validated data, never authenticated provider origin. Access must
|
||||
fetch provider bytes through its independently admitted fixed origin/identity,
|
||||
then wrap this data in its own unforgeable source handle. A caller-supplied claim
|
||||
cannot become source truth merely by passing this helper. Access remains the
|
||||
single evaluator and must bound decision/operation/caller lifetimes separately.
|
||||
PEP consume CAS and live one-shot journal remain required before any effect.
|
||||
|
||||
Expected native act bytes must preserve the actual owner/compiler/normalization
|
||||
representation. Marshaling float64-decoded CheckRequest maps loses 1.0/1 and
|
||||
large integers. Raw submitted resource is sufficient only when Access proves
|
||||
the active registry cannot override that target and applies its exact existing
|
||||
normalization. Otherwise authoritative raw enrichment/compiler evidence is
|
||||
needed. This helper supplies no fallback or invented restricted numeric domain.
|
||||
|
||||
The strict parser retains integer/floating lexical types, arbitrary integer
|
||||
magnitudes, finite IEEE-754 float semantics and valid Unicode scalar values.
|
||||
Canonical float/string formatting follows Python source, including negative
|
||||
floating zero, exponent switching/padding, HTML and U+2028/U+2029. Duplicate
|
||||
object keys, nonfinite/overflow numbers, invalid UTF-8 and lone surrogates refuse.
|
||||
Inputs are bounded to 1 MiB and 128 container levels. Timestamp precision is at
|
||||
most nanoseconds with strict zoned RFC3339; current producer emits whole seconds.
|
||||
These explicit parser/resource limits fail closed, never normalize authority.
|
||||
|
||||
`go test -race ./...` checks the portable producer vectors, typed fact pairing,
|
||||
strict JSON/Unicode and exact deadline semantics. From the owner repo root,
|
||||
`python3 scripts/check_go_approvalfact.py` runs a deterministic 14,114-case
|
||||
development-only differential corpus against the actual Python producer,
|
||||
including random finite IEEE bit patterns, exponent boundaries/subnormals and
|
||||
Unicode. It uses a test-only local oracle file; production imports no oracle or
|
||||
Python runtime. Sample conformance is evidence, not a formal proof over all JSON.
|
||||
|
||||
The module pins Go 1.26.7 to match the existing Access consumer toolchain.
|
||||
`testdata/owner-vectors.json` is an unchanged portable copy of the Approval owner
|
||||
fixture, SHA256 `dfb0a8810cd91847d7af6a7af6a51607a67a66fad7b86fc57365308c36bd9323`.
|
||||
Transport cases requiring HTTP remain the Access adapter's tests; this module
|
||||
checks their duplicate/nonfinite bodies only. Import/deployment/native identity
|
||||
admission and positive policy gates require separate owner evidence.
|
||||
13
third_party/approvalfact/SOURCE.json
vendored
Normal file
13
third_party/approvalfact/SOURCE.json
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"owner": "approval-engine",
|
||||
"module": "github.com/netkingdom/approval-engine/adapters/go/approvalfact",
|
||||
"source_artifact": "approval-go-fact/manifest.json",
|
||||
"review_status": "owner-frozen; independent coordinator review required",
|
||||
"role": "pure data canonicalization and fact correspondence only; not provider authentication or PDP",
|
||||
"files_sha256": {
|
||||
"canonical.go": "d48588fb26e9037c8e83421586b8a818f4c0a52afb874f3fadf7229fc971c282",
|
||||
"claim.go": "007a2494f79c47c104635402407e9e68f17d8599d23e0f2c8ebf6cb1b47e3621",
|
||||
"go.mod": "bb09c53ffea926fbfba6e580ed3bd71ca358575401717389b3c7c49c0c674b43",
|
||||
"README.md": "5d936b7f3de87bbf4e946f0dc44823d2f69b62b4122b55b9064791b7a19fd4f5"
|
||||
}
|
||||
}
|
||||
295
third_party/approvalfact/canonical.go
vendored
Normal file
295
third_party/approvalfact/canonical.go
vendored
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
// Package approvalfact provides data-only Approval claim correspondence.
|
||||
// It neither authenticates a provider nor decides permission or consumes objects.
|
||||
package approvalfact
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"math"
|
||||
"math/big"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const MaxJSONBytes = 1 << 20
|
||||
|
||||
var errJSON = errors.New("invalid Approval JSON")
|
||||
|
||||
// DecodeJSON retains numeric lexical types, refuses duplicates and invalid
|
||||
// Unicode, and bounds work. A Go map previously decoded into float64 cannot
|
||||
// recover the producer's integer-versus-floating identity; pass source bytes.
|
||||
func DecodeJSON(raw []byte) (any, error) {
|
||||
if len(raw) > MaxJSONBytes || !utf8.Valid(raw) || !validSurrogates(raw) {
|
||||
return nil, errJSON
|
||||
}
|
||||
d := json.NewDecoder(bytes.NewReader(raw))
|
||||
d.UseNumber()
|
||||
var read func(int) (any, error)
|
||||
read = func(depth int) (any, error) {
|
||||
if depth > 128 {
|
||||
return nil, errJSON
|
||||
}
|
||||
t, e := d.Token()
|
||||
if e != nil {
|
||||
return nil, errJSON
|
||||
}
|
||||
switch x := t.(type) {
|
||||
case json.Delim:
|
||||
if x == '{' {
|
||||
m := map[string]any{}
|
||||
for d.More() {
|
||||
k, e := d.Token()
|
||||
if e != nil {
|
||||
return nil, errJSON
|
||||
}
|
||||
s, ok := k.(string)
|
||||
if !ok {
|
||||
return nil, errJSON
|
||||
}
|
||||
if _, ok = m[s]; ok {
|
||||
return nil, errJSON
|
||||
}
|
||||
v, e := read(depth + 1)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
m[s] = v
|
||||
}
|
||||
end, e := d.Token()
|
||||
if e != nil || end != json.Delim('}') {
|
||||
return nil, errJSON
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
if x == '[' {
|
||||
a := []any{}
|
||||
for d.More() {
|
||||
v, e := read(depth + 1)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
a = append(a, v)
|
||||
}
|
||||
end, e := d.Token()
|
||||
if e != nil || end != json.Delim(']') {
|
||||
return nil, errJSON
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
return nil, errJSON
|
||||
case json.Number:
|
||||
if _, e := pythonNumber(x); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
return x, nil
|
||||
case string, bool, nil:
|
||||
return x, nil
|
||||
default:
|
||||
return nil, errJSON
|
||||
}
|
||||
}
|
||||
v, e := read(0)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if _, e = d.Token(); e != io.EOF {
|
||||
return nil, errJSON
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// encoding/json replaces lone UTF-16 surrogates, whereas the Python producer
|
||||
// cannot encode them as UTF-8. Reject before the decoder can erase that fact.
|
||||
func validSurrogates(raw []byte) bool {
|
||||
in := false
|
||||
for i := 0; i < len(raw); i++ {
|
||||
if raw[i] == '"' {
|
||||
in = !in
|
||||
continue
|
||||
}
|
||||
if !in || raw[i] != '\\' {
|
||||
continue
|
||||
}
|
||||
i++
|
||||
if i >= len(raw) {
|
||||
return false
|
||||
}
|
||||
if raw[i] != 'u' {
|
||||
continue
|
||||
}
|
||||
if i+4 >= len(raw) {
|
||||
return false
|
||||
}
|
||||
n, e := strconv.ParseUint(string(raw[i+1:i+5]), 16, 16)
|
||||
if e != nil {
|
||||
return false
|
||||
}
|
||||
i += 4
|
||||
if n >= 0xdc00 && n <= 0xdfff {
|
||||
return false
|
||||
}
|
||||
if n < 0xd800 || n > 0xdbff {
|
||||
continue
|
||||
}
|
||||
if i+6 >= len(raw) || raw[i+1] != '\\' || raw[i+2] != 'u' {
|
||||
return false
|
||||
}
|
||||
low, e := strconv.ParseUint(string(raw[i+3:i+7]), 16, 16)
|
||||
if e != nil || low < 0xdc00 || low > 0xdfff {
|
||||
return false
|
||||
}
|
||||
i += 6
|
||||
}
|
||||
return !in
|
||||
}
|
||||
|
||||
func pythonNumber(n json.Number) (string, error) {
|
||||
s := string(n)
|
||||
if !strings.ContainsAny(s, ".eE") {
|
||||
i, ok := new(big.Int).SetString(s, 10)
|
||||
if !ok {
|
||||
return "", errJSON
|
||||
}
|
||||
return i.String(), nil
|
||||
}
|
||||
f, e := strconv.ParseFloat(s, 64)
|
||||
if e != nil || math.IsInf(f, 0) || math.IsNaN(f) {
|
||||
return "", errJSON
|
||||
}
|
||||
// Python repr uses shortest round-trip digits, fixed form for exponents
|
||||
// -4..15, a mandatory .0 for integral floats, and two exponent digits.
|
||||
sci := strconv.FormatFloat(f, 'e', -1, 64)
|
||||
at := strings.IndexByte(sci, 'e')
|
||||
exp, _ := strconv.Atoi(sci[at+1:])
|
||||
if exp >= -4 && exp < 16 {
|
||||
s = strconv.FormatFloat(f, 'f', -1, 64)
|
||||
if !strings.Contains(s, ".") {
|
||||
s += ".0"
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
sign := "+"
|
||||
if exp < 0 {
|
||||
sign = "-"
|
||||
exp = -exp
|
||||
}
|
||||
digits := strconv.Itoa(exp)
|
||||
if len(digits) < 2 {
|
||||
digits = "0" + digits
|
||||
}
|
||||
return sci[:at] + "e" + sign + digits, nil
|
||||
}
|
||||
|
||||
func quote(s string) string {
|
||||
var b strings.Builder
|
||||
b.WriteByte('"')
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '"', '\\':
|
||||
b.WriteByte('\\')
|
||||
b.WriteRune(r)
|
||||
case '\b':
|
||||
b.WriteString(`\b`)
|
||||
case '\f':
|
||||
b.WriteString(`\f`)
|
||||
case '\n':
|
||||
b.WriteString(`\n`)
|
||||
case '\r':
|
||||
b.WriteString(`\r`)
|
||||
case '\t':
|
||||
b.WriteString(`\t`)
|
||||
default:
|
||||
if r < 32 {
|
||||
b.WriteString(`\u00`)
|
||||
const h = "0123456789abcdef"
|
||||
b.WriteByte(h[byte(r)>>4])
|
||||
b.WriteByte(h[byte(r)&15])
|
||||
} else {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
b.WriteByte('"')
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func canonical(v any) (string, error) {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
return "null", nil
|
||||
case bool:
|
||||
if x {
|
||||
return "true", nil
|
||||
}
|
||||
return "false", nil
|
||||
case string:
|
||||
return quote(x), nil
|
||||
case json.Number:
|
||||
return pythonNumber(x)
|
||||
case []any:
|
||||
a := make([]string, len(x))
|
||||
for i, v := range x {
|
||||
s, e := canonical(v)
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
a[i] = s
|
||||
}
|
||||
return "[" + strings.Join(a, ",") + "]", nil
|
||||
case map[string]any:
|
||||
keys := make([]string, 0, len(x))
|
||||
for k := range x {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
a := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
s, e := canonical(x[k])
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
a = append(a, quote(k)+":"+s)
|
||||
}
|
||||
return "{" + strings.Join(a, ",") + "}", nil
|
||||
default:
|
||||
return "", errJSON
|
||||
}
|
||||
}
|
||||
|
||||
// CanonicalBinding reproduces approval_engine.binding.binding_digest over the
|
||||
// five native act fields. Extra binding metadata is excluded by that contract.
|
||||
func CanonicalBinding(raw []byte) ([]byte, string, error) {
|
||||
v, e := DecodeJSON(raw)
|
||||
if e != nil {
|
||||
return nil, "", e
|
||||
}
|
||||
m, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
return nil, "", errJSON
|
||||
}
|
||||
act := map[string]any{}
|
||||
for _, k := range []string{"action", "actor", "principal", "purpose"} {
|
||||
s, ok := m[k].(string)
|
||||
if !ok || s == "" {
|
||||
return nil, "", errJSON
|
||||
}
|
||||
act[k] = s
|
||||
}
|
||||
target, ok := m["target"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, "", errJSON
|
||||
}
|
||||
act["target"] = target
|
||||
s, e := canonical(act)
|
||||
if e != nil {
|
||||
return nil, "", e
|
||||
}
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return []byte(s), "sha256:" + hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
170
third_party/approvalfact/claim.go
vendored
Normal file
170
third_party/approvalfact/claim.go
vendored
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
package approvalfact
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
var errClaim = errors.New("Approval fact does not match required claim")
|
||||
var digestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
|
||||
var uuidPattern = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
|
||||
var timePattern = regexp.MustCompile(`^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,9})?(Z|[+-]([01][0-9]|2[0-3]):[0-5][0-9])$`)
|
||||
|
||||
// Expected contains independently owner-derived native act bytes and the actual
|
||||
// Access approval-free digest. Marshaling an already float64-decoded Go request
|
||||
// is not an authoritative reconstruction of the native act.
|
||||
type Expected struct {
|
||||
ApprovalID string
|
||||
NativeBindingJSON []byte
|
||||
PDPDigest string
|
||||
}
|
||||
|
||||
// Fact is validated data shape/correspondence, NOT authenticated provider origin
|
||||
// or execution authority. Access must wrap it only after its own source admission.
|
||||
type Fact struct {
|
||||
raw []byte
|
||||
stable string
|
||||
digest string
|
||||
observed, timeLimit, validatedAt time.Time
|
||||
}
|
||||
|
||||
func (f Fact) RawJSON() []byte { return append([]byte(nil), f.raw...) }
|
||||
func (f Fact) ObservedAt() time.Time { return f.observed }
|
||||
func (f Fact) Deadline() time.Time { return f.timeLimit }
|
||||
func (f Fact) NativeDigest() string { return f.digest }
|
||||
|
||||
func members(m map[string]any, required, optional []string) bool {
|
||||
allowed := map[string]bool{}
|
||||
for _, k := range required {
|
||||
allowed[k] = true
|
||||
if _, ok := m[k]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, k := range optional {
|
||||
allowed[k] = true
|
||||
}
|
||||
for k := range m {
|
||||
if !allowed[k] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
func object(v any) (map[string]any, bool) { m, ok := v.(map[string]any); return m, ok }
|
||||
func stamp(v any) (time.Time, error) {
|
||||
s, ok := v.(string)
|
||||
if !ok || !timePattern.MatchString(s) {
|
||||
return time.Time{}, errClaim
|
||||
}
|
||||
t, e := time.Parse(time.RFC3339Nano, s)
|
||||
if e != nil {
|
||||
return time.Time{}, errClaim
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// ValidateClaim checks one original or fetched schema-0.1 human/PDP-path claim
|
||||
// at a caller-captured instant, using the existing Approval producer semantics.
|
||||
// It has no I/O, policy evaluator, credentials, cache, or consume mutation.
|
||||
func ValidateClaim(raw []byte, expected Expected, now time.Time) (Fact, error) {
|
||||
if now.IsZero() || !uuidPattern.MatchString(expected.ApprovalID) || !digestPattern.MatchString(expected.PDPDigest) {
|
||||
return Fact{}, errClaim
|
||||
}
|
||||
_, want, e := CanonicalBinding(expected.NativeBindingJSON)
|
||||
if e != nil {
|
||||
return Fact{}, errClaim
|
||||
}
|
||||
v, e := DecodeJSON(raw)
|
||||
if e != nil {
|
||||
return Fact{}, errClaim
|
||||
}
|
||||
m, ok := object(v)
|
||||
if !ok || !members(m, []string{"schema_version", "kind", "issuer", "approval_id", "state", "valid_now", "consumed", "binding", "freshness", "validity", "reason_code"}, []string{"yields_to"}) {
|
||||
return Fact{}, errClaim
|
||||
}
|
||||
if y, ok := m["yields_to"]; ok {
|
||||
if _, ok = y.(string); !ok {
|
||||
return Fact{}, errClaim
|
||||
}
|
||||
}
|
||||
if m["schema_version"] != "0.1" || m["kind"] != "approval-claim" || m["issuer"] != "approval-engine" || m["approval_id"] != expected.ApprovalID || m["state"] != "valid" || m["valid_now"] != true || m["consumed"] != false || m["reason_code"] != "ok" {
|
||||
return Fact{}, errClaim
|
||||
}
|
||||
b, ok := object(m["binding"])
|
||||
if !ok || !members(b, []string{"action", "actor", "principal", "purpose", "target", "digest", "pdp_digest", "pdp_path", "human_control"}, nil) {
|
||||
return Fact{}, errClaim
|
||||
}
|
||||
if b["digest"] != want || b["pdp_digest"] != expected.PDPDigest || b["pdp_path"] != true || b["human_control"] != true {
|
||||
return Fact{}, errClaim
|
||||
}
|
||||
text, e := canonical(b)
|
||||
if e != nil {
|
||||
return Fact{}, errClaim
|
||||
}
|
||||
_, got, e := CanonicalBinding([]byte(text))
|
||||
if e != nil || got != want {
|
||||
return Fact{}, errClaim
|
||||
}
|
||||
f, ok := object(m["freshness"])
|
||||
if !ok || !members(f, []string{"observed_at", "ttl_seconds", "not_after"}, nil) {
|
||||
return Fact{}, errClaim
|
||||
}
|
||||
observed, e := stamp(f["observed_at"])
|
||||
if e != nil {
|
||||
return Fact{}, e
|
||||
}
|
||||
notAfter, e := stamp(f["not_after"])
|
||||
if e != nil {
|
||||
return Fact{}, e
|
||||
}
|
||||
ttl, ok := f["ttl_seconds"].(json.Number)
|
||||
if !ok {
|
||||
return Fact{}, errClaim
|
||||
}
|
||||
seconds, e := strconv.ParseInt(string(ttl), 10, 64)
|
||||
if e != nil || seconds < 1 {
|
||||
return Fact{}, errClaim
|
||||
}
|
||||
// Bound arithmetic to time.Duration's representable seconds, not a new
|
||||
// semantic freshness grant or configured TTL. Unsupported extremes refuse.
|
||||
if seconds > int64((1<<63-1)/int64(time.Second)) || !observed.Add(time.Duration(seconds)*time.Second).Equal(notAfter) || now.Before(observed) || !now.Before(notAfter) {
|
||||
return Fact{}, errClaim
|
||||
}
|
||||
validity, ok := object(m["validity"])
|
||||
if !ok || !members(validity, []string{"not_before", "expires_at"}, nil) {
|
||||
return Fact{}, errClaim
|
||||
}
|
||||
before, e := stamp(validity["not_before"])
|
||||
if e != nil {
|
||||
return Fact{}, e
|
||||
}
|
||||
expires, e := stamp(validity["expires_at"])
|
||||
if e != nil || now.Before(before) || !now.Before(expires) {
|
||||
return Fact{}, errClaim
|
||||
}
|
||||
limit := notAfter
|
||||
if expires.Before(limit) {
|
||||
limit = expires
|
||||
}
|
||||
delete(m, "freshness")
|
||||
delete(m, "yields_to")
|
||||
stable, e := canonical(m)
|
||||
if e != nil {
|
||||
return Fact{}, errClaim
|
||||
}
|
||||
return Fact{raw: append([]byte(nil), raw...), stable: stable, digest: want, observed: observed, timeLimit: limit, validatedAt: now}, nil
|
||||
}
|
||||
|
||||
// MatchObserved requires independently validated original/fetched facts at the
|
||||
// same captured instant and exact nonfreshness authority correspondence. It
|
||||
// never rewrites the original request or extends its original deadline.
|
||||
func MatchObserved(submitted, observed Fact) error {
|
||||
if submitted.stable == "" || observed.stable == "" || !submitted.validatedAt.Equal(observed.validatedAt) || submitted.stable != observed.stable {
|
||||
return errClaim
|
||||
}
|
||||
return nil
|
||||
}
|
||||
3
third_party/approvalfact/go.mod
vendored
Normal file
3
third_party/approvalfact/go.mod
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module github.com/netkingdom/approval-engine/adapters/go/approvalfact
|
||||
|
||||
go 1.26.7
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue