access-engine/internal/decision/trusted_service.go
tegwick 2b064eee51
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
Bind trusted task approval to exact producer facts and lifetime
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a140c4-354a-7823-8f0d-8f717397f786
2026-10-16 17:27:30 +02:00

193 lines
7.6 KiB
Go

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
}