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