Add producer-owned authenticated Go decision client and validated Forgejo profile
Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a14bfc-9643-7872-aa12-98326dcf25e7
This commit is contained in:
parent
d328a26010
commit
ca0cb75729
24 changed files with 2980 additions and 4 deletions
296
pkg/decisionpep/client.go
Normal file
296
pkg/decisionpep/client.go
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
// Package decisionpep authenticates the producer's decision record for one
|
||||
// current request. It never evaluates policy, caches a verdict, or discovers trust.
|
||||
package decisionpep
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/netkingdom/access-engine/internal/sign"
|
||||
"github.com/netkingdom/access-engine/pkg/api"
|
||||
)
|
||||
|
||||
var ErrRefused = errors.New("authenticated decision refused")
|
||||
|
||||
const nonceKey = "decisionpep_request_nonce"
|
||||
|
||||
const JSONProfile = "lossless-json-v1"
|
||||
const provenanceMode = "standalone/lossless-json-v1"
|
||||
const maxBytes = 2 << 20
|
||||
|
||||
// Admission is public, independently admitted owner configuration. TokenFile is
|
||||
// only a path to a private rotating caller credential, never a literal token.
|
||||
type Admission struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
CAFile string `json:"ca_file"`
|
||||
TokenFile string `json:"token_file"`
|
||||
Keys map[string]string `json:"keys"`
|
||||
Evaluator string `json:"evaluator"`
|
||||
PolicyPackage string `json:"policy_package"`
|
||||
PolicyVersion string `json:"policy_version"`
|
||||
PolicyDigest string `json:"policy_digest"`
|
||||
RegistryDigest string `json:"registry_digest"`
|
||||
CallerPrincipal string `json:"caller_principal"`
|
||||
CallerAudience string `json:"caller_audience"`
|
||||
MaxTTL string `json:"max_ttl"`
|
||||
MaxClockSkew string `json:"max_clock_skew"`
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
admission Admission
|
||||
keys map[string]ed25519.PublicKey
|
||||
http *http.Client
|
||||
maxTTL, skew time.Duration
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// Load reads a current independently admitted file. No trust is bootstrapped
|
||||
// from a PDP response, token contents, discovery endpoint, or environment value.
|
||||
func Load(path string) (*Client, error) {
|
||||
raw, err := readFile(path, false)
|
||||
if err != nil {
|
||||
return nil, ErrRefused
|
||||
}
|
||||
var a Admission
|
||||
d := json.NewDecoder(bytes.NewReader(raw))
|
||||
d.DisallowUnknownFields()
|
||||
if d.Decode(&a) != nil {
|
||||
return nil, ErrRefused
|
||||
}
|
||||
if _, err = d.Token(); err != io.EOF {
|
||||
return nil, ErrRefused
|
||||
}
|
||||
return New(a)
|
||||
}
|
||||
|
||||
func New(a Admission) (*Client, error) {
|
||||
u, err := url.Parse(a.Endpoint)
|
||||
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Path != "/v1/check" || u.RawQuery != "" || u.Fragment != "" {
|
||||
return nil, ErrRefused
|
||||
}
|
||||
if !filepath.IsAbs(a.CAFile) || !filepath.IsAbs(a.TokenFile) || a.Evaluator == "" || a.PolicyPackage == "" || a.PolicyVersion == "" || a.CallerPrincipal == "" || a.CallerAudience == "" {
|
||||
return nil, ErrRefused
|
||||
}
|
||||
if !validDigest(a.PolicyDigest) || !validDigest(a.RegistryDigest) || len(a.Keys) == 0 {
|
||||
return nil, ErrRefused
|
||||
}
|
||||
ttl, err := time.ParseDuration(a.MaxTTL)
|
||||
if err != nil || ttl <= 0 {
|
||||
return nil, ErrRefused
|
||||
}
|
||||
skew, err := time.ParseDuration(a.MaxClockSkew)
|
||||
if err != nil || skew < 0 || skew > ttl {
|
||||
return nil, ErrRefused
|
||||
}
|
||||
ca, err := readFile(a.CAFile, false)
|
||||
if err != nil {
|
||||
return nil, ErrRefused
|
||||
}
|
||||
roots := x509.NewCertPool()
|
||||
if !roots.AppendCertsFromPEM(ca) {
|
||||
return nil, ErrRefused
|
||||
}
|
||||
keys := map[string]ed25519.PublicKey{}
|
||||
for kid, value := range a.Keys {
|
||||
b, e := base64.RawURLEncoding.Strict().DecodeString(value)
|
||||
if e != nil || kid == "" || len(b) != ed25519.PublicKeySize {
|
||||
return nil, ErrRefused
|
||||
}
|
||||
keys[kid] = append(ed25519.PublicKey(nil), b...)
|
||||
}
|
||||
transport := &http.Transport{Proxy: nil, TLSClientConfig: &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12}, DisableKeepAlives: true}
|
||||
client := &http.Client{Transport: transport, Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return ErrRefused }}
|
||||
return &Client{admission: a, keys: keys, http: client, maxTTL: ttl, skew: skew, now: time.Now}, nil
|
||||
}
|
||||
|
||||
func validDigest(value string) bool {
|
||||
if !strings.HasPrefix(value, "sha256:") || len(value) != 71 {
|
||||
return false
|
||||
}
|
||||
_, e := hex.DecodeString(value[7:])
|
||||
return e == nil && strings.ToLower(value) == value
|
||||
}
|
||||
|
||||
func readFile(path string, private bool) ([]byte, error) {
|
||||
if !filepath.IsAbs(path) {
|
||||
return nil, ErrRefused
|
||||
}
|
||||
f, e := os.Open(path)
|
||||
if e != nil {
|
||||
return nil, ErrRefused
|
||||
}
|
||||
defer f.Close()
|
||||
info, e := f.Stat()
|
||||
if e != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0022 != 0 {
|
||||
return nil, ErrRefused
|
||||
}
|
||||
if private && info.Mode().Perm()&0007 != 0 {
|
||||
return nil, ErrRefused
|
||||
}
|
||||
b, e := io.ReadAll(io.LimitReader(f, maxBytes+1))
|
||||
if e != nil || len(b) > maxBytes {
|
||||
return nil, ErrRefused
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// Check sends exactly one request with a cryptographically fresh nonce. The
|
||||
// returned envelope is the producer's authenticated typed interpretation.
|
||||
// Callers must execute only the request's exact operation and honor their own
|
||||
// execution boundary; a login decision is never a later resource decision.
|
||||
func (c *Client) Check(ctx context.Context, request api.CheckRequest) (api.DecisionEnvelope, error) {
|
||||
refuse := func() (api.DecisionEnvelope, error) { return api.DecisionEnvelope{}, ErrRefused }
|
||||
if c == nil || request.Purpose != "" || request.Tenant == "" || request.Subject.ID == "" || request.Subject.Type == "" || request.Resource.ID == "" || request.Resource.Type == "" || request.Resource.System == "" || request.Action == "" {
|
||||
return refuse()
|
||||
}
|
||||
if request.Subject.Tenant != request.Tenant || request.Resource.Tenant != request.Tenant {
|
||||
return refuse()
|
||||
}
|
||||
if request.PolicyVersion != "" && request.PolicyVersion != c.admission.PolicyVersion {
|
||||
return refuse()
|
||||
}
|
||||
// Use the producer's typed representation and preserve numeric lexemes. A
|
||||
// producer that rounds before hashing fails this join rather than weakening it.
|
||||
raw, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return refuse()
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
if decoder.Decode(&request) != nil {
|
||||
return refuse()
|
||||
}
|
||||
if request.Context == nil {
|
||||
request.Context = map[string]any{}
|
||||
}
|
||||
if _, exists := request.Context[nonceKey]; exists {
|
||||
return refuse()
|
||||
}
|
||||
nonce := make([]byte, 32)
|
||||
if _, err = rand.Read(nonce); err != nil {
|
||||
return refuse()
|
||||
}
|
||||
request.Context[nonceKey] = hex.EncodeToString(nonce)
|
||||
request.Context["decisionpep_json_profile"] = JSONProfile
|
||||
request.ID = hex.EncodeToString(nonce)
|
||||
request.PolicyVersion = c.admission.PolicyVersion
|
||||
raw, err = json.Marshal(request)
|
||||
if err != nil || len(raw) > maxBytes {
|
||||
return refuse()
|
||||
}
|
||||
token, err := readFile(c.admission.TokenFile, true)
|
||||
if err != nil {
|
||||
return refuse()
|
||||
}
|
||||
value := strings.TrimSpace(string(token))
|
||||
if value == "" || strings.ContainsAny(value, " \t\r\n") {
|
||||
return refuse()
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.admission.Endpoint, bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return refuse()
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Access-Engine-JSON-Profile", JSONProfile)
|
||||
req.Header.Set("Authorization", "Bearer "+value)
|
||||
started := c.now().UTC()
|
||||
response, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return refuse()
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return refuse()
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, maxBytes+1))
|
||||
if err != nil || len(body) > maxBytes {
|
||||
return refuse()
|
||||
}
|
||||
envelope, err := sign.DecodeEnvelope(body)
|
||||
if err != nil || sign.Verify(envelope, c.keys) != nil {
|
||||
return refuse()
|
||||
}
|
||||
if c.validate(envelope, request, started, c.now().UTC()) != nil {
|
||||
return refuse()
|
||||
}
|
||||
return envelope, nil
|
||||
}
|
||||
|
||||
func (c *Client) validate(e api.DecisionEnvelope, r api.CheckRequest, started, now time.Time) error {
|
||||
p := e.Provenance
|
||||
b := e.Binding
|
||||
l := e.Lifetime
|
||||
if e.ID == "" || e.ContractVersion != api.DecisionRecordContractV1 || e.Effect != api.DecisionEffectAllow || e.RequestID != r.ID || len(e.Obligations) != 0 || b == nil || l == nil {
|
||||
return ErrRefused
|
||||
}
|
||||
if p.Evaluator != c.admission.Evaluator || p.Mode != provenanceMode || p.PolicyPackage != c.admission.PolicyPackage || p.PolicyVersion != c.admission.PolicyVersion || p.PolicyPackageDigest != c.admission.PolicyDigest || p.RegistrySnapshotDigest != c.admission.RegistryDigest || e.MatchedPolicyVersion != p.PolicyVersion {
|
||||
return ErrRefused
|
||||
}
|
||||
if b.Purpose != "" || b.SubmittedRequestDigest != api.RequestDigest(r) || b.Tenant != r.Tenant || b.Action != r.Action || api.CanonicalDigest(b.Context) != api.CanonicalDigest(r.Context) {
|
||||
return ErrRefused
|
||||
}
|
||||
if !sameSubject(b.Subject, r.Subject) || !sameResource(b.Resource, r.Resource) || !sameSubject(e.Subject, b.Subject) || !sameResource(e.Resource, b.Resource) {
|
||||
return ErrRefused
|
||||
}
|
||||
evaluated := api.CheckRequest{Tenant: b.Tenant, Subject: b.Subject, Action: b.Action, Resource: b.Resource, Context: b.Context, Purpose: b.Purpose}
|
||||
if b.RequestDigest != api.RequestDigest(evaluated) {
|
||||
return ErrRefused
|
||||
}
|
||||
for k, v := range api.InputClaimDigests(r) {
|
||||
if p.InputClaimDigests[k] != v {
|
||||
return ErrRefused
|
||||
}
|
||||
}
|
||||
caller := p.Caller
|
||||
if caller == nil || caller.Mode != "enforce" || caller.Principal != c.admission.CallerPrincipal || caller.Audience != c.admission.CallerAudience {
|
||||
return ErrRefused
|
||||
}
|
||||
callerEnd, err := time.Parse(time.RFC3339Nano, caller.NotAfter)
|
||||
if err != nil || !now.Before(callerEnd) {
|
||||
return ErrRefused
|
||||
}
|
||||
decision, err := time.Parse(time.RFC3339Nano, p.DecisionTime)
|
||||
if err != nil || decision.Before(started.Add(-c.skew)) || decision.After(now.Add(c.skew)) {
|
||||
return ErrRefused
|
||||
}
|
||||
begin, err := time.Parse(time.RFC3339Nano, l.NotBefore)
|
||||
if err != nil || begin.After(now) {
|
||||
return ErrRefused
|
||||
}
|
||||
end, err := time.Parse(time.RFC3339Nano, l.ExpiresAt)
|
||||
if err != nil || !now.Before(end) || !end.After(begin) || end.Sub(begin) > c.maxTTL || end.After(callerEnd) {
|
||||
return ErrRefused
|
||||
}
|
||||
ttl, err := time.ParseDuration(l.TTL)
|
||||
if err != nil || l.Kind != api.DecisionLifetimeTTL || ttl <= 0 || ttl > c.maxTTL {
|
||||
return ErrRefused
|
||||
}
|
||||
// Producer CompleteDecision uses its exact decision instant for both bounds.
|
||||
// RFC3339 output can truncate subsecond precision, so do not invent lost time.
|
||||
if !begin.Equal(decision) || end.Sub(begin) != ttl {
|
||||
return ErrRefused
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sameSubject(a, b api.SubjectRef) bool {
|
||||
return a.ID == b.ID && a.Type == b.Type && a.Tenant == b.Tenant
|
||||
}
|
||||
func sameResource(a, b api.ResourceRef) bool {
|
||||
return a.ID == b.ID && a.Type == b.Type && a.System == b.System && a.Tenant == b.Tenant
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue