Skip to content

Commit d15faf0

Browse files
authored
operator: Embed Authz config from ConfigMap into runconfig (#1893)
operator: embed Authz config from ConfigMap into runconfig; avoid --authz-config with --from-configmap; add unit and Chainsaw tests
1 parent 8396023 commit d15faf0

File tree

6 files changed

+415
-18
lines changed

6 files changed

+415
-18
lines changed

cmd/thv-operator/controllers/mcpserver_runconfig.go

Lines changed: 84 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import (
1010
"sort"
1111
"strconv"
1212
"strings"
13+
"time"
1314

15+
"gopkg.in/yaml.v3"
1416
corev1 "k8s.io/api/core/v1"
1517
"k8s.io/apimachinery/pkg/api/errors"
1618
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -27,6 +29,9 @@ import (
2729
// defaultProxyHost is the default host for proxy binding
2830
const defaultProxyHost = "0.0.0.0"
2931

32+
// defaultAPITimeout is the default timeout for Kubernetes API calls made during reconciliation
33+
const defaultAPITimeout = 15 * time.Second
34+
3035
// RunConfig management methods
3136

3237
// computeConfigMapChecksum computes a SHA256 checksum of the ConfigMap content for change detection
@@ -295,7 +300,12 @@ func (r *MCPServerReconciler) createRunConfigFromMCPServer(m *mcpv1alpha1.MCPSer
295300
addTelemetryConfigOptions(&options, m.Spec.Telemetry, m.Name)
296301

297302
// Add authorization configuration if specified
298-
addAuthzConfigOptions(&options, m.Spec.AuthzConfig)
303+
ctx, cancel := context.WithTimeout(context.Background(), defaultAPITimeout)
304+
defer cancel()
305+
306+
if err := r.addAuthzConfigOptions(ctx, m, &options, m.Spec.AuthzConfig); err != nil {
307+
return nil, fmt.Errorf("failed to process AuthzConfig: %w", err)
308+
}
299309

300310
// Add OIDC authentication configuration if specified
301311
addOIDCConfigOptions(&options, m.Spec.OIDCConfig)
@@ -623,18 +633,25 @@ func addTelemetryConfigOptions(
623633
}
624634

625635
// addAuthzConfigOptions adds authorization configuration options to the builder options
626-
func addAuthzConfigOptions(
636+
// Supports both inline and ConfigMap-based configurations.
637+
func (r *MCPServerReconciler) addAuthzConfigOptions(
638+
ctx context.Context,
639+
m *mcpv1alpha1.MCPServer,
627640
options *[]runner.RunConfigBuilderOption,
628-
authzConfig *mcpv1alpha1.AuthzConfigRef,
629-
) {
630-
if authzConfig == nil {
631-
return
641+
authzRef *mcpv1alpha1.AuthzConfigRef,
642+
) error {
643+
if authzRef == nil {
644+
return nil
632645
}
633646

634-
// Handle inline authorization configuration
635-
if authzConfig.Type == mcpv1alpha1.AuthzConfigTypeInline && authzConfig.Inline != nil {
636-
policies := authzConfig.Inline.Policies
637-
entitiesJSON := authzConfig.Inline.EntitiesJSON
647+
switch authzRef.Type {
648+
case mcpv1alpha1.AuthzConfigTypeInline:
649+
if authzRef.Inline == nil {
650+
return fmt.Errorf("inline authz config type specified but inline config is nil")
651+
}
652+
653+
policies := authzRef.Inline.Policies
654+
entitiesJSON := authzRef.Inline.EntitiesJSON
638655

639656
// Create authorization config
640657
authzCfg := &authz.Config{
@@ -648,9 +665,64 @@ func addAuthzConfigOptions(
648665

649666
// Add authorization config to options
650667
*options = append(*options, runner.WithAuthzConfig(authzCfg))
651-
}
668+
return nil
669+
670+
case mcpv1alpha1.AuthzConfigTypeConfigMap:
671+
// Validate reference
672+
if authzRef.ConfigMap == nil || authzRef.ConfigMap.Name == "" {
673+
return fmt.Errorf("configMap authz config type specified but reference is missing name")
674+
}
675+
key := authzRef.ConfigMap.Key
676+
if key == "" {
677+
key = "authz.json"
678+
}
652679

653-
// ConfigMap type is not currently implemented
680+
// Ensure we have a Kubernetes client to fetch the ConfigMap
681+
if r.Client == nil {
682+
return fmt.Errorf("kubernetes client is not configured for ConfigMap authz resolution")
683+
}
684+
685+
// Fetch the ConfigMap from the same namespace as the MCPServer
686+
var cm corev1.ConfigMap
687+
if err := r.Get(ctx, types.NamespacedName{
688+
Namespace: m.Namespace,
689+
Name: authzRef.ConfigMap.Name,
690+
}, &cm); err != nil {
691+
return fmt.Errorf("failed to get Authz ConfigMap %s/%s: %w", m.Namespace, authzRef.ConfigMap.Name, err)
692+
}
693+
694+
raw, ok := cm.Data[key]
695+
if !ok {
696+
return fmt.Errorf("authz ConfigMap %s/%s is missing key %q", m.Namespace, authzRef.ConfigMap.Name, key)
697+
}
698+
if strings.TrimSpace(raw) == "" {
699+
return fmt.Errorf("authz ConfigMap %s/%s key %q is empty", m.Namespace, authzRef.ConfigMap.Name, key)
700+
}
701+
702+
// Unmarshal into authz.Config supporting YAML or JSON
703+
var cfg authz.Config
704+
// Try YAML first (it also handles JSON)
705+
if err := yaml.Unmarshal([]byte(raw), &cfg); err != nil {
706+
// Fallback to JSON explicitly for clearer error paths
707+
if err2 := json.Unmarshal([]byte(raw), &cfg); err2 != nil {
708+
return fmt.Errorf("failed to parse authz config from ConfigMap %s/%s key %q: %v; json fallback error: %v",
709+
m.Namespace, authzRef.ConfigMap.Name, key, err, err2)
710+
}
711+
}
712+
713+
// Validate the config
714+
if err := cfg.Validate(); err != nil {
715+
return fmt.Errorf("invalid authz config from ConfigMap %s/%s key %q: %w",
716+
m.Namespace, authzRef.ConfigMap.Name, key, err)
717+
}
718+
719+
*options = append(*options, runner.WithAuthzConfig(&cfg))
720+
return nil
721+
722+
default:
723+
// Unknown type
724+
return fmt.Errorf("unknown authz config type: %s", authzRef.Type)
725+
}
654726
}
655727

656728
// addOIDCConfigOptions adds OIDC authentication configuration options to the builder options

cmd/thv-operator/controllers/mcpserver_runconfig_test.go

Lines changed: 140 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const (
2626
stdioTransport = "stdio"
2727
sseProxyMode = "sse"
2828
streamableHTTPProxyMode = "streamable-http"
29+
defaultAuthzKey = "authz.json"
2930
)
3031

3132
func createRunConfigTestScheme() *runtime.Scheme {
@@ -456,7 +457,7 @@ func TestCreateRunConfigFromMCPServer(t *testing.T) {
456457
Type: mcpv1alpha1.AuthzConfigTypeConfigMap,
457458
ConfigMap: &mcpv1alpha1.ConfigMapAuthzRef{
458459
Name: "test-authz-config",
459-
Key: "authz.json",
460+
Key: defaultAuthzKey,
460461
},
461462
},
462463
},
@@ -465,9 +466,14 @@ func TestCreateRunConfigFromMCPServer(t *testing.T) {
465466
expected: func(t *testing.T, config *runner.RunConfig) {
466467
assert.Equal(t, "authz-configmap-server", config.Name)
467468

468-
// For ConfigMap type, authorization config should not be set directly in RunConfig
469-
// since it will be handled by proxyrunner when reading from ConfigMap
470-
assert.Nil(t, config.AuthzConfig)
469+
// For ConfigMap type, with new feature, authorization config is embedded in RunConfig
470+
require.NotNil(t, config.AuthzConfig)
471+
assert.Equal(t, "v1", config.AuthzConfig.Version)
472+
assert.Equal(t, authz.ConfigTypeCedarV1, config.AuthzConfig.Type)
473+
require.NotNil(t, config.AuthzConfig.Cedar)
474+
assert.Len(t, config.AuthzConfig.Cedar.Policies, 1)
475+
assert.Contains(t, config.AuthzConfig.Cedar.Policies[0], "call_tool")
476+
assert.Equal(t, "[]", config.AuthzConfig.Cedar.EntitiesJSON)
471477
},
472478
},
473479
{
@@ -594,7 +600,54 @@ func TestCreateRunConfigFromMCPServer(t *testing.T) {
594600
for _, tt := range tests {
595601
t.Run(tt.name, func(t *testing.T) {
596602
t.Parallel()
597-
r := &MCPServerReconciler{}
603+
604+
// Build reconciler; if test uses ConfigMap-based authz, provide a fake client with that ConfigMap
605+
var r *MCPServerReconciler
606+
if tt.mcpServer != nil &&
607+
tt.mcpServer.Spec.AuthzConfig != nil &&
608+
tt.mcpServer.Spec.AuthzConfig.Type == mcpv1alpha1.AuthzConfigTypeConfigMap &&
609+
tt.mcpServer.Spec.AuthzConfig.ConfigMap != nil {
610+
611+
scheme := createRunConfigTestScheme()
612+
613+
// Prepare a ConfigMap with authorization configuration content
614+
cm := &corev1.ConfigMap{
615+
ObjectMeta: metav1.ObjectMeta{
616+
Name: tt.mcpServer.Spec.AuthzConfig.ConfigMap.Name,
617+
Namespace: tt.mcpServer.Namespace,
618+
},
619+
Data: map[string]string{
620+
func() string {
621+
if k := tt.mcpServer.Spec.AuthzConfig.ConfigMap.Key; k != "" {
622+
return k
623+
}
624+
return defaultAuthzKey
625+
}(): `{
626+
"version": "v1",
627+
"type": "cedarv1",
628+
"cedar": {
629+
"policies": [
630+
"permit(principal, action == Action::\"call_tool\", resource == Tool::\"weather\");"
631+
],
632+
"entities_json": "[]"
633+
}
634+
}`,
635+
},
636+
}
637+
638+
fakeClient := fake.NewClientBuilder().
639+
WithScheme(scheme).
640+
WithRuntimeObjects(cm).
641+
Build()
642+
643+
r = &MCPServerReconciler{
644+
Client: fakeClient,
645+
Scheme: scheme,
646+
}
647+
} else {
648+
r = &MCPServerReconciler{}
649+
}
650+
598651
result, err := r.createRunConfigFromMCPServer(tt.mcpServer)
599652
require.NoError(t, err)
600653
assert.NotNil(t, result)
@@ -1053,7 +1106,7 @@ func TestEnsureRunConfigConfigMap(t *testing.T) {
10531106
t.Run(tt.name, func(t *testing.T) {
10541107
t.Parallel()
10551108
testScheme := createRunConfigTestScheme()
1056-
objects := []runtime.Object{}
1109+
objects := []runtime.Object{tt.mcpServer}
10571110
if tt.existingCM != nil {
10581111
objects = append(objects, tt.existingCM)
10591112
}
@@ -1100,6 +1153,87 @@ func TestEnsureRunConfigConfigMap(t *testing.T) {
11001153
}
11011154
})
11021155
}
1156+
1157+
// Additional test: ConfigMap-based Authz referenced externally should be embedded into runconfig.json
1158+
t.Run("configmap with external authorization configuration", func(t *testing.T) {
1159+
t.Parallel()
1160+
testScheme := createRunConfigTestScheme()
1161+
1162+
mcpServer := &mcpv1alpha1.MCPServer{
1163+
ObjectMeta: metav1.ObjectMeta{
1164+
Name: "authz-cm-ext",
1165+
Namespace: "toolhive-system",
1166+
},
1167+
Spec: mcpv1alpha1.MCPServerSpec{
1168+
Image: "ghcr.io/example/server:v1.0.0",
1169+
Transport: "stdio",
1170+
Port: 8080,
1171+
AuthzConfig: &mcpv1alpha1.AuthzConfigRef{
1172+
Type: mcpv1alpha1.AuthzConfigTypeConfigMap,
1173+
ConfigMap: &mcpv1alpha1.ConfigMapAuthzRef{
1174+
Name: "ext-authz-config",
1175+
Key: "authz.json",
1176+
},
1177+
},
1178+
},
1179+
}
1180+
1181+
authzCM := &corev1.ConfigMap{
1182+
ObjectMeta: metav1.ObjectMeta{
1183+
Name: "ext-authz-config",
1184+
Namespace: "toolhive-system",
1185+
},
1186+
Data: map[string]string{
1187+
"authz.json": `{
1188+
"version": "v1",
1189+
"type": "cedarv1",
1190+
"cedar": {
1191+
"policies": [
1192+
"permit(principal, action == Action::\"call_tool\", resource == Tool::\"weather\");",
1193+
"permit(principal, action == Action::\"get_prompt\", resource == Prompt::\"greeting\");"
1194+
],
1195+
"entities_json": "[{\"uid\": {\"type\": \"User\", \"id\": \"user1\"}, \"attrs\": {}}]"
1196+
}
1197+
}`,
1198+
},
1199+
}
1200+
1201+
fakeClient := fake.NewClientBuilder().
1202+
WithScheme(testScheme).
1203+
WithRuntimeObjects(mcpServer, authzCM).
1204+
Build()
1205+
1206+
reconciler := &MCPServerReconciler{
1207+
Client: fakeClient,
1208+
Scheme: testScheme,
1209+
}
1210+
1211+
err := reconciler.ensureRunConfigConfigMap(context.TODO(), mcpServer)
1212+
require.NoError(t, err)
1213+
1214+
// Fetch the generated runconfig ConfigMap
1215+
configMapName := fmt.Sprintf("%s-runconfig", mcpServer.Name)
1216+
configMap := &corev1.ConfigMap{}
1217+
err = fakeClient.Get(context.TODO(), types.NamespacedName{
1218+
Name: configMapName,
1219+
Namespace: mcpServer.Namespace,
1220+
}, configMap)
1221+
require.NoError(t, err)
1222+
1223+
// Validate that authz config is embedded
1224+
var runConfig runner.RunConfig
1225+
err = json.Unmarshal([]byte(configMap.Data["runconfig.json"]), &runConfig)
1226+
require.NoError(t, err)
1227+
1228+
require.NotNil(t, runConfig.AuthzConfig)
1229+
assert.Equal(t, "v1", runConfig.AuthzConfig.Version)
1230+
assert.Equal(t, authz.ConfigTypeCedarV1, runConfig.AuthzConfig.Type)
1231+
require.NotNil(t, runConfig.AuthzConfig.Cedar)
1232+
assert.Len(t, runConfig.AuthzConfig.Cedar.Policies, 2)
1233+
assert.Contains(t, runConfig.AuthzConfig.Cedar.Policies, `permit(principal, action == Action::"call_tool", resource == Tool::"weather");`)
1234+
assert.Contains(t, runConfig.AuthzConfig.Cedar.Policies, `permit(principal, action == Action::"get_prompt", resource == Prompt::"greeting");`)
1235+
assert.Equal(t, `[{"uid": {"type": "User", "id": "user1"}, "attrs": {}}]`, runConfig.AuthzConfig.Cedar.EntitiesJSON)
1236+
})
11031237
}
11041238

11051239
// TestRunConfigContentEquals tests the content comparison logic
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
apiVersion: v1
2+
kind: Pod
3+
metadata:
4+
namespace: toolhive-system
5+
labels:
6+
app: mcpserver
7+
app.kubernetes.io/instance: authz-configmap-ref-test
8+
status:
9+
phase: Running
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
apiVersion: toolhive.stacklok.dev/v1alpha1
2+
kind: MCPServer
3+
metadata:
4+
name: authz-configmap-ref-test
5+
namespace: toolhive-system
6+
status:
7+
phase: Running

0 commit comments

Comments
 (0)