Skip to content

Commit fa55427

Browse files
authored
Merge pull request #510 from rossoctl/feat/agentruntime-auth-config
feat: Add spec.auth to AgentRuntime for SPIFFE token exchange
2 parents 0ded2b1 + fff8223 commit fa55427

7 files changed

Lines changed: 577 additions & 22 deletions

File tree

charts/operator/crds/agent.rossoctl.dev_agentruntimes.yaml

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,58 @@ spec:
6060
spec:
6161
description: AgentRuntimeSpec defines the desired state of AgentRuntime.
6262
properties:
63+
auth:
64+
description: |-
65+
Auth configures SPIFFE-based authentication and token exchange for
66+
outbound requests. When set with mode: federated-jwt, the operator
67+
configures AuthBridge to perform token exchange when calling the
68+
specified destinations, requesting the appropriate audiences.
69+
properties:
70+
outbound:
71+
description: |-
72+
Outbound defines token exchange routes for calling other services.
73+
Each route tells AuthBridge which audiences to request when calling
74+
a specific destination.
75+
76+
Routes are only effective when the namespace is configured with
77+
SPIFFE authentication (authBridge.clientAuthType: federated-jwt).
78+
The authentication mode is set globally at the namespace level, not
79+
per-agent.
80+
items:
81+
description: OutboundRoute defines a token exchange route for
82+
a specific destination.
83+
properties:
84+
audiences:
85+
description: |-
86+
Audiences lists the SPIFFE IDs to request in the token's audience claim.
87+
Typically includes the SPIFFE ID of the target service.
88+
89+
Example: ["spiffe://localtest.me/ns/team1/sa/weather-tool"]
90+
items:
91+
type: string
92+
minItems: 1
93+
type: array
94+
destination:
95+
description: Destination specifies which service this route
96+
matches.
97+
properties:
98+
host:
99+
description: |-
100+
Host is an exact hostname to match.
101+
Example: "weather-tool-mcp.team1.svc.cluster.local"
102+
type: string
103+
hostRegex:
104+
description: |-
105+
HostRegex is a regex pattern to match hostnames.
106+
Example: ".*\\.team1\\.svc\\.cluster\\.local"
107+
type: string
108+
type: object
109+
required:
110+
- audiences
111+
- destination
112+
type: object
113+
type: array
114+
type: object
63115
authBridgeMode:
64116
description: |-
65117
AuthBridgeMode selects the deployment shape for this workload's
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
apiVersion: apiextensions.k8s.io/v1
3+
kind: CustomResourceDefinition
4+
metadata:
5+
annotations:
6+
controller-gen.kubebuilder.io/version: v0.17.1
7+
spec:
8+
group: ""
9+
names:
10+
kind: ""
11+
plural: ""
12+
scope: ""
13+
versions: null

operator/api/v1alpha1/agentruntime_types.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,58 @@ type AgentRuntimeSpec struct {
155155
// +optional
156156
// +kubebuilder:validation:Enum=enforce-redirect;none
157157
EgressEnforcement string `json:"egressEnforcement,omitempty"`
158+
159+
// Auth configures SPIFFE-based authentication and token exchange for
160+
// outbound requests. When set with mode: federated-jwt, the operator
161+
// configures AuthBridge to perform token exchange when calling the
162+
// specified destinations, requesting the appropriate audiences.
163+
//
164+
// +optional
165+
Auth *AuthConfig `json:"auth,omitempty"`
166+
}
167+
168+
// AuthConfig defines authentication configuration for an agent or tool.
169+
type AuthConfig struct {
170+
// Outbound defines token exchange routes for calling other services.
171+
// Each route tells AuthBridge which audiences to request when calling
172+
// a specific destination.
173+
//
174+
// Routes are only effective when the namespace is configured with
175+
// SPIFFE authentication (authBridge.clientAuthType: federated-jwt).
176+
// The authentication mode is set globally at the namespace level, not
177+
// per-agent.
178+
//
179+
// +optional
180+
Outbound []OutboundRoute `json:"outbound,omitempty"`
181+
}
182+
183+
// OutboundRoute defines a token exchange route for a specific destination.
184+
type OutboundRoute struct {
185+
// Destination specifies which service this route matches.
186+
Destination RouteMatch `json:"destination"`
187+
188+
// Audiences lists the SPIFFE IDs to request in the token's audience claim.
189+
// Typically includes the SPIFFE ID of the target service.
190+
//
191+
// Example: ["spiffe://localtest.me/ns/team1/sa/weather-tool"]
192+
//
193+
// +kubebuilder:validation:MinItems=1
194+
Audiences []string `json:"audiences"`
195+
}
196+
197+
// RouteMatch defines how to match an outbound destination.
198+
type RouteMatch struct {
199+
// Host is an exact hostname to match.
200+
// Example: "weather-tool-mcp.team1.svc.cluster.local"
201+
//
202+
// +optional
203+
Host string `json:"host,omitempty"`
204+
205+
// HostRegex is a regex pattern to match hostnames.
206+
// Example: ".*\\.team1\\.svc\\.cluster\\.local"
207+
//
208+
// +optional
209+
HostRegex string `json:"hostRegex,omitempty"`
158210
}
159211

160212
// CardStatus holds the fetched A2A agent card data along with fetch metadata

operator/config/crd/bases/agent.rossoctl.dev_agentruntimes.yaml

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,58 @@ spec:
6060
spec:
6161
description: AgentRuntimeSpec defines the desired state of AgentRuntime.
6262
properties:
63+
auth:
64+
description: |-
65+
Auth configures SPIFFE-based authentication and token exchange for
66+
outbound requests. When set with mode: federated-jwt, the operator
67+
configures AuthBridge to perform token exchange when calling the
68+
specified destinations, requesting the appropriate audiences.
69+
properties:
70+
outbound:
71+
description: |-
72+
Outbound defines token exchange routes for calling other services.
73+
Each route tells AuthBridge which audiences to request when calling
74+
a specific destination.
75+
76+
Routes are only effective when the namespace is configured with
77+
SPIFFE authentication (authBridge.clientAuthType: federated-jwt).
78+
The authentication mode is set globally at the namespace level, not
79+
per-agent.
80+
items:
81+
description: OutboundRoute defines a token exchange route for
82+
a specific destination.
83+
properties:
84+
audiences:
85+
description: |-
86+
Audiences lists the SPIFFE IDs to request in the token's audience claim.
87+
Typically includes the SPIFFE ID of the target service.
88+
89+
Example: ["spiffe://localtest.me/ns/team1/sa/weather-tool"]
90+
items:
91+
type: string
92+
minItems: 1
93+
type: array
94+
destination:
95+
description: Destination specifies which service this route
96+
matches.
97+
properties:
98+
host:
99+
description: |-
100+
Host is an exact hostname to match.
101+
Example: "weather-tool-mcp.team1.svc.cluster.local"
102+
type: string
103+
hostRegex:
104+
description: |-
105+
HostRegex is a regex pattern to match hostnames.
106+
Example: ".*\\.team1\\.svc\\.cluster\\.local"
107+
type: string
108+
type: object
109+
required:
110+
- audiences
111+
- destination
112+
type: object
113+
type: array
114+
type: object
63115
authBridgeMode:
64116
description: |-
65117
AuthBridgeMode selects the deployment shape for this workload's

operator/internal/webhook/injector/pod_mutator.go

Lines changed: 97 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@ const (
7676
RossoctlTypeAgent = "agent"
7777
// RossoctlTypeTool is the label value that identifies tool workloads
7878
RossoctlTypeTool = "tool"
79+
80+
// tokenExchangePluginName is the name of the AuthBridge plugin that handles
81+
// OAuth2 token exchange for SPIFFE-based authentication.
82+
tokenExchangePluginName = "token-exchange"
7983
)
8084

8185
type PodMutator struct {
@@ -220,6 +224,27 @@ func (m *PodMutator) InjectAuthBridge(ctx context.Context, podSpec *corev1.PodSp
220224
nsConfig = &NamespaceConfig{}
221225
}
222226

227+
// Fetch the AgentRuntime CR for this workload (if it exists).
228+
// The CR may not exist if the workload was deployed before AgentRuntime CRDs were created.
229+
// In that case, we proceed with defaults (no spec.auth configuration).
230+
var agentRuntime *agentv1alpha1.AgentRuntime
231+
agentRuntimeList := &agentv1alpha1.AgentRuntimeList{}
232+
if err := reader.List(ctx, agentRuntimeList, client.InNamespace(namespace)); err != nil {
233+
mutatorLog.Info("failed to list AgentRuntimes (proceeding with defaults)",
234+
"namespace", namespace, "crName", crName, "error", err)
235+
} else {
236+
// Find AgentRuntime that targets this workload
237+
for i := range agentRuntimeList.Items {
238+
rt := &agentRuntimeList.Items[i]
239+
if rt.Spec.TargetRef.Name == crName && rt.Spec.TargetRef.Kind == workloadKind {
240+
agentRuntime = rt
241+
mutatorLog.Info("found AgentRuntime CR for workload",
242+
"namespace", namespace, "crName", crName, "agentRuntime", rt.Name)
243+
break
244+
}
245+
}
246+
}
247+
223248
// ========================================
224249
// Resolve mTLS posture (namespace > "disabled")
225250
// ========================================
@@ -544,7 +569,7 @@ func (m *PodMutator) InjectAuthBridge(ctx context.Context, podSpec *corev1.PodSp
544569
"reverse_proxy_backend": fmt.Sprintf("http://127.0.0.1:%d", newAgentPort),
545570
"forward_proxy_addr": fmt.Sprintf(":%d", forwardProxyPort),
546571
},
547-
mtlsMode, tlsBridgeMode, spireEnabled)
572+
mtlsMode, tlsBridgeMode, spireEnabled, agentRuntime)
548573
if err != nil {
549574
return false, fmt.Errorf("proxy-sidecar per-agent ConfigMap: %w", err)
550575
}
@@ -680,7 +705,7 @@ func (m *PodMutator) InjectAuthBridge(ctx context.Context, podSpec *corev1.PodSp
680705
// inbound listener (gated on MTLSEnabled) and UpstreamTlsContext on
681706
// original_destination_tls (strict only).
682707
perAgentCMName, err := m.ensurePerAgentConfigMap(ctx, namespace, crName,
683-
ModeEnvoySidecar, nsConfig.AuthBridgeRuntimeYAML, nsConfig, nil, mtlsMode, "", spireEnabled) // bridge never runs under envoy-sidecar
708+
ModeEnvoySidecar, nsConfig.AuthBridgeRuntimeYAML, nsConfig, nil, mtlsMode, "", spireEnabled, agentRuntime) // bridge never runs under envoy-sidecar
684709
if err != nil {
685710
return false, fmt.Errorf("envoy-sidecar per-agent ConfigMap: %w", err)
686711
}
@@ -877,7 +902,7 @@ func synthesizePipeline(nsConfig *NamespaceConfig) map[string]interface{} {
877902
"outbound": map[string]interface{}{
878903
"plugins": []interface{}{
879904
map[string]interface{}{
880-
"name": "token-exchange",
905+
"name": tokenExchangePluginName,
881906
"config": tokenCfg,
882907
},
883908
},
@@ -907,6 +932,7 @@ func (m *PodMutator) ensurePerAgentConfigMap(
907932
mtlsMode string,
908933
tlsBridgeMode string,
909934
spireEnabled bool,
935+
agentRuntime *agentv1alpha1.AgentRuntime,
910936
) (string, error) {
911937
cmName := perAgentConfigMapName(crName)
912938

@@ -992,6 +1018,74 @@ func (m *PodMutator) ensurePerAgentConfigMap(
9921018
delete(cfg, "spiffe")
9931019
}
9941020

1021+
// Generate token-exchange routes from AgentRuntime spec.auth.outbound.
1022+
// Routes tell AuthBridge which audiences to request when calling specific
1023+
// destinations. Routes are only effective when the namespace is configured
1024+
// with SPIFFE authentication (CLIENT_AUTH_TYPE=federated-jwt).
1025+
if agentRuntime != nil && agentRuntime.Spec.Auth != nil &&
1026+
len(agentRuntime.Spec.Auth.Outbound) > 0 {
1027+
1028+
// Navigate to pipeline.outbound.plugins[token-exchange].config
1029+
pipeline, _ := cfg["pipeline"].(map[string]interface{})
1030+
if pipeline == nil {
1031+
mutatorLog.Info("WARN: no pipeline block found, cannot inject routes",
1032+
"namespace", namespace, "crName", crName)
1033+
} else {
1034+
outbound, _ := pipeline["outbound"].(map[string]interface{})
1035+
if outbound == nil {
1036+
mutatorLog.Info("WARN: no outbound block found, cannot inject routes",
1037+
"namespace", namespace, "crName", crName)
1038+
} else {
1039+
plugins, _ := outbound["plugins"].([]interface{})
1040+
if len(plugins) == 0 {
1041+
mutatorLog.Info("WARN: no outbound plugins found, cannot inject routes",
1042+
"namespace", namespace, "crName", crName)
1043+
} else {
1044+
// Find the token-exchange plugin
1045+
for i := range plugins {
1046+
plugin, _ := plugins[i].(map[string]interface{})
1047+
if plugin == nil {
1048+
continue
1049+
}
1050+
pluginName, _ := plugin["name"].(string)
1051+
if pluginName == tokenExchangePluginName {
1052+
pluginConfig, _ := plugin["config"].(map[string]interface{})
1053+
if pluginConfig == nil {
1054+
pluginConfig = make(map[string]interface{})
1055+
plugin["config"] = pluginConfig
1056+
}
1057+
1058+
// Generate routes from spec.auth.outbound
1059+
routes := make([]interface{}, 0, len(agentRuntime.Spec.Auth.Outbound))
1060+
for _, outboundRoute := range agentRuntime.Spec.Auth.Outbound {
1061+
route := map[string]interface{}{
1062+
"audiences": outboundRoute.Audiences,
1063+
}
1064+
1065+
// Add destination match (host or hostRegex)
1066+
destination := make(map[string]interface{})
1067+
if outboundRoute.Destination.Host != "" {
1068+
destination["host"] = outboundRoute.Destination.Host
1069+
}
1070+
if outboundRoute.Destination.HostRegex != "" {
1071+
destination["hostRegex"] = outboundRoute.Destination.HostRegex
1072+
}
1073+
route["destination"] = destination
1074+
1075+
routes = append(routes, route)
1076+
}
1077+
1078+
pluginConfig["routes"] = routes
1079+
mutatorLog.Info("injected token-exchange routes from AgentRuntime spec.auth",
1080+
"namespace", namespace, "crName", crName, "routeCount", len(routes))
1081+
break
1082+
}
1083+
}
1084+
}
1085+
}
1086+
}
1087+
}
1088+
9951089
// Marshal back to YAML
9961090
data, err := yaml.Marshal(cfg)
9971091
if err != nil {

0 commit comments

Comments
 (0)