Add producer-owned authenticated Go decision client and validated Forgejo profile
All checks were successful
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) Successful in 1m22s

Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a14bfc-9643-7872-aa12-98326dcf25e7
This commit is contained in:
tegwick 2026-10-18 01:20:05 +02:00
parent d328a26010
commit ca0cb75729
24 changed files with 2980 additions and 4 deletions

296
pkg/decisionpep/client.go Normal file
View 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
}

View file

@ -0,0 +1,211 @@
package decisionpep
import (
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/json"
"encoding/pem"
"io"
"math/big"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/netkingdom/access-engine/internal/sign"
"github.com/netkingdom/access-engine/pkg/api"
)
type transportFunc func(*http.Request) (*http.Response, error)
func (f transportFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func testClient(t *testing.T) (*Client, ed25519.PrivateKey, time.Time) {
t.Helper()
pub, key, e := ed25519.GenerateKey(rand.Reader)
if e != nil {
t.Fatal(e)
}
now := time.Date(2026, 10, 18, 1, 2, 3, 0, time.UTC)
dir := t.TempDir()
cert, e := x509.CreateCertificate(rand.Reader, &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test-only"}, NotBefore: now.Add(-time.Hour), NotAfter: now.Add(time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign}, &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test-only"}, NotBefore: now.Add(-time.Hour), NotAfter: now.Add(time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign}, pub, key)
if e != nil {
t.Fatal(e)
}
ca := filepath.Join(dir, "ca.pem")
token := filepath.Join(dir, "token")
if e = os.WriteFile(ca, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert}), 0600); e != nil {
t.Fatal(e)
}
if e = os.WriteFile(token, []byte("synthetic-caller-token"), 0600); e != nil {
t.Fatal(e)
}
a := Admission{Endpoint: "https://pdp.invalid/v1/check", CAFile: ca, TokenFile: token, Keys: map[string]string{"test-key": base64.RawURLEncoding.EncodeToString(pub)}, Evaluator: "flex-auth/local", PolicyPackage: "forgejo.application-access", PolicyVersion: "v1", PolicyDigest: "sha256:" + strings.Repeat("1", 64), RegistryDigest: "sha256:" + strings.Repeat("2", 64), CallerPrincipal: "system:serviceaccount:test:forgejo", CallerAudience: "access-engine", MaxTTL: "30s", MaxClockSkew: "1s"}
c, e := New(a)
if e != nil {
t.Fatal(e)
}
c.now = func() time.Time { return now }
return c, key, now
}
func testRequest() api.CheckRequest {
return api.CheckRequest{Tenant: "tenant:test", Subject: api.SubjectRef{ID: "subject:test", Type: api.SubjectType("Human"), Tenant: "tenant:test", Attributes: map[string]any{"groups": []string{"engineers"}}}, Action: "application.access", Resource: api.ResourceRef{ID: "application:forgejo", Type: "application", System: "forgejo", Tenant: "tenant:test"}, Context: map[string]any{"big": json.Number("9007199254740993"), "fraction": json.Number("0.123456789123456789")}}
}
func envelope(c *Client, r api.CheckRequest, key ed25519.PrivateKey, now time.Time) api.DecisionEnvelope {
e := api.DecisionEnvelope{ID: "test-decision", RequestID: r.ID, Effect: api.DecisionEffectAllow, MatchedPolicyVersion: c.admission.PolicyVersion, Subject: r.Subject, Resource: r.Resource, Binding: api.NewDecisionBindingFor(r, r), Provenance: api.DecisionProvenance{Evaluator: c.admission.Evaluator, Mode: provenanceMode, PolicyPackage: c.admission.PolicyPackage, PolicyVersion: c.admission.PolicyVersion, PolicyPackageDigest: c.admission.PolicyDigest, RegistrySnapshotDigest: c.admission.RegistryDigest, Caller: &api.CallerProvenance{Mode: "enforce", Principal: c.admission.CallerPrincipal, Audience: c.admission.CallerAudience, NotAfter: now.Add(time.Minute).Format(time.RFC3339Nano)}}}
api.CompleteDecision(&e, r, api.DecisionCompletion{AllowTTL: "10s", Now: now})
if sign.Sign(&e, key, "test-key") != nil {
panic("test sign")
}
return e
}
func respond(t *testing.T, c *Client, key ed25519.PrivateKey, now time.Time, mutate func(*api.DecisionEnvelope), resign bool) {
t.Helper()
c.http = &http.Client{Transport: transportFunc(func(req *http.Request) (*http.Response, error) {
if req.Header.Get("Access-Engine-JSON-Profile") != JSONProfile {
t.Fatal("missing lossless profile")
}
if req.Header.Get("Authorization") != "Bearer synthetic-caller-token" {
t.Fatal("wrong synthetic caller token")
}
var r api.CheckRequest
d := json.NewDecoder(req.Body)
d.UseNumber()
if d.Decode(&r) != nil {
t.Fatal("request decode")
}
if r.Context["big"] != json.Number("9007199254740993") || r.Context["fraction"] != json.Number("0.123456789123456789") {
t.Fatal("request numeric precision lost")
}
if len(r.Context[nonceKey].(string)) != 64 || r.ID != r.Context[nonceKey] {
t.Fatal("nonce")
}
e := envelope(c, r, key, now)
if mutate != nil {
mutate(&e)
}
if resign {
if sign.Sign(&e, key, "test-key") != nil {
t.Fatal("sign")
}
}
b, err := json.Marshal(e)
if err != nil {
t.Fatal(err)
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(string(b))), Header: http.Header{}}, nil
})}
}
func TestAuthenticDecisionProducerCanonicalPrecision(t *testing.T) {
c, key, now := testClient(t)
respond(t, c, key, now, nil, false)
e, err := c.Check(context.Background(), testRequest())
if err != nil {
t.Fatal(err)
}
if e.Binding.Context["big"] != json.Number("9007199254740993") || e.Binding.Context["fraction"] != json.Number("0.123456789123456789") {
t.Fatal("signed typed precision lost")
}
if sign.Verify(e, c.keys) != nil {
t.Fatal("returned envelope not authentic producer bytes")
}
}
func TestDecisionAdverseBindings(t *testing.T) {
cases := map[string]func(*api.DecisionEnvelope){
"unsigned": func(e *api.DecisionEnvelope) { e.Signature = api.UnsignedEnvelopeSignature() },
"effect": func(e *api.DecisionEnvelope) { e.Effect = api.DecisionEffectDeny },
"evaluator": func(e *api.DecisionEnvelope) { e.Provenance.Evaluator = "impostor" },
"policy": func(e *api.DecisionEnvelope) { e.Provenance.PolicyPackage = "other" },
"version": func(e *api.DecisionEnvelope) { e.Provenance.PolicyVersion = "v2" },
"policy-digest": func(e *api.DecisionEnvelope) { e.Provenance.PolicyPackageDigest = "sha256:" + strings.Repeat("3", 64) },
"registry": func(e *api.DecisionEnvelope) {
e.Provenance.RegistrySnapshotDigest = "sha256:" + strings.Repeat("4", 64)
},
"tenant": func(e *api.DecisionEnvelope) { e.Binding.Tenant = "other" },
"actor": func(e *api.DecisionEnvelope) { e.Binding.Subject.ID = "other" },
"action": func(e *api.DecisionEnvelope) { e.Binding.Action = "write" },
"resource": func(e *api.DecisionEnvelope) { e.Binding.Resource.ID = "other" },
"purpose": func(e *api.DecisionEnvelope) { e.Binding.Purpose = "readback" },
"submitted": func(e *api.DecisionEnvelope) { e.Binding.SubmittedRequestDigest = "wrong" },
"context": func(e *api.DecisionEnvelope) { e.Binding.Context["fraction"] = json.Number("0.123456789123456788") },
"legacy-numeric-profile": func(e *api.DecisionEnvelope) { e.Provenance.Mode = "standalone" },
"caller-warn": func(e *api.DecisionEnvelope) { e.Provenance.Caller.Mode = "warn" },
"caller-principal": func(e *api.DecisionEnvelope) { e.Provenance.Caller.Principal = "other" },
"caller-audience": func(e *api.DecisionEnvelope) { e.Provenance.Caller.Audience = "other" },
"caller-expired": func(e *api.DecisionEnvelope) { e.Provenance.Caller.NotAfter = "2020-01-01T00:00:00Z" },
"stale-decision": func(e *api.DecisionEnvelope) { e.Provenance.DecisionTime = "2020-01-01T00:00:00Z" },
"expired": func(e *api.DecisionEnvelope) { e.Lifetime.ExpiresAt = "2020-01-01T00:00:00Z" },
"future-begin": func(e *api.DecisionEnvelope) { e.Lifetime.NotBefore = "2030-01-01T00:00:00Z" },
"subnanosecond-expiry": func(e *api.DecisionEnvelope) { e.Lifetime.ExpiresAt = "2026-10-18T01:02:02.999999999Z" },
"missing-TTL": func(e *api.DecisionEnvelope) { e.Lifetime = nil },
"excess-TTL": func(e *api.DecisionEnvelope) { e.Lifetime.TTL = "1h" },
"obligation": func(e *api.DecisionEnvelope) { e.Obligations = []api.Obligation{{Type: "unimplemented"}} },
}
for name, mutation := range cases {
t.Run(name, func(t *testing.T) {
c, key, now := testClient(t)
respond(t, c, key, now, mutation, name != "unsigned")
if _, err := c.Check(context.Background(), testRequest()); err == nil {
t.Fatal("accepted wrong signed binding")
}
})
}
}
func TestSignatureForgeryAndReplay(t *testing.T) {
t.Run("forged-payload", func(t *testing.T) {
c, key, now := testClient(t)
respond(t, c, key, now, func(e *api.DecisionEnvelope) { e.Reason = "changed after signature" }, false)
if _, e := c.Check(context.Background(), testRequest()); e == nil {
t.Fatal("forgery accepted")
}
})
t.Run("wrong-key", func(t *testing.T) {
c, _, now := testClient(t)
_, key, _ := ed25519.GenerateKey(rand.Reader)
respond(t, c, key, now, nil, false)
if _, e := c.Check(context.Background(), testRequest()); e == nil {
t.Fatal("wrong key accepted")
}
})
t.Run("replay", func(t *testing.T) {
c, key, now := testClient(t)
var saved []byte
c.http = &http.Client{Transport: transportFunc(func(req *http.Request) (*http.Response, error) {
if saved == nil {
var r api.CheckRequest
d := json.NewDecoder(req.Body)
d.UseNumber()
if d.Decode(&r) != nil {
t.Fatal("decode")
}
e := envelope(c, r, key, now)
saved, _ = json.Marshal(e)
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(string(saved)))}, nil
})}
if _, e := c.Check(context.Background(), testRequest()); e != nil {
t.Fatal(e)
}
if _, e := c.Check(context.Background(), testRequest()); e == nil {
t.Fatal("old decision replay accepted for new nonce")
}
})
}
func TestUnavailableAndPublicTokenFileRefuse(t *testing.T) {
c, _, _ := testClient(t)
c.http = &http.Client{Transport: transportFunc(func(*http.Request) (*http.Response, error) { return nil, io.ErrUnexpectedEOF })}
if _, e := c.Check(context.Background(), testRequest()); e == nil {
t.Fatal("unavailable allowed")
}
if e := os.Chmod(c.admission.TokenFile, 0644); e != nil {
t.Fatal(e)
}
if _, e := c.Check(context.Background(), testRequest()); e == nil {
t.Fatal("public token file allowed")
}
}

View file

@ -0,0 +1,15 @@
package decisionpep
import (
"os/exec"
"path/filepath"
"testing"
)
// The distribution is a generated source slice, never a second implementation.
func TestConsumerExportMatchesProducerSource(t *testing.T) {
command := exec.Command("python3", filepath.Join("..", "..", "tools", "export_decisionpep_adapter.py"), "--check")
if output, err := command.CombinedOutput(); err != nil {
t.Fatalf("consumer export drift: %v\n%s", err, output)
}
}