Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a14bfc-9643-7872-aa12-98326dcf25e7
211 lines
10 KiB
Go
211 lines
10 KiB
Go
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")
|
|
}
|
|
}
|