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

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

View file

@ -8,6 +8,7 @@ import (
"path/filepath"
"sync"
"github.com/netkingdom/access-engine/internal/sign"
"github.com/netkingdom/access-engine/pkg/api"
)
@ -67,8 +68,8 @@ func (l *JSONLDecisionLog) ReadAll() ([]api.DecisionEnvelope, error) {
if scanner.Text() == "" {
continue
}
var decision api.DecisionEnvelope
if err := json.Unmarshal(scanner.Bytes(), &decision); err != nil {
decision, err := sign.DecodeEnvelope(scanner.Bytes())
if err != nil {
return nil, fmt.Errorf("unmarshal decision log line %d: %w", len(decisions)+1, err)
}
decisions = append(decisions, decision)

View file

@ -158,6 +158,10 @@ func (e *Engine) decide(ctx context.Context, request api.CheckRequest) (api.Deci
func (e *Engine) IsServicePilot() bool {
return e.policy.Metadata.ID == taskexecution.ServicePilotPackageID && e.policy.Metadata.Version == taskexecution.ServicePilotPackageVersion
}
func (e *Engine) IsTrustedServicePilot() bool {
return e.policy.Metadata.ID == taskexecution.ServicePilotPackageID && e.policy.Metadata.Version == taskexecution.TrustedServicePilotPackageVersion
}
func (e *Engine) IsTaskPin() bool { return e.IsServicePilot() || e.IsTrustedServicePilot() }
// ErrServicePilotUnavailable withholds any result that is not this B1 slice's
// signed DENY. Provider joins and executable eligibility remain unimplemented.
@ -189,7 +193,7 @@ func (e *Engine) CheckServicePilot(ctx context.Context, input taskexecution.Serv
// Check evaluates one subject/action/resource request. The decision is
// released only after its required record commits (GH-DEC-2026-024).
func (e *Engine) Check(ctx context.Context, request api.CheckRequest) (api.DecisionEnvelope, error) {
if e.IsServicePilot() || request.Action == "execute-ccr" {
if e.IsTaskPin() || request.Action == "execute-ccr" {
return api.DecisionEnvelope{}, ErrServicePilotUnavailable
}
decision, err := e.decide(ctx, request)
@ -205,7 +209,7 @@ func (e *Engine) Check(ctx context.Context, request api.CheckRequest) (api.Decis
// BatchCheck evaluates one subject/action/context tuple against resources in
// request order, and commits the whole batch in one durable write.
func (e *Engine) BatchCheck(ctx context.Context, request api.BatchCheckRequest) ([]api.DecisionEnvelope, error) {
if e.IsServicePilot() || request.Action == "execute-ccr" {
if e.IsTaskPin() || request.Action == "execute-ccr" {
return nil, ErrServicePilotUnavailable
}
decisions := make([]api.DecisionEnvelope, 0, len(request.Resources))
@ -232,7 +236,7 @@ func (e *Engine) BatchCheck(ctx context.Context, request api.BatchCheckRequest)
// ListAllowed evaluates candidate resources and returns only allow decisions.
func (e *Engine) ListAllowed(ctx context.Context, request ListAllowedRequest) ([]api.DecisionEnvelope, error) {
if e.IsServicePilot() || request.Action == "execute-ccr" {
if e.IsTaskPin() || request.Action == "execute-ccr" {
return nil, ErrServicePilotUnavailable
}
candidates := e.store.ResourceRefs(request.System, request.ResourceType)

View file

@ -0,0 +1,193 @@
package decision
import (
"context"
"encoding/json"
"github.com/netkingdom/access-engine/internal/callerauth"
"github.com/netkingdom/access-engine/internal/sign"
"github.com/netkingdom/access-engine/internal/taskexecution"
"github.com/netkingdom/access-engine/pkg/api"
"github.com/netkingdom/approval-engine/adapters/go/approvalfact"
"time"
)
// legacyDigestProjection preserves the published PDP digest profile while the
// signed structured binding retains original numeric kinds. It is used only
// for digest material, never to reconstruct an Approval-native act.
func legacyDigestProjection(request api.CheckRequest) (api.CheckRequest, error) {
raw, err := json.Marshal(request)
if err != nil {
return api.CheckRequest{}, err
}
var projected api.CheckRequest
err = json.Unmarshal(raw, &projected)
return projected, err
}
func nativeServiceAct(request api.CheckRequest) ([]byte, error) {
purpose, ok := request.Context["purpose"].(string)
if !ok || purpose == "" || request.Subject.ID != "secrets-engine" || request.Subject.Type != "service" {
return nil, taskexecution.ErrServiceInput
}
if principal, exists := request.Subject.Attributes["principal"]; exists && principal != "secrets-engine" {
return nil, taskexecution.ErrServiceInput
}
// All map numbers originate from the number-preserving request/registry
// profile. This raw draft is canonicalized only by the Approval owner helper.
return json.Marshal(map[string]any{"action": request.Action, "actor": request.Subject.ID, "principal": "secrets-engine", "purpose": purpose, "target": request.Resource})
}
func (e *Engine) signTaskEnvelope(envelope *api.DecisionEnvelope) error {
e.mu.RLock()
key, kid := e.signingKey, e.signingKID
e.mu.RUnlock()
if len(key) == 0 {
return ErrServicePilotUnavailable
}
return sign.Sign(envelope, key, kid)
}
// CheckTrustedService is the only v2 task evaluation path. Its source reader
// and immutable service input are server-created, not HTTP validation flags.
// It never consumes an approval or causes a protected backend effect.
func (e *Engine) CheckTrustedService(ctx context.Context, input taskexecution.ServiceInput, source *taskexecution.ApprovalReader) (api.DecisionEnvelope, error) {
if !e.IsTrustedServicePilot() || !input.NumberPreserved() || !source.Configured() {
return api.DecisionEnvelope{}, ErrServicePilotUnavailable
}
now := e.now()
submitted, err := input.RequestAt(now)
if err != nil {
return api.DecisionEnvelope{}, err
}
normalized, facts := e.normalizeRequest(submitted)
if (facts.resourceFound || facts.subjectFound) && !e.store.NumbersPreserved() {
return api.DecisionEnvelope{}, ErrServicePilotUnavailable
}
original, err := legacyDigestProjection(submitted)
if err != nil {
return api.DecisionEnvelope{}, err
}
evaluated, err := legacyDigestProjection(normalized)
if err != nil {
return api.DecisionEnvelope{}, err
}
native, err := nativeServiceAct(normalized)
if err != nil {
return api.DecisionEnvelope{}, err
}
ctx = callerauth.WithRecord(ctx, input.Caller())
var approvalEvidence *api.ApprovalObservationProvenance
deadline := input.Caller().NotAfter
contract := submitted.Context["execution_contract"].(map[string]any)
operationEnd, err := time.Parse(time.RFC3339Nano, contract["operation_deadline"].(string))
if err != nil {
return api.DecisionEnvelope{}, taskexecution.ErrServiceInput
}
if operationEnd.Before(deadline) {
deadline = operationEnd
}
carried, hasClaim := submitted.Context[api.ApprovalContextKey]
if hasClaim {
claim, ok := carried.(map[string]any)
if !ok {
return api.DecisionEnvelope{}, taskexecution.ErrApprovalSource
}
id, ok := claim["approval_id"].(string)
if !ok {
return api.DecisionEnvelope{}, taskexecution.ErrApprovalSource
}
originalRaw, err := json.Marshal(carried)
if err != nil {
return api.DecisionEnvelope{}, taskexecution.ErrApprovalSource
}
observed, err := source.Observe(ctx, id)
if err != nil {
return api.DecisionEnvelope{}, err
}
// One validation instant AFTER network I/O; a fresh read cannot rescue the
// original carried claim's freshness, source expiry or operation deadline.
now = e.now()
if _, err := input.RequestAt(now); err != nil {
return api.DecisionEnvelope{}, err
}
verified, err := observed.Validate(originalRaw, approvalfact.Expected{ApprovalID: id, NativeBindingJSON: native, PDPDigest: api.ApprovalBindingDigest(evaluated)}, now)
if err != nil {
return api.DecisionEnvelope{}, err
}
factEnd, err := verified.Deadline()
if err != nil {
return api.DecisionEnvelope{}, err
}
if factEnd.Before(deadline) {
deadline = factEnd
}
approvalEvidence, err = verified.Provenance()
if err != nil {
return api.DecisionEnvelope{}, err
}
}
expectation, err := e.policy.EvaluatePreservingNumbers(ctx, normalized)
if err != nil {
return api.DecisionEnvelope{}, err
}
if !hasClaim && expectation.Effect == api.DecisionEffectAllow {
return api.DecisionEnvelope{}, ErrServicePilotUnavailable
}
now = e.now()
if _, err := input.RequestAt(now); err != nil {
return api.DecisionEnvelope{}, err
}
envelope := e.envelope(ctx, normalized, submitted, expectation, facts)
envelope.Binding.RequestDigest = api.RequestDigest(evaluated)
envelope.Binding.SubmittedRequestDigest = api.RequestDigest(original)
// Claim-free signed DENY is the producer-owned normalization receipt used
// before Approval issue. It supplies facts only, never execution authority.
envelope.Binding.ApprovalBindingDigest = api.ApprovalBindingDigest(evaluated)
envelope.Provenance.ApprovalObservation = approvalEvidence
if envelope.Effect == api.DecisionEffectAllow {
if approvalEvidence == nil || envelope.Lifetime == nil {
return api.DecisionEnvelope{}, ErrServicePilotUnavailable
}
lifetime, err := trustedTaskLifetime(contract, operationEnd, deadline, now, e.policy.Metadata.AllowTTL)
if err != nil {
return api.DecisionEnvelope{}, err
}
envelope.Lifetime = lifetime
}
if err := e.signTaskEnvelope(&envelope); err != nil {
return api.DecisionEnvelope{}, err
}
if err := e.recordDecisions([]api.DecisionEnvelope{envelope}); err != nil {
return api.DecisionEnvelope{}, err
}
return envelope, nil
}
// The source ceiling is deliberately no larger than the existing task-token
// ceiling. Structural backend budgets do not guarantee an executable window.
const trustedTaskMaxAuthority = 120 * time.Second
const trustedTaskCleanupReserve = 5 * time.Second
// trustedTaskLifetime authorizes the entire exact operation, not merely its
// start. It cannot extend any caller, original/fresh fact, or approval bound.
func trustedTaskLifetime(contract map[string]any, operationEnd, authorityEnd, now time.Time, declared string) (*api.DecisionLifetime, error) {
maximum, err := api.ParseAllowTTL(declared)
if err != nil || maximum <= 0 || maximum > trustedTaskMaxAuthority || now.IsZero() || operationEnd.After(authorityEnd) || !operationEnd.After(now) || operationEnd.Sub(now) > maximum {
return nil, ErrServicePilotUnavailable
}
budget, ok := contract["backend_budget"].(map[string]any)
if !ok {
return nil, ErrServicePilotUnavailable
}
number, ok := budget["ttl_seconds"].(json.Number)
if !ok {
return nil, ErrServicePilotUnavailable
}
seconds, err := number.Int64()
if err != nil || seconds < 30 || seconds > 120 {
return nil, ErrServicePilotUnavailable
}
remaining := operationEnd.Sub(now)
if remaining < time.Duration(seconds)*time.Second+trustedTaskCleanupReserve {
return nil, ErrServicePilotUnavailable
}
return &api.DecisionLifetime{Kind: api.DecisionLifetimeTTL, TTL: remaining.String(), NotBefore: now.UTC().Format(time.RFC3339Nano), ExpiresAt: operationEnd.UTC().Format(time.RFC3339Nano)}, nil
}

View file

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

View file

@ -112,7 +112,9 @@ func (o *Outbox) nextEntry() (entry, int64, error) {
return entry{}, 0, err
}
var e entry
if err := json.Unmarshal(line, &e); err != nil {
decoder := json.NewDecoder(bytes.NewReader(line))
decoder.UseNumber()
if err := decoder.Decode(&e); err != nil {
return entry{}, 0, err
}
if e.Seq != o.cursor.Seq+1 || e.Event.Source != o.source {

View file

@ -0,0 +1,53 @@
package emission
import (
"bytes"
"crypto/ed25519"
"encoding/json"
"github.com/netkingdom/access-engine/internal/sign"
"github.com/netkingdom/access-engine/pkg/api"
"testing"
)
func TestOutboxReplayPreservesProducerNumericSignature(t *testing.T) {
folder := t.TempDir()
outbox, err := Open(folder, "synthetic-source")
if err != nil {
t.Fatal(err)
}
key := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{21}, 32))
envelope := api.DecisionEnvelope{ID: "decision:synthetic-numeric", Effect: api.DecisionEffectDeny, Resource: api.ResourceRef{ID: "synthetic", Type: "tenant", System: "tenant-engine"}}
envelope.Binding = &api.DecisionBinding{Context: map[string]any{"float": json.Number("1.0"), "negative_zero": json.Number("-0.0"), "integer": json.Number("9007199254740993"), "exponent": json.Number("1e-07")}}
if err := sign.Sign(&envelope, key, "synthetic-numeric"); err != nil {
t.Fatal(err)
}
if err := outbox.Append(envelope); err != nil {
t.Fatal(err)
}
outbox.Close()
outbox, err = Open(folder, "synthetic-source")
if err != nil {
t.Fatal(err)
}
defer outbox.Close()
entry, _, err := outbox.nextEntry()
if err != nil {
t.Fatal(err)
}
wire, err := json.Marshal(entry.Event.Data)
if err != nil {
t.Fatal(err)
}
replayed, err := sign.DecodeEnvelope(wire)
if err != nil {
t.Fatal(err)
}
if err := sign.Verify(replayed, map[string]ed25519.PublicKey{"synthetic-numeric": key.Public().(ed25519.PublicKey)}); err != nil {
t.Fatal(err)
}
before, _ := sign.CanonicalBytes(envelope)
after, _ := sign.CanonicalBytes(replayed)
if !bytes.Equal(before, after) {
t.Fatal("replayed signed bytes changed")
}
}

View file

@ -136,7 +136,9 @@ func (o *Outbox) load() error {
return os.Truncate(o.path, o.size)
}
var e entry
if err := json.Unmarshal(data[good:good+end], &e); err != nil {
decoder := json.NewDecoder(bytes.NewReader(data[good : good+end]))
decoder.UseNumber()
if err := decoder.Decode(&e); err != nil {
return fmt.Errorf("outbox line %d is corrupt: %w", line, err)
}
if e.Seq != o.seq+1 || e.Event.Source != o.source {

View file

@ -1,6 +1,7 @@
package policy
import (
"bytes"
"context"
"encoding/json"
"fmt"
@ -375,7 +376,25 @@ func (p *Package) runFixtures(ctx context.Context) []FixtureResult {
}
func (p *Package) evaluateDecision(ctx context.Context, request api.CheckRequest) (api.DecisionExpectation, error) {
return p.evaluateDecisionProfile(ctx, request, false)
}
// EvaluatePreservingNumbers feeds the same evaluator the task profile's exact
// numeric values. Ordinary evaluation behavior and canonical contracts stay put.
func (p *Package) EvaluatePreservingNumbers(ctx context.Context, request api.CheckRequest) (api.DecisionExpectation, error) {
return p.evaluateDecisionProfile(ctx, request, true)
}
func (p *Package) evaluateDecisionProfile(ctx context.Context, request api.CheckRequest, preserve bool) (api.DecisionExpectation, error) {
input, err := toRegoInput(request)
if preserve {
raw, e := json.Marshal(request)
if e != nil {
return api.DecisionExpectation{}, e
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
err = decoder.Decode(&input)
}
if err != nil {
return api.DecisionExpectation{}, err
}

View file

@ -0,0 +1,36 @@
package registry
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestTaskRegistryNumericSourceAndStrictDecode(t *testing.T) {
path := filepath.Join(t.TempDir(), "snapshot.json")
raw := []byte(`{"resource_manifests":[{"id":"synthetic","system":"secrets-engine","resources":[{"id":"catalog:synthetic","type":"secret-catalog-lane","attributes":{"float":1.0,"integer":9007199254740993,"negative_zero":-0.0}}]}]}`)
os.WriteFile(path, raw, 0600)
store, err := LoadFilePreservingNumbers(path)
if err != nil {
t.Fatal(err)
}
if !store.NumbersPreserved() {
t.Fatal("missing source profile")
}
resource, ok := store.Resource("secrets-engine", "catalog:synthetic")
if !ok {
t.Fatal("missing resource")
}
for k, v := range map[string]string{"float": "1.0", "integer": "9007199254740993", "negative_zero": "-0.0"} {
if resource.Attributes[k] != json.Number(v) {
t.Fatalf("lost native representation: %s", k)
}
}
for _, raw := range []string{`{} {}`, `{"subjects":[],"subjects":[]}`, `{"unknown":true}`} {
os.WriteFile(path, []byte(raw), 0600)
if _, err := LoadFilePreservingNumbers(path); err == nil {
t.Fatal("invalid authoritative registry accepted")
}
}
}

View file

@ -1,6 +1,7 @@
package registry
import (
"bytes"
"encoding/json"
"fmt"
"os"
@ -8,10 +9,12 @@ import (
"sort"
"github.com/netkingdom/access-engine/pkg/api"
"github.com/netkingdom/approval-engine/adapters/go/approvalfact"
)
// Store is a deterministic local registry for standalone development and tests.
type Store struct {
numbersPreserved bool
systems map[string]api.ProtectedSystemManifest
resourceManifests map[string]api.ResourceManifest
resources map[string]api.Resource
@ -66,6 +69,32 @@ func LoadFile(path string) (*Store, error) {
return store, nil
}
// LoadFilePreservingNumbers is the task-source profile: owner registry numeric
// representations stay intact for native act correspondence. Ordinary loaders
// and their existing snapshot/digest behavior remain unchanged.
func LoadFilePreservingNumbers(path string) (*Store, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
if _, err := approvalfact.DecodeJSON(raw); err != nil {
return nil, err
}
var snapshot Snapshot
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
decoder.DisallowUnknownFields()
if err := decoder.Decode(&snapshot); err != nil {
return nil, err
}
store := NewStore()
if err := store.ApplySnapshot(snapshot); err != nil {
return nil, err
}
store.numbersPreserved = true
return store, nil
}
// SaveFile writes the store as deterministic pretty-printed JSON.
func (s *Store) SaveFile(path string) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
@ -380,3 +409,6 @@ func sortedValues[T any](items map[string]T) []T {
}
return out
}
// NumbersPreserved reports the source profile of file-backed registry facts.
func (s *Store) NumbersPreserved() bool { return s != nil && s.numbersPreserved }

View file

@ -0,0 +1,54 @@
package sign
import (
"bytes"
"crypto/ed25519"
"encoding/json"
"github.com/netkingdom/access-engine/pkg/api"
"testing"
)
func TestProducerNumericEnvelopeDecodeCompatibility(t *testing.T) {
key := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{11}, 32))
keys := map[string]ed25519.PublicKey{"synthetic-numeric": key.Public().(ed25519.PublicKey)}
for _, raw := range []string{`1`, `1.0`, `-0.0`, `1e-07`, `1e+20`, `9007199254740993`, `1.7976931348623157e308`, `5e-324`, `true`, `"<>&\u2028\u2029 café"`, `[1,1.0,-0.0]`, `{"nested":1.0}`} {
t.Run(raw, func(t *testing.T) {
var value any
decoder := json.NewDecoder(bytes.NewBufferString(raw))
decoder.UseNumber()
if err := decoder.Decode(&value); err != nil {
t.Fatal(err)
}
original := api.DecisionEnvelope{Binding: &api.DecisionBinding{Context: map[string]any{"numeric": value}}}
if err := Sign(&original, key, "synthetic-numeric"); err != nil {
t.Fatal(err)
}
wire, _ := json.Marshal(original)
decoded, err := DecodeEnvelope(wire)
if err != nil {
t.Fatal(err)
}
if err := Verify(decoded, keys); err != nil {
t.Fatal(err)
}
before, _ := CanonicalBytes(original)
after, _ := CanonicalBytes(decoded)
if !bytes.Equal(before, after) {
t.Fatal("producer canonical bytes changed")
}
})
}
// Existing float64 producer profile still verifies through the new decoder.
legacy := api.DecisionEnvelope{Binding: &api.DecisionBinding{Context: map[string]any{"number": float64(1), "exponent": float64(1e-7)}}}
Sign(&legacy, key, "synthetic-numeric")
wire, _ := json.Marshal(legacy)
decoded, err := DecodeEnvelope(wire)
if err != nil || Verify(decoded, keys) != nil {
t.Fatal("legacy producer compatibility failed")
}
for _, raw := range []string{`{} {}`, `{"new_unknown_authority":true}`} {
if _, err := DecodeEnvelope([]byte(raw)); err == nil {
t.Fatal("invalid envelope accepted")
}
}
}

View file

@ -4,10 +4,12 @@
package sign
import (
"bytes"
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"github.com/netkingdom/access-engine/pkg/api"
)
@ -74,3 +76,20 @@ func Verify(envelope api.DecisionEnvelope, keys map[string]ed25519.PublicKey) er
}
return nil
}
// DecodeEnvelope preserves JSON numeric kinds/lexemes emitted by task inputs.
// Legacy producer envelopes remain byte-compatible. It does not invent another
// signature canonicalization: CanonicalBytes still calls typed json.Marshal.
func DecodeEnvelope(raw []byte) (api.DecisionEnvelope, error) {
var envelope api.DecisionEnvelope
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
decoder.DisallowUnknownFields()
if err := decoder.Decode(&envelope); err != nil {
return envelope, err
}
if _, err := decoder.Token(); err != io.EOF {
return api.DecisionEnvelope{}, fmt.Errorf("trailing envelope data")
}
return envelope, nil
}

View file

@ -0,0 +1,215 @@
package taskexecution
import (
"context"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"encoding/json"
"errors"
"io"
"mime"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/netkingdom/access-engine/pkg/api"
"github.com/netkingdom/approval-engine/adapters/go/approvalfact"
)
var ErrApprovalSource = errors.New("trusted Approval source refused")
var approvalUUID = regexp.MustCompile(`^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$`)
type ApprovalSourceConfig struct {
SourceID string `json:"source_id"`
SourceRevision string `json:"source_revision"`
Origin string `json:"origin"`
StoreTenant string `json:"store_tenant"`
CAFile string `json:"ca_file"`
TokenFile string `json:"token_file"`
}
// ApprovalReader is installed from owner configuration, never an HTTP request.
// It reads one fixed PIP endpoint; no policy, retry, consume or effect lives here.
type ApprovalReader struct {
config ApprovalSourceConfig
configurationDigest string
client *http.Client
}
func NewApprovalReader(config ApprovalSourceConfig) (*ApprovalReader, error) {
u, err := url.Parse(config.Origin)
if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") || strings.ContainsAny(config.Origin, "\r\n\t ") || config.SourceID == "" || config.SourceRevision == "" || config.StoreTenant != "tenant:platform" || !filepath.IsAbs(config.CAFile) || !filepath.IsAbs(config.TokenFile) {
return nil, ErrApprovalSource
}
ca, err := readPublicApprovalFile(config.CAFile, 1048576)
if err != nil {
return nil, ErrApprovalSource
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(ca) {
return nil, ErrApprovalSource
}
config.Origin = strings.TrimRight(config.Origin, "/")
digest := api.CanonicalDigest(struct {
Config ApprovalSourceConfig `json:"configuration"`
CA string `json:"ca_sha256"`
}{config, byteDigest(ca)})
return &ApprovalReader{config: config, configurationDigest: digest, client: &http.Client{Timeout: 3 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, Transport: &http.Transport{Proxy: nil, DisableCompression: true, TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: pool}}}}, nil
}
func readPublicApprovalFile(path string, limit int64) ([]byte, error) {
file, err := os.Open(path)
if err != nil {
return nil, ErrApprovalSource
}
defer file.Close()
info, err := file.Stat()
if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0022 != 0 {
return nil, ErrApprovalSource
}
raw, err := io.ReadAll(io.LimitReader(file, limit+1))
if err != nil || int64(len(raw)) > limit {
return nil, ErrApprovalSource
}
return raw, nil
}
func LoadApprovalReader(path string) (*ApprovalReader, error) {
if !filepath.IsAbs(path) {
return nil, ErrApprovalSource
}
raw, err := readPublicApprovalFile(path, 65536)
if err != nil {
return nil, ErrApprovalSource
}
if _, err := approvalfact.DecodeJSON(raw); err != nil {
return nil, ErrApprovalSource
}
var config ApprovalSourceConfig
decoder := json.NewDecoder(strings.NewReader(string(raw)))
decoder.DisallowUnknownFields()
if decoder.Decode(&config) != nil {
return nil, ErrApprovalSource
}
return NewApprovalReader(config)
}
func (r *ApprovalReader) Configured() bool {
return r != nil && r.client != nil && r.configurationDigest != ""
}
func byteDigest(raw []byte) string {
h := sha256.Sum256(raw)
return "sha256:" + hex.EncodeToString(h[:])
}
func readApprovalToken(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", ErrApprovalSource
}
defer file.Close()
info, err := file.Stat()
if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0027 != 0 {
return "", ErrApprovalSource
}
raw, err := io.ReadAll(io.LimitReader(file, 8193))
if err != nil || len(raw) > 8192 {
return "", ErrApprovalSource
}
token := strings.TrimSpace(string(raw))
if token == "" || strings.ContainsAny(token, " \t\r\n") {
return "", ErrApprovalSource
}
return token, nil
}
// ApprovalObservation cannot be created by caller JSON or a public verified flag.
// It is raw fact data plus the source/configuration used by this fixed reader.
type ApprovalObservation struct {
raw []byte
sourceID, revision, configurationDigest string
}
func (r *ApprovalReader) Observe(ctx context.Context, id string) (ApprovalObservation, error) {
if !r.Configured() || !approvalUUID.MatchString(id) {
return ApprovalObservation{}, ErrApprovalSource
}
token, err := readApprovalToken(r.config.TokenFile)
if err != nil {
return ApprovalObservation{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, r.config.Origin+"/v1/approvals/"+id+"/claim", nil)
if err != nil {
return ApprovalObservation{}, ErrApprovalSource
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/json")
response, err := r.client.Do(req)
if err != nil {
return ApprovalObservation{}, ErrApprovalSource
}
defer response.Body.Close()
types := response.Header.Values("Content-Type")
if len(types) != 1 {
return ApprovalObservation{}, ErrApprovalSource
}
media, parameters, err := mime.ParseMediaType(types[0])
if err != nil || media != "application/json" || (parameters["charset"] != "" && !strings.EqualFold(parameters["charset"], "utf-8")) || (len(parameters) != 0 && (len(parameters) != 1 || parameters["charset"] == "")) || response.StatusCode != 200 || response.Header.Get("Content-Encoding") != "" {
return ApprovalObservation{}, ErrApprovalSource
}
raw, err := io.ReadAll(io.LimitReader(response.Body, 524289))
if err != nil || len(raw) > 524288 {
return ApprovalObservation{}, ErrApprovalSource
}
if _, err := approvalfact.DecodeJSON(raw); err != nil {
return ApprovalObservation{}, ErrApprovalSource
}
return ApprovalObservation{raw: append([]byte(nil), raw...), sourceID: r.config.SourceID, revision: r.config.SourceRevision, configurationDigest: r.configurationDigest}, nil
}
// VerifiedApprovalFacts establishes typed fact correspondence, not a decision.
// Only an authenticated fixed-reader observation can construct this handle.
type VerifiedApprovalFacts struct {
original, fetched approvalfact.Fact
evidence api.ApprovalObservationProvenance
valid bool
}
func (o ApprovalObservation) Validate(original []byte, expected approvalfact.Expected, now time.Time) (VerifiedApprovalFacts, error) {
if len(o.raw) == 0 || o.sourceID == "" || o.revision == "" || o.configurationDigest == "" {
return VerifiedApprovalFacts{}, ErrApprovalSource
}
originalFact, err := approvalfact.ValidateClaim(original, expected, now)
if err != nil {
return VerifiedApprovalFacts{}, ErrApprovalSource
}
fetched, err := approvalfact.ValidateClaim(o.raw, expected, now)
if err != nil || approvalfact.MatchObserved(originalFact, fetched) != nil {
return VerifiedApprovalFacts{}, ErrApprovalSource
}
limit := originalFact.Deadline()
if fetched.Deadline().Before(limit) {
limit = fetched.Deadline()
}
evidence := api.ApprovalObservationProvenance{SourceID: o.sourceID, SourceRevision: o.revision, ConfigurationDigest: o.configurationDigest, ClaimDigest: byteDigest(o.raw), NativeBindingDigest: fetched.NativeDigest(), ObservedAt: fetched.ObservedAt().UTC().Format(time.RFC3339Nano), NotAfter: limit.UTC().Format(time.RFC3339Nano), ValidatedAt: now.UTC().Format(time.RFC3339Nano)}
return VerifiedApprovalFacts{original: originalFact, fetched: fetched, evidence: evidence, valid: true}, nil
}
func (f VerifiedApprovalFacts) Deadline() (time.Time, error) {
if !f.valid {
return time.Time{}, ErrApprovalSource
}
a := f.original.Deadline()
if f.fetched.Deadline().Before(a) {
a = f.fetched.Deadline()
}
return a, nil
}
func (f VerifiedApprovalFacts) Provenance() (*api.ApprovalObservationProvenance, error) {
if !f.valid {
return nil, ErrApprovalSource
}
copy := f.evidence
return &copy, nil
}

View file

@ -0,0 +1,118 @@
package taskexecution
import (
"context"
"encoding/pem"
"github.com/netkingdom/approval-engine/adapters/go/approvalfact"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
)
func TestFixedApprovalReaderRefusalBoundary(t *testing.T) {
folder := t.TempDir()
token := filepath.Join(folder, "token")
ca := filepath.Join(folder, "ca")
if err := os.WriteFile(token, []byte("synthetic-source-token"), 0440); err != nil {
t.Fatal(err)
}
var mode atomic.Value
mode.Store("ok")
var foreignCalls atomic.Int64
foreign := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
foreignCalls.Add(1)
t.Error("credential escaped fixed origin")
}))
defer foreign.Close()
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer synthetic-source-token" || r.URL.Path != "/v1/approvals/aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee/claim" || r.Method != "GET" {
t.Error("wrong auth/path/method")
}
w.Header().Set("Content-Type", "application/json")
switch mode.Load().(string) {
case "redirect":
w.Header().Set("Location", foreign.URL)
w.WriteHeader(302)
return
case "status":
w.WriteHeader(403)
case "large":
w.Write([]byte(strings.Repeat(" ", 524289)))
case "duplicate":
w.Write([]byte(`{"a":true,"a":false}`))
case "invalid-utf8":
w.Write([]byte{'"', 255, '"'})
case "media":
w.Header().Set("Content-Type", "application/json; arbitrary=authority")
w.Write([]byte(`{}`))
case "duplicate-media":
w.Header().Add("Content-Type", "application/json")
w.Write([]byte(`{}`))
case "compressed":
w.Header().Set("Content-Encoding", "gzip")
w.Write([]byte(`{}`))
case "slow":
select {
case <-r.Context().Done():
case <-time.After(time.Second):
}
default:
w.Write([]byte(`{}`))
}
}))
defer server.Close()
os.WriteFile(ca, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}), 0600)
config := ApprovalSourceConfig{SourceID: "synthetic-source", SourceRevision: "synthetic-revision", Origin: server.URL, StoreTenant: "tenant:platform", CAFile: ca, TokenFile: token}
reader, err := NewApprovalReader(config)
if err != nil {
t.Fatal(err)
}
if _, err := reader.Observe(context.Background(), "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"); err != nil {
t.Fatal(err)
}
for _, name := range []string{"redirect", "status", "large", "duplicate", "invalid-utf8", "media", "duplicate-media", "compressed", "slow"} {
t.Run(name, func(t *testing.T) {
mode.Store(name)
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
if _, err := reader.Observe(ctx, "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"); err == nil {
t.Fatal("invalid provider response admitted")
}
})
}
if foreignCalls.Load() != 0 {
t.Fatal("redirect followed")
}
mode.Store("ok")
for _, bad := range []string{"../consume", "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee/consume", ""} {
if _, err := reader.Observe(context.Background(), bad); err == nil {
t.Fatal("request selected route")
}
}
for _, mode := range []os.FileMode{0460, 0644, 0620} {
os.Chmod(token, mode)
if _, err := reader.Observe(context.Background(), "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"); err == nil {
t.Fatal("unsafe token permissions")
}
}
for _, origin := range []string{"http://127.0.0.1", "https://user:password@localhost", "https://localhost/path", "https://localhost?query=1", "https://localhost#fragment"} {
bad := config
bad.Origin = origin
if _, err := NewApprovalReader(bad); err == nil {
t.Fatal("unsafe origin")
}
}
bad := config
bad.StoreTenant = "tenant:foreign"
if _, err := NewApprovalReader(bad); err == nil {
t.Fatal("foreign tenant configured")
}
if _, err := (ApprovalObservation{}).Validate([]byte(`{}`), approvalfact.Expected{}, time.Now()); err == nil {
t.Fatal("zero observation admitted")
}
}

View file

@ -7,6 +7,7 @@ import (
"errors"
"github.com/netkingdom/access-engine/internal/callerauth"
"github.com/netkingdom/access-engine/pkg/api"
"github.com/netkingdom/approval-engine/adapters/go/approvalfact"
"io"
"time"
)
@ -15,6 +16,7 @@ var ErrServiceInput = errors.New("service task execution input refused")
const ServicePilotPackageID = "secrets-engine.task-execution.service-pilot"
const ServicePilotPackageVersion = "v1"
const TrustedServicePilotPackageVersion = "v2"
const ServicePilotMappingVersion = "proposed-service-task-caller/v1"
const ServicePilotPrincipal = "system:serviceaccount:secrets-engine:secrets-engine"
@ -24,7 +26,10 @@ const ServicePilotPrincipal = "system:serviceaccount:secrets-engine:secrets-engi
// It installs the explicit
// proposed fixed subject-to-transport mapping, never a request-selected mapping.
// No constructor parameter can select warn/disabled mode or another subject.
type ServicePilotInputs struct{ auth *callerauth.Authenticator }
type ServicePilotInputs struct {
auth *callerauth.Authenticator
preserveNumbers bool
}
func NewServicePilotInputs(reviewer callerauth.TokenReviewer) (*ServicePilotInputs, error) {
a, e := callerauth.New(callerauth.ModeEnforce, reviewer, "flex-auth", map[string]string{"secrets-engine": ServicePilotPrincipal}, nil)
@ -34,12 +39,24 @@ func NewServicePilotInputs(reviewer callerauth.TokenReviewer) (*ServicePilotInpu
return &ServicePilotInputs{auth: a}, nil
}
// NewTrustedServiceInputs preserves numeric representations only for the exact
// v2 task path; it grants no Approval-source trust or executable eligibility.
func NewTrustedServiceInputs(reviewer callerauth.TokenReviewer) (*ServicePilotInputs, error) {
input, err := NewServicePilotInputs(reviewer)
if err != nil {
return nil, err
}
input.preserveNumbers = true
return input, nil
}
// ServiceInput cannot be fabricated through exported fields. It proves only
// source input preparation through TokenReview, not approval provenance/consume
// or execution authority. Default zero value is not an authenticated input.
type ServiceInput struct {
serialized []byte
caller callerauth.Record
serialized []byte
caller callerauth.Record
preserveNumbers bool
}
func (s ServiceInput) Request() (api.CheckRequest, error) {
@ -47,7 +64,11 @@ func (s ServiceInput) Request() (api.CheckRequest, error) {
return api.CheckRequest{}, ErrServiceInput
}
var r api.CheckRequest
if json.Unmarshal(s.serialized, &r) != nil {
decoder := json.NewDecoder(bytes.NewReader(s.serialized))
if s.preserveNumbers {
decoder.UseNumber()
}
if decoder.Decode(&r) != nil {
return r, ErrServiceInput
}
return r, nil
@ -142,5 +163,27 @@ func (s *ServicePilotInputs) Prepare(ctx context.Context, authorization string,
if err != nil {
return refuse()
}
return ServiceInput{serialized: raw, caller: record}, nil
return ServiceInput{serialized: raw, caller: record, preserveNumbers: s.preserveNumbers}, nil
}
func (s ServiceInput) NumberPreserved() bool { return s.preserveNumbers }
// DecodeTrustedServiceRequest preserves numbers in the exact new task profile.
// Owner helper validates strict Unicode/JSON; this is not a new canonicalizer.
func DecodeTrustedServiceRequest(reader io.Reader) (api.CheckRequest, error) {
raw, err := io.ReadAll(io.LimitReader(reader, 524289))
if err != nil || len(raw) > 524288 {
return api.CheckRequest{}, ErrServiceInput
}
if _, err := approvalfact.DecodeJSON(raw); err != nil {
return api.CheckRequest{}, ErrServiceInput
}
var request api.CheckRequest
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
decoder.DisallowUnknownFields()
if decoder.Decode(&request) != nil {
return api.CheckRequest{}, ErrServiceInput
}
return request, nil
}