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
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue