access-engine/internal/taskexecution/approval_source.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

215 lines
8 KiB
Go

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
}