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 }