Skip to content

Commit 4624480

Browse files
author
bussyjd
committed
Merge PR #556: fix(x402): inject agent upstream auth
2 parents 81b4339 + f4bd11f commit 4624480

4 files changed

Lines changed: 189 additions & 19 deletions

File tree

internal/embed/embed_crd_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -741,6 +741,56 @@ func bindingHasSubject(doc map[string]any, name, namespace string) bool {
741741
return false
742742
}
743743

744+
func TestX402VerifierRBAC_CanReadAgentAPISecrets(t *testing.T) {
745+
data, err := ReadInfrastructureFile("base/templates/x402.yaml")
746+
if err != nil {
747+
t.Fatalf("ReadInfrastructureFile: %v", err)
748+
}
749+
docs := multiDoc(data)
750+
751+
role := findDocByName(docs, "ClusterRole", "x402-verifier")
752+
if role == nil {
753+
t.Fatal("no ClusterRole 'x402-verifier' found")
754+
}
755+
rules, ok := role["rules"].([]any)
756+
if !ok {
757+
t.Fatal("x402-verifier ClusterRole has no rules")
758+
}
759+
760+
for _, r := range rules {
761+
rm := r.(map[string]any)
762+
if !stringSet(rm["apiGroups"])[""] || !stringSet(rm["resources"])["secrets"] {
763+
continue
764+
}
765+
verbs := stringSet(rm["verbs"])
766+
if !verbs["get"] || !verbs["list"] || !verbs["watch"] {
767+
continue
768+
}
769+
names := stringSet(rm["resourceNames"])
770+
if !names["litellm-secrets"] {
771+
t.Fatal("x402-verifier secret rule lost litellm-secrets")
772+
}
773+
if !names["hermes-api-server"] {
774+
t.Fatal("x402-verifier secret rule must include hermes-api-server for agent upstream auth")
775+
}
776+
return
777+
}
778+
779+
t.Fatal("x402-verifier ClusterRole missing scoped secret get/list/watch rule")
780+
}
781+
782+
func TestX402VerifierImage_CarriesAgentAuthFix(t *testing.T) {
783+
data, err := ReadInfrastructureFile("base/templates/x402.yaml")
784+
if err != nil {
785+
t.Fatalf("ReadInfrastructureFile: %v", err)
786+
}
787+
788+
const ref = "ghcr.io/obolnetwork/x402-verifier:46e63fd@sha256:a8cd7946884c9a702b5cfcfad28d1f5eac1037899303eb4e0157e3ffab7a572c"
789+
if !strings.Contains(string(data), "image: "+ref) {
790+
t.Fatalf("x402-verifier image must carry agent upstream auth fix: %s", ref)
791+
}
792+
}
793+
744794
func TestAgentRBAC_NoOverlyBroadPermissions(t *testing.T) {
745795
data, err := ReadInfrastructureFile("base/templates/obol-agent-monetize-rbac.yaml")
746796
if err != nil {

internal/embed/infrastructure/base/templates/x402.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ rules:
8383
verbs: ["get", "list", "watch"]
8484
- apiGroups: [""]
8585
resources: ["secrets"]
86-
resourceNames: ["litellm-secrets"]
86+
resourceNames: ["litellm-secrets", "hermes-api-server"]
8787
verbs: ["get", "list", "watch"]
8888

8989
---
@@ -234,7 +234,7 @@ spec:
234234
type: RuntimeDefault
235235
containers:
236236
- name: verifier
237-
image: ghcr.io/obolnetwork/x402-verifier:b13254e@sha256:a8a7aa0ca4c35b0ddf6983fa6e3e5f8a3f64e44d8e506ebfd55e39de2bc0342d
237+
image: ghcr.io/obolnetwork/x402-verifier:46e63fd@sha256:a8cd7946884c9a702b5cfcfad28d1f5eac1037899303eb4e0157e3ffab7a572c
238238
imagePullPolicy: IfNotPresent
239239
# PSS Restricted: per-container hardening. Verifier is a Go binary
240240
# reading two RO ConfigMaps; no writeable rootfs paths required.

internal/x402/serviceoffer_source.go

Lines changed: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import (
2121
"k8s.io/client-go/tools/cache"
2222
)
2323

24-
// WatchServiceOffers runs the ServiceOffer + litellm-secrets informers and
24+
// WatchServiceOffers runs the ServiceOffer + upstream-auth Secret informers and
2525
// pushes rendered RouteRules to apply on every change. The optional
2626
// onFirstApply callback is invoked exactly once after the post-cache-sync
2727
// refresh succeeds; it is the signal that the route source has produced its
@@ -33,14 +33,20 @@ func WatchServiceOffers(ctx context.Context, cfg *rest.Config, apply func([]Rout
3333
}
3434

3535
offerFactory := dynamicinformer.NewFilteredDynamicSharedInformerFactory(client, 0, metav1.NamespaceAll, nil)
36-
secretFactory := dynamicinformer.NewFilteredDynamicSharedInformerFactory(client, 0, metav1.NamespaceAll, func(options *metav1.ListOptions) {
36+
litellmSecretFactory := dynamicinformer.NewFilteredDynamicSharedInformerFactory(client, 0, metav1.NamespaceAll, func(options *metav1.ListOptions) {
3737
options.FieldSelector = fields.OneTermEqualSelector("metadata.name", "litellm-secrets").String()
3838
})
39+
hermesSecretFactory := dynamicinformer.NewFilteredDynamicSharedInformerFactory(client, 0, metav1.NamespaceAll, func(options *metav1.ListOptions) {
40+
options.FieldSelector = fields.OneTermEqualSelector("metadata.name", "hermes-api-server").String()
41+
})
3942
offers := offerFactory.ForResource(monetizeapi.ServiceOfferGVR).Informer()
40-
secrets := secretFactory.ForResource(monetizeapi.SecretGVR).Informer()
43+
litellmSecrets := litellmSecretFactory.ForResource(monetizeapi.SecretGVR).Informer()
44+
hermesSecrets := hermesSecretFactory.ForResource(monetizeapi.SecretGVR).Informer()
4145

4246
refresh := func() (ok bool) {
43-
routes, err := routesFromStore(offers.GetStore().List(), secrets.GetStore().List())
47+
secretItems := append([]any{}, litellmSecrets.GetStore().List()...)
48+
secretItems = append(secretItems, hermesSecrets.GetStore().List()...)
49+
routes, err := routesFromStore(offers.GetStore().List(), secretItems)
4450
if err != nil {
4551
log.Printf("x402-serviceoffer-source: render routes: %v", err)
4652
return false
@@ -59,11 +65,13 @@ func WatchServiceOffers(ctx context.Context, cfg *rest.Config, apply func([]Rout
5965
DeleteFunc: func(any) { refresh() },
6066
}
6167
offers.AddEventHandler(handler)
62-
secrets.AddEventHandler(handler)
68+
litellmSecrets.AddEventHandler(handler)
69+
hermesSecrets.AddEventHandler(handler)
6370

6471
go offers.Run(ctx.Done())
65-
go secrets.Run(ctx.Done())
66-
if !cache.WaitForCacheSync(ctx.Done(), offers.HasSynced, secrets.HasSynced) {
72+
go litellmSecrets.Run(ctx.Done())
73+
go hermesSecrets.Run(ctx.Done())
74+
if !cache.WaitForCacheSync(ctx.Done(), offers.HasSynced, litellmSecrets.HasSynced, hermesSecrets.HasSynced) {
6775
return fmt.Errorf("wait for serviceoffer informer sync")
6876
}
6977

@@ -75,7 +83,7 @@ func WatchServiceOffers(ctx context.Context, cfg *rest.Config, apply func([]Rout
7583
}
7684

7785
func routesFromStore(offerItems, secretItems []any) ([]RouteRule, error) {
78-
upstreamAuthByNamespace, err := upstreamAuthByNamespace(secretItems)
86+
litellmAuthByNamespace, hermesAuthByNamespace, err := upstreamAuthByNamespace(secretItems)
7987
if err != nil {
8088
return nil, err
8189
}
@@ -103,7 +111,11 @@ func routesFromStore(offerItems, secretItems []any) ([]RouteRule, error) {
103111
continue
104112
}
105113

106-
rule, err := routeRuleFromOffer(&offer, upstreamAuthByNamespace[offer.EffectiveNamespace()])
114+
upstreamAuth := litellmAuthByNamespace[offer.EffectiveNamespace()]
115+
if offer.IsAgent() {
116+
upstreamAuth = hermesAuthByNamespace[offer.Spec.Agent.Ref.Namespace]
117+
}
118+
rule, err := routeRuleFromOffer(&offer, upstreamAuth)
107119
if err != nil {
108120
return nil, err
109121
}
@@ -194,36 +206,54 @@ func effectivePrice(offer *monetizeapi.ServiceOffer) (price, priceModel, perMTok
194206
}
195207
}
196208

197-
func upstreamAuthByNamespace(items []any) (map[string]string, error) {
198-
result := make(map[string]string)
209+
func upstreamAuthByNamespace(items []any) (map[string]string, map[string]string, error) {
210+
litellmAuth := make(map[string]string)
211+
hermesAuth := make(map[string]string)
199212
for _, item := range items {
200213
obj, ok := item.(*unstructured.Unstructured)
201-
if !ok || obj.GetName() != "litellm-secrets" {
214+
if !ok {
215+
continue
216+
}
217+
dataKey := ""
218+
switch obj.GetName() {
219+
case "litellm-secrets":
220+
dataKey = "LITELLM_MASTER_KEY"
221+
case "hermes-api-server":
222+
dataKey = "API_SERVER_KEY"
223+
default:
202224
continue
203225
}
204226

205-
value, found, err := unstructured.NestedString(obj.Object, "data", "LITELLM_MASTER_KEY")
227+
value, found, err := unstructured.NestedString(obj.Object, "data", dataKey)
206228
if err != nil {
207-
return nil, err
229+
return nil, nil, err
208230
}
209231
if !found || value == "" {
210232
continue
211233
}
212234

213235
decoded, err := base64.StdEncoding.DecodeString(value)
214236
if err != nil {
215-
return nil, err
237+
return nil, nil, err
216238
}
217239
token := strings.TrimSpace(string(decoded))
218240
if token == "" {
219241
continue
220242
}
221-
result[obj.GetNamespace()] = "Bearer " + token
243+
switch obj.GetName() {
244+
case "litellm-secrets":
245+
litellmAuth[obj.GetNamespace()] = "Bearer " + token
246+
case "hermes-api-server":
247+
hermesAuth[obj.GetNamespace()] = "Bearer " + token
248+
}
222249
}
223-
return result, nil
250+
return litellmAuth, hermesAuth, nil
224251
}
225252

226253
func effectiveUpstreamAuth(offer *monetizeapi.ServiceOffer, upstreamAuth string) string {
254+
if offer.IsAgent() {
255+
return upstreamAuth
256+
}
227257
if !strings.EqualFold(offer.Spec.Upstream.Service, "litellm") {
228258
return ""
229259
}

internal/x402/serviceoffer_source_test.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,96 @@ func TestRouteRuleFromOffer_AgentResolutionAdvertisesRuntimeModelSkills(t *testi
200200
}
201201
}
202202

203+
func TestRoutesFromStore_AgentOfferInjectsHermesAPIKey(t *testing.T) {
204+
items := []any{
205+
mustOfferObject(t, monetizeapi.ServiceOffer{
206+
ObjectMeta: metav1.ObjectMeta{Name: "demo-quant", Namespace: "seller"},
207+
Spec: monetizeapi.ServiceOfferSpec{
208+
Type: "agent",
209+
Agent: monetizeapi.ServiceOfferAgent{
210+
Ref: monetizeapi.ServiceOfferAgentRef{Name: "demo-quant", Namespace: "agent-demo-quant"},
211+
},
212+
Payment: monetizeapi.ServiceOfferPayment{
213+
PayTo: "0x1111111111111111111111111111111111111111",
214+
Price: monetizeapi.ServiceOfferPriceTable{PerRequest: "10"},
215+
},
216+
},
217+
Status: monetizeapi.ServiceOfferStatus{
218+
Conditions: []monetizeapi.Condition{{Type: "RoutePublished", Status: "True"}},
219+
AgentResolution: &monetizeapi.ServiceOfferAgentResolution{
220+
Model: "qwen3.5:9b",
221+
Runtime: "hermes",
222+
Endpoint: "http://hermes.agent-demo-quant.svc.cluster.local:8642",
223+
},
224+
},
225+
}),
226+
}
227+
secrets := []any{
228+
mustSecretObject(t, "agent-demo-quant", "hermes-api-server", map[string]string{
229+
"API_SERVER_KEY": base64.StdEncoding.EncodeToString([]byte("agent-api-key")),
230+
}),
231+
mustSecretObject(t, "seller", "litellm-secrets", map[string]string{
232+
"LITELLM_MASTER_KEY": base64.StdEncoding.EncodeToString([]byte("wrong-secret")),
233+
}),
234+
}
235+
236+
routes, err := routesFromStore(items, secrets)
237+
if err != nil {
238+
t.Fatalf("routesFromStore: %v", err)
239+
}
240+
if len(routes) != 1 {
241+
t.Fatalf("len(routes) = %d, want 1", len(routes))
242+
}
243+
if routes[0].UpstreamAuth != "Bearer agent-api-key" {
244+
t.Fatalf("agent UpstreamAuth = %q, want Bearer agent-api-key", routes[0].UpstreamAuth)
245+
}
246+
}
247+
248+
func TestRoutesFromStore_AgentAuthUsesReferencedAgentNamespace(t *testing.T) {
249+
items := []any{
250+
mustOfferObject(t, monetizeapi.ServiceOffer{
251+
ObjectMeta: metav1.ObjectMeta{Name: "cross-ns-agent", Namespace: "seller-ns"},
252+
Spec: monetizeapi.ServiceOfferSpec{
253+
Type: "agent",
254+
Agent: monetizeapi.ServiceOfferAgent{
255+
Ref: monetizeapi.ServiceOfferAgentRef{Name: "quant", Namespace: "agent-quant"},
256+
},
257+
Payment: monetizeapi.ServiceOfferPayment{
258+
PayTo: "0x1111111111111111111111111111111111111111",
259+
Price: monetizeapi.ServiceOfferPriceTable{PerRequest: "1"},
260+
},
261+
},
262+
Status: monetizeapi.ServiceOfferStatus{
263+
Conditions: []monetizeapi.Condition{{Type: "RoutePublished", Status: "True"}},
264+
AgentResolution: &monetizeapi.ServiceOfferAgentResolution{
265+
Model: "qwen3.5:9b",
266+
Runtime: "hermes",
267+
Endpoint: "http://hermes.agent-quant.svc.cluster.local:8642",
268+
},
269+
},
270+
}),
271+
}
272+
secrets := []any{
273+
mustSecretObject(t, "seller-ns", "hermes-api-server", map[string]string{
274+
"API_SERVER_KEY": base64.StdEncoding.EncodeToString([]byte("seller-ns-key")),
275+
}),
276+
mustSecretObject(t, "agent-quant", "hermes-api-server", map[string]string{
277+
"API_SERVER_KEY": base64.StdEncoding.EncodeToString([]byte("agent-ns-key")),
278+
}),
279+
}
280+
281+
routes, err := routesFromStore(items, secrets)
282+
if err != nil {
283+
t.Fatalf("routesFromStore: %v", err)
284+
}
285+
if len(routes) != 1 {
286+
t.Fatalf("len(routes) = %d, want 1", len(routes))
287+
}
288+
if routes[0].UpstreamAuth != "Bearer agent-ns-key" {
289+
t.Fatalf("agent UpstreamAuth = %q, want referenced agent namespace key", routes[0].UpstreamAuth)
290+
}
291+
}
292+
203293
func mustOfferObject(t *testing.T, offer monetizeapi.ServiceOffer) *unstructured.Unstructured {
204294
t.Helper()
205295
offer.TypeMeta = metav1.TypeMeta{

0 commit comments

Comments
 (0)