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 }