Skip to content

Commit c86679f

Browse files
committed
Render AuthBridge layer-3 plugin presets from AgentRuntime.spec
Add spec.pluginPreset / spec.plugins / spec.onError to AgentRuntime and render the full canonical AuthBridge plugin pipeline into the per-agent authbridge-config-<name> ConfigMap when a preset is set (Option B). Ported from the workload-harness pipeline-merge.py: - Presets auth-only / ibac-only / full select plugin membership; every supported plugin is emitted in fixed canonical order (inbound [a2a-parser, jwt-validation]; outbound [token-exchange, token-broker, inference-parser, mcp-parser, ibac]), unselected ones at on_error: off. - Per-plugin policy enforce|observe|off maps to on_error (enforce omits it); spec.onError sets the chain default, spec.plugins "NAME:POLICY" tokens override per plugin. - token-exchange XOR token-broker on the outbound chain (mutex rejected). - Per-plugin config is seeded from the operator's already-rendered base (jwt-validation issuer, token-exchange identity) so it survives — never replaced wholesale (prevents "issuer is required" reload failures). - ibac judge config (endpoint/model/timeout/host/bearer) comes from a new platform IBACConfig (Helm defaults.ibac.*); the judge system prompt is baked into the operator binary. Validating webhook rejects malformed spec.plugins tokens via AgentRuntimeSpec.ValidatePlugins (in the leaf api package to avoid an injector import cycle). CRD + deepcopy regenerated. Unit tests cover synthesis, membership, overrides, mutex, base-config seeding, and ibac. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Rong Chang <rong@us.ibm.com>
1 parent c3f9d2b commit c86679f

12 files changed

Lines changed: 959 additions & 0 deletions

File tree

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,53 @@ spec:
210210
- permissive
211211
- strict
212212
type: string
213+
onError:
214+
description: |-
215+
OnError sets the chain-default policy applied to every selected plugin that
216+
has no explicit per-plugin override in Plugins. enforce is the framework
217+
default (on_error omitted); observe/off are emitted explicitly. Ignored when
218+
PluginPreset is unset.
219+
enum:
220+
- enforce
221+
- observe
222+
- "off"
223+
type: string
224+
pluginPreset:
225+
description: |-
226+
PluginPreset selects an AuthBridge layer-3 plugin-pipeline preset for this
227+
workload's per-agent authbridge-config-<name> ConfigMap. When set, the
228+
admission webhook renders the full canonical pipeline (all supported plugins
229+
in fixed order; unselected ones emitted with on_error: off) instead of the
230+
default two-plugin (jwt-validation + token-exchange) synthesis.
231+
232+
Presets (membership; every preset seeds its plugins at policy "enforce"):
233+
auth-only inbound: jwt-validation outbound: token-exchange
234+
ibac-only inbound: a2a-parser outbound: inference-parser, mcp-parser, ibac
235+
full inbound: a2a-parser, jwt-validation outbound: token-exchange,
236+
inference-parser, mcp-parser, ibac
237+
238+
Only honored on the proxy-sidecar / lite paths (the plugin pipeline lives in
239+
the per-agent ConfigMap those modes mount). Requires the AuthBridge sidecar to
240+
be injected.
241+
enum:
242+
- auth-only
243+
- ibac-only
244+
- full
245+
type: string
246+
plugins:
247+
description: |-
248+
Plugins carries per-plugin policy overrides layered on top of PluginPreset, as
249+
"NAME:POLICY" tokens (e.g. "ibac:observe"). POLICY is one of enforce|observe|off
250+
and maps to the plugin entry's on_error field: enforce omits on_error (the
251+
framework default), observe sets on_error: observe, off sets on_error: off.
252+
A plugin named here that isn't part of the preset is added at the given policy.
253+
Ignored when PluginPreset is unset.
254+
255+
token-exchange and token-broker are mutually exclusive on the outbound chain;
256+
a spec that activates both is rejected by admission.
257+
items:
258+
type: string
259+
type: array
213260
targetRef:
214261
description: TargetRef identifies the workload backing this agent
215262
runtime (duck typing).

charts/operator/values.yaml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,3 +338,28 @@ defaults:
338338
jwt_svids = [{jwt_audience="http://keycloak.localtest.me:8080/realms/rossoctl", jwt_svid_file_name="/opt/jwt_svid.token"}]
339339
jwt_svid_file_mode = 0644
340340
include_federated_domains = true
341+
342+
# IBAC (intent-based access control) judge settings. The operator stamps
343+
# these into the ibac plugin's config when a workload selects an AuthBridge
344+
# plugin preset that includes ibac (spec.pluginPreset: ibac-only | full).
345+
# The ibac plugin calls this OpenAI-compatible "judge" LLM to decide whether
346+
# an outbound action matches the user's stated intent.
347+
#
348+
# judgeEndpoint/judgeModel MUST be set before an ibac-bearing preset can
349+
# enforce — an ibac plugin rendered with an empty judge_endpoint fails the
350+
# sidecar's config reload. When they are empty the operator still emits the
351+
# plugin (so the pipeline shape is correct) but logs a warning at admission.
352+
# The judge system prompt is baked into the operator binary, not sourced here.
353+
ibac:
354+
# OpenAI-compatible base URL of the judge LLM, e.g.
355+
# "http://litellm.rossoctl-system.svc.cluster.local:4000".
356+
judgeEndpoint: ""
357+
# Model id the judge call uses, e.g. "llama3.2:3b".
358+
judgeModel: ""
359+
# Bounds the judge call in milliseconds. Mirrors the harness default (15000).
360+
timeoutMs: 15000
361+
# Optional: the agent's own LLM host, so the judge can distinguish agent
362+
# LLM traffic from tool traffic. Omitted from the plugin config when empty.
363+
agentLlmHost: ""
364+
# Optional: bearer token for the judge endpoint. Omitted when empty.
365+
judgeBearer: ""
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/*
2+
Copyright 2026.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package v1alpha1
18+
19+
import "testing"
20+
21+
func TestValidatePlugins(t *testing.T) {
22+
tests := []struct {
23+
name string
24+
spec AgentRuntimeSpec
25+
wantErr bool
26+
}{
27+
{
28+
name: "no preset is a no-op even with malformed plugins",
29+
spec: AgentRuntimeSpec{Plugins: []string{"bogus:loud"}},
30+
},
31+
{
32+
name: "valid tokens with preset",
33+
spec: AgentRuntimeSpec{PluginPreset: "full", Plugins: []string{"ibac:observe", "mcp-parser:off"}},
34+
},
35+
{
36+
name: "bare name defaults to enforce",
37+
spec: AgentRuntimeSpec{PluginPreset: "full", Plugins: []string{"ibac"}},
38+
},
39+
{
40+
name: "unknown plugin name",
41+
spec: AgentRuntimeSpec{PluginPreset: "full", Plugins: []string{"nope:enforce"}},
42+
wantErr: true,
43+
},
44+
{
45+
name: "invalid policy",
46+
spec: AgentRuntimeSpec{PluginPreset: "full", Plugins: []string{"ibac:loud"}},
47+
wantErr: true,
48+
},
49+
{
50+
name: "empty token",
51+
spec: AgentRuntimeSpec{PluginPreset: "full", Plugins: []string{" "}},
52+
wantErr: true,
53+
},
54+
}
55+
for _, tt := range tests {
56+
t.Run(tt.name, func(t *testing.T) {
57+
err := tt.spec.ValidatePlugins()
58+
if (err != nil) != tt.wantErr {
59+
t.Errorf("ValidatePlugins() error = %v, wantErr %v", err, tt.wantErr)
60+
}
61+
})
62+
}
63+
}

operator/api/v1alpha1/agentruntime_types.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ limitations under the License.
1717
package v1alpha1
1818

1919
import (
20+
"fmt"
21+
"strings"
22+
2023
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2124
)
2225

@@ -163,6 +166,48 @@ type AgentRuntimeSpec struct {
163166
//
164167
// +optional
165168
Auth *AuthConfig `json:"auth,omitempty"`
169+
170+
// PluginPreset selects an AuthBridge layer-3 plugin-pipeline preset for this
171+
// workload's per-agent authbridge-config-<name> ConfigMap. When set, the
172+
// admission webhook renders the full canonical pipeline (all supported plugins
173+
// in fixed order; unselected ones emitted with on_error: off) instead of the
174+
// default two-plugin (jwt-validation + token-exchange) synthesis.
175+
//
176+
// Presets (membership; every preset seeds its plugins at policy "enforce"):
177+
// auth-only inbound: jwt-validation outbound: token-exchange
178+
// ibac-only inbound: a2a-parser outbound: inference-parser, mcp-parser, ibac
179+
// full inbound: a2a-parser, jwt-validation outbound: token-exchange,
180+
// inference-parser, mcp-parser, ibac
181+
//
182+
// Only honored on the proxy-sidecar / lite paths (the plugin pipeline lives in
183+
// the per-agent ConfigMap those modes mount). Requires the AuthBridge sidecar to
184+
// be injected.
185+
//
186+
// +optional
187+
// +kubebuilder:validation:Enum=auth-only;ibac-only;full
188+
PluginPreset string `json:"pluginPreset,omitempty"`
189+
190+
// Plugins carries per-plugin policy overrides layered on top of PluginPreset, as
191+
// "NAME:POLICY" tokens (e.g. "ibac:observe"). POLICY is one of enforce|observe|off
192+
// and maps to the plugin entry's on_error field: enforce omits on_error (the
193+
// framework default), observe sets on_error: observe, off sets on_error: off.
194+
// A plugin named here that isn't part of the preset is added at the given policy.
195+
// Ignored when PluginPreset is unset.
196+
//
197+
// token-exchange and token-broker are mutually exclusive on the outbound chain;
198+
// a spec that activates both is rejected by admission.
199+
//
200+
// +optional
201+
Plugins []string `json:"plugins,omitempty"`
202+
203+
// OnError sets the chain-default policy applied to every selected plugin that
204+
// has no explicit per-plugin override in Plugins. enforce is the framework
205+
// default (on_error omitted); observe/off are emitted explicitly. Ignored when
206+
// PluginPreset is unset.
207+
//
208+
// +optional
209+
// +kubebuilder:validation:Enum=enforce;observe;off
210+
OnError string `json:"onError,omitempty"`
166211
}
167212

168213
// AuthConfig defines authentication configuration for an agent or tool.
@@ -209,6 +254,53 @@ type RouteMatch struct {
209254
HostRegex string `json:"hostRegex,omitempty"`
210255
}
211256

257+
// SupportedAuthBridgePlugins is the set of plugin names accepted in
258+
// AgentRuntimeSpec.Plugins override tokens. Kept in this leaf API package so
259+
// both the validating webhook and the injector's pipeline renderer reference
260+
// one list without an import cycle. Keep in sync with the injector's canonical
261+
// inbound/outbound order lists (internal/webhook/injector/preset_pipeline.go).
262+
var SupportedAuthBridgePlugins = map[string]bool{
263+
"a2a-parser": true,
264+
"jwt-validation": true,
265+
"token-exchange": true,
266+
"token-broker": true,
267+
"inference-parser": true,
268+
"mcp-parser": true,
269+
"ibac": true,
270+
}
271+
272+
// supportedAuthBridgePolicies is the legal per-plugin / chain-default policy set.
273+
var supportedAuthBridgePolicies = map[string]bool{"enforce": true, "observe": true, "off": true}
274+
275+
// ValidatePlugins checks the spec.plugins override tokens ("NAME:POLICY") for a
276+
// known plugin name and a valid policy. No-op when pluginPreset is unset (the
277+
// preset gates whether Plugins is honored). The token-exchange/token-broker
278+
// mutex is enforced at render time, where preset membership is resolved.
279+
func (s *AgentRuntimeSpec) ValidatePlugins() error {
280+
if s.PluginPreset == "" {
281+
return nil
282+
}
283+
for _, tok := range s.Plugins {
284+
t := strings.TrimSpace(tok)
285+
if t == "" {
286+
return fmt.Errorf("empty plugin override token in spec.plugins")
287+
}
288+
name := t
289+
policy := "enforce"
290+
if idx := strings.Index(t, ":"); idx >= 0 {
291+
name = strings.TrimSpace(t[:idx])
292+
policy = strings.TrimSpace(t[idx+1:])
293+
}
294+
if !SupportedAuthBridgePlugins[name] {
295+
return fmt.Errorf("unknown plugin %q in spec.plugins token %q", name, tok)
296+
}
297+
if !supportedAuthBridgePolicies[policy] {
298+
return fmt.Errorf("invalid policy %q in spec.plugins token %q (want enforce, observe, or off)", policy, tok)
299+
}
300+
}
301+
return nil
302+
}
303+
212304
// CardStatus holds the fetched A2A agent card data along with fetch metadata
213305
// and optional verification results. Populated by the card discovery phase when
214306
// --enable-card-discovery is set.

operator/api/v1alpha1/zz_generated.deepcopy.go

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,53 @@ spec:
210210
- permissive
211211
- strict
212212
type: string
213+
onError:
214+
description: |-
215+
OnError sets the chain-default policy applied to every selected plugin that
216+
has no explicit per-plugin override in Plugins. enforce is the framework
217+
default (on_error omitted); observe/off are emitted explicitly. Ignored when
218+
PluginPreset is unset.
219+
enum:
220+
- enforce
221+
- observe
222+
- "off"
223+
type: string
224+
pluginPreset:
225+
description: |-
226+
PluginPreset selects an AuthBridge layer-3 plugin-pipeline preset for this
227+
workload's per-agent authbridge-config-<name> ConfigMap. When set, the
228+
admission webhook renders the full canonical pipeline (all supported plugins
229+
in fixed order; unselected ones emitted with on_error: off) instead of the
230+
default two-plugin (jwt-validation + token-exchange) synthesis.
231+
232+
Presets (membership; every preset seeds its plugins at policy "enforce"):
233+
auth-only inbound: jwt-validation outbound: token-exchange
234+
ibac-only inbound: a2a-parser outbound: inference-parser, mcp-parser, ibac
235+
full inbound: a2a-parser, jwt-validation outbound: token-exchange,
236+
inference-parser, mcp-parser, ibac
237+
238+
Only honored on the proxy-sidecar / lite paths (the plugin pipeline lives in
239+
the per-agent ConfigMap those modes mount). Requires the AuthBridge sidecar to
240+
be injected.
241+
enum:
242+
- auth-only
243+
- ibac-only
244+
- full
245+
type: string
246+
plugins:
247+
description: |-
248+
Plugins carries per-plugin policy overrides layered on top of PluginPreset, as
249+
"NAME:POLICY" tokens (e.g. "ibac:observe"). POLICY is one of enforce|observe|off
250+
and maps to the plugin entry's on_error field: enforce omits on_error (the
251+
framework default), observe sets on_error: observe, off sets on_error: off.
252+
A plugin named here that isn't part of the preset is added at the given policy.
253+
Ignored when PluginPreset is unset.
254+
255+
token-exchange and token-broker are mutually exclusive on the outbound chain;
256+
a spec that activates both is rejected by admission.
257+
items:
258+
type: string
259+
type: array
213260
targetRef:
214261
description: TargetRef identifies the workload backing this agent
215262
runtime (duck typing).

operator/internal/webhook/config/defaults.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,5 +117,12 @@ func CompiledDefaults() *PlatformConfig {
117117
LogLevel: "info",
118118
EnableMetrics: true,
119119
},
120+
// IBAC judge defaults. JudgeEndpoint/JudgeModel are intentionally empty —
121+
// they must be configured (Helm defaults.ibac.*) before an ibac-bearing
122+
// preset (ibac-only / full) can enforce. TimeoutMS mirrors the harness
123+
// default (authbridge/apply-pipeline.sh IBAC_TIMEOUT_MS default 15000).
124+
IBAC: IBACConfig{
125+
TimeoutMS: 15000,
126+
},
120127
}
121128
}

operator/internal/webhook/config/types.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ type PlatformConfig struct {
1818
TokenExchange TokenExchangeDefaults `json:"tokenExchange" yaml:"tokenExchange"`
1919
Spiffe SpiffeConfig `json:"spiffe" yaml:"spiffe"`
2020
Observability ObservabilityConfig `json:"observability" yaml:"observability"`
21+
IBAC IBACConfig `json:"ibac" yaml:"ibac"`
2122
}
2223

2324
type ImageConfig struct {
@@ -110,6 +111,34 @@ type ObservabilityConfig struct {
110111
EnableMetrics bool `json:"enableMetrics" yaml:"enableMetrics"`
111112
}
112113

114+
// IBACConfig holds the platform-level settings the operator stamps into the
115+
// ibac plugin's config when a workload selects an AuthBridge preset that
116+
// includes ibac (ibac-only / full). These are the LLM "judge" the ibac plugin
117+
// calls to decide whether an outbound action matches the user's intent.
118+
//
119+
// JudgeEndpoint/JudgeModel must be set for ibac to enforce — an ibac plugin
120+
// rendered without a judge_endpoint fails the sidecar's config reload. When
121+
// they are empty the operator still emits the plugin (so the pipeline shape is
122+
// correct) but logs a warning; the preset should be configured before use.
123+
//
124+
// The ibac judge system prompt is baked into the operator binary
125+
// (ibacSystemPrompt in pod_mutator.go), not sourced from here.
126+
type IBACConfig struct {
127+
// JudgeEndpoint is the OpenAI-compatible base URL of the judge LLM,
128+
// e.g. "http://litellm.rossoctl-system.svc.cluster.local:4000".
129+
JudgeEndpoint string `json:"judgeEndpoint" yaml:"judgeEndpoint"`
130+
// JudgeModel is the model id the judge call uses, e.g. "llama3.2:3b".
131+
JudgeModel string `json:"judgeModel" yaml:"judgeModel"`
132+
// TimeoutMS bounds the judge call. Defaults to 15000 when unset.
133+
TimeoutMS int `json:"timeoutMs" yaml:"timeoutMs"`
134+
// AgentLLMHost optionally names the agent's own LLM host so the judge can
135+
// distinguish agent LLM traffic from tool traffic. Omitted when empty.
136+
AgentLLMHost string `json:"agentLlmHost" yaml:"agentLlmHost"`
137+
// JudgeBearer optionally supplies the bearer token for the judge endpoint.
138+
// Omitted when empty.
139+
JudgeBearer string `json:"judgeBearer" yaml:"judgeBearer"`
140+
}
141+
113142
// DeepCopy creates a copy of the config
114143
func (c *PlatformConfig) DeepCopy() *PlatformConfig {
115144
if c == nil {

0 commit comments

Comments
 (0)