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
175
cmd/access-engine/forgejo_application_profile_test.go
Normal file
175
cmd/access-engine/forgejo_application_profile_test.go
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/netkingdom/access-engine/internal/callerauth"
|
||||
"github.com/netkingdom/access-engine/internal/decision"
|
||||
"github.com/netkingdom/access-engine/internal/policy"
|
||||
"github.com/netkingdom/access-engine/internal/registry"
|
||||
"github.com/netkingdom/access-engine/internal/sign"
|
||||
"github.com/netkingdom/access-engine/pkg/api"
|
||||
"github.com/netkingdom/access-engine/pkg/decisionpep"
|
||||
)
|
||||
|
||||
type forgejoSyntheticReviewer struct{}
|
||||
|
||||
func (forgejoSyntheticReviewer) Review(_ context.Context, token string) (callerauth.Identity, error) {
|
||||
if token != "synthetic-forgejo-caller" {
|
||||
return callerauth.Identity{}, callerauth.ErrUnauthenticated
|
||||
}
|
||||
return callerauth.Identity{Username: "system:serviceaccount:synthetic:forgejo", Audiences: []string{"access-engine"}, NotAfter: time.Now().Add(time.Minute)}, nil
|
||||
}
|
||||
func forgejoRequest(group string) api.CheckRequest {
|
||||
groups := []string{group}
|
||||
return api.CheckRequest{Tenant: "tenant:synthetic", Subject: api.SubjectRef{ID: "user:synthetic", Type: "human", Tenant: "tenant:synthetic", Attributes: map[string]any{"issuer": "https://issuer.invalid", "assurance": "aal2", "current_userinfo_groups": groups}}, Action: "application.access", Resource: api.ResourceRef{ID: "application:forgejo", Type: "application", System: "forgejo", Tenant: "tenant:synthetic"}, Context: map[string]any{"identity_tenant": "tenant:synthetic", "identity_subject": "user:synthetic", "identity_principal_type": "human", "identity_issuer": "https://issuer.invalid", "identity_assurance": "aal2", "current_userinfo_groups": groups, "required_groups_any_of": []string{"forge-admins", "forge-coulomb-owners", "forge-coulomb-readers"}, "big": json.Number("9007199254740993"), "fraction": json.Number("0.123456789123456789")}}
|
||||
}
|
||||
|
||||
func TestForgejoActualPolicySignedLosslessAndLegacy(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
pkg, e := policy.LoadAndValidateFile(ctx, filepath.Join("..", "..", "examples", "forgejo-application-access", "policy.md"))
|
||||
if e != nil || !pkg.Valid {
|
||||
t.Fatalf("policy: %v %+v", e, pkg)
|
||||
}
|
||||
store, e := registry.LoadFilePreservingNumbers(filepath.Join("..", "..", "examples", "forgejo-application-access", "registry_snapshot.json"))
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
engine, e := decision.NewEngine(store, pkg)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
pub, key, e := ed25519.GenerateKey(rand.Reader)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if e = engine.SetEnvelopeSigningKey(key, "synthetic-pdp"); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
auth, e := callerauth.New(callerauth.ModeEnforce, forgejoSyntheticReviewer{}, "access-engine", map[string]string{"forgejo": "system:serviceaccount:synthetic:forgejo"}, nil)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
server := httptest.NewTLSServer(newServeMuxWithCallerAuth(engine, auth))
|
||||
defer server.Close()
|
||||
folder := t.TempDir()
|
||||
ca := filepath.Join(folder, "ca.pem")
|
||||
token := filepath.Join(folder, "token")
|
||||
os.WriteFile(ca, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}), 0600)
|
||||
os.WriteFile(token, []byte("synthetic-forgejo-caller"), 0600)
|
||||
admitted := decisionpep.Admission{Endpoint: server.URL + "/v1/check", CAFile: ca, TokenFile: token, Keys: map[string]string{"synthetic-pdp": base64.RawURLEncoding.EncodeToString(pub)}, Evaluator: "flex-auth/local", PolicyPackage: pkg.Metadata.ID, PolicyVersion: pkg.Metadata.Version, PolicyDigest: pkg.Digest(), RegistryDigest: store.Digest(), CallerPrincipal: "system:serviceaccount:synthetic:forgejo", CallerAudience: "access-engine", MaxTTL: "30s", MaxClockSkew: "2s"}
|
||||
client, e := decisionpep.New(admitted)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
for _, g := range []string{"forge-admins", "forge-coulomb-owners", "forge-coulomb-readers"} {
|
||||
t.Run(g, func(t *testing.T) {
|
||||
got, e := client.Check(ctx, forgejoRequest(g))
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if got.Binding.Context["big"] != json.Number("9007199254740993") || got.Binding.Context["fraction"] != json.Number("0.123456789123456789") {
|
||||
t.Fatal("actual producer numeric precision lost")
|
||||
}
|
||||
if got.Provenance.Mode != "standalone/lossless-json-v1" || sign.Verify(got, map[string]ed25519.PublicKey{"synthetic-pdp": pub}) != nil {
|
||||
t.Fatal("actual producer profile not authenticated")
|
||||
}
|
||||
})
|
||||
}
|
||||
for name, mutate := range map[string]func(*api.CheckRequest){"object-current-groups": func(r *api.CheckRequest) {
|
||||
r.Context["current_userinfo_groups"] = map[string]any{"key": "forge-admins"}
|
||||
}, "object-required-groups": func(r *api.CheckRequest) {
|
||||
r.Context["required_groups_any_of"] = map[string]any{"a": "forge-admins", "b": "forge-coulomb-owners", "c": "forge-coulomb-readers"}
|
||||
}, "outsider": func(r *api.CheckRequest) {
|
||||
r.Context["current_userinfo_groups"] = []string{"outsider"}
|
||||
r.Subject.Attributes["current_userinfo_groups"] = []string{"outsider"}
|
||||
}, "empty-current-groups": func(r *api.CheckRequest) {
|
||||
r.Context["current_userinfo_groups"] = []string{}
|
||||
r.Subject.Attributes["current_userinfo_groups"] = []string{}
|
||||
}, "wrong-tenant": func(r *api.CheckRequest) {
|
||||
r.Tenant = "tenant:other"
|
||||
r.Subject.Tenant = r.Tenant
|
||||
r.Resource.Tenant = r.Tenant
|
||||
r.Context["identity_tenant"] = r.Tenant
|
||||
}, "wrong-issuer": func(r *api.CheckRequest) {
|
||||
r.Subject.Attributes["issuer"] = "https://other.invalid"
|
||||
r.Context["identity_issuer"] = "https://other.invalid"
|
||||
}, "forged-resource-issuer": func(r *api.CheckRequest) {
|
||||
r.Context["identity_issuer"] = "https://other.invalid"
|
||||
r.Resource.Attributes = map[string]any{"identity_issuer": "https://other.invalid", "tenant": "tenant:synthetic"}
|
||||
}, "wrong-assurance": func(r *api.CheckRequest) {
|
||||
r.Subject.Attributes["assurance"] = "aal1"
|
||||
r.Context["identity_assurance"] = "aal1"
|
||||
}, "wrong-action": func(r *api.CheckRequest) { r.Action = "source.operation" }, "wrong-resource": func(r *api.CheckRequest) { r.Resource.ID = "application:unregistered" }, "claim-shortcut": func(r *api.CheckRequest) {
|
||||
r.Context["required_groups_any_of"] = []string{"outsider"}
|
||||
r.Context["current_userinfo_groups"] = []string{"outsider"}
|
||||
r.Subject.Attributes["current_userinfo_groups"] = []string{"outsider"}
|
||||
}} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
r := forgejoRequest("forge-admins")
|
||||
mutate(&r)
|
||||
if _, e := client.Check(ctx, r); e == nil {
|
||||
t.Fatal("actual PDP allowed adverse input")
|
||||
}
|
||||
})
|
||||
}
|
||||
// A caller-provided JSON key cannot install the producer's private Go context
|
||||
// key. This path uses the actual normal handler, not a constructed envelope.
|
||||
r := forgejoRequest("forge-admins")
|
||||
r.Context["decisionpep_request_nonce"] = strings.Repeat("0", 64)
|
||||
r.Context["decisionpep_json_profile"] = decisionpep.JSONProfile
|
||||
r.Context["jsonProfileContextKey"] = decisionpep.JSONProfile
|
||||
raw, _ := json.Marshal(r)
|
||||
for _, profile := range []string{"", decisionpep.JSONProfile, "unknown"} {
|
||||
req, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/check", bytes.NewReader(raw))
|
||||
req.Header.Set("Authorization", "Bearer synthetic-forgejo-caller")
|
||||
if profile != "" {
|
||||
req.Header.Set("Access-Engine-JSON-Profile", profile)
|
||||
}
|
||||
response, e := server.Client().Do(req)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
response.Body.Close()
|
||||
if profile == "unknown" {
|
||||
if response.StatusCode != 400 {
|
||||
t.Fatal("unknown profile not refused")
|
||||
}
|
||||
continue
|
||||
}
|
||||
e1, e := sign.DecodeEnvelope(body)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if sign.Verify(e1, map[string]ed25519.PublicKey{"synthetic-pdp": pub}) != nil {
|
||||
t.Fatal("producer signature")
|
||||
}
|
||||
if profile == "" {
|
||||
if e1.Provenance.Mode != "standalone" {
|
||||
t.Fatal("caller JSON spoofed producer profile")
|
||||
}
|
||||
if e1.Binding.SubmittedRequestDigest == api.RequestDigest(r) {
|
||||
t.Fatal("legacy numeric projection unexpectedly changed")
|
||||
}
|
||||
} else {
|
||||
if e1.Provenance.Mode != "standalone/lossless-json-v1" || e1.Binding.SubmittedRequestDigest != api.RequestDigest(r) {
|
||||
t.Fatal("lossless submitted binding mismatch")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -512,8 +512,17 @@ func newServeMuxWithTaskFacts(engine *decisioncore.Engine, authenticator *caller
|
|||
writeHTTP(w, decision, err)
|
||||
return
|
||||
}
|
||||
profile := r.Header.Get("Access-Engine-JSON-Profile")
|
||||
if profile != "" && profile != decisioncore.LosslessJSONProfile {
|
||||
http.Error(w, "unsupported JSON numeric profile", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var request api.CheckRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
if profile == decisioncore.LosslessJSONProfile {
|
||||
decoder.UseNumber()
|
||||
}
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
|
@ -528,7 +537,11 @@ func newServeMuxWithTaskFacts(engine *decisioncore.Engine, authenticator *caller
|
|||
writeCallerAuthError(w, err)
|
||||
return
|
||||
}
|
||||
decision, err := engine.Check(callerauth.WithRecord(r.Context(), authenticator.Record(identity)), request)
|
||||
decisionContext := callerauth.WithRecord(r.Context(), authenticator.Record(identity))
|
||||
if profile == decisioncore.LosslessJSONProfile {
|
||||
decisionContext = decisioncore.WithLosslessJSONProfile(decisionContext)
|
||||
}
|
||||
decision, err := engine.Check(decisionContext, request)
|
||||
writeHTTP(w, decision, err)
|
||||
})
|
||||
mux.HandleFunc("/v1/batch_check", func(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue