diff --git a/cmd/help/dedupe-enums/main.go b/cmd/help/dedupe-enums/main.go index 655f7dddaa..a84f1543f1 100644 --- a/cmd/help/dedupe-enums/main.go +++ b/cmd/help/dedupe-enums/main.go @@ -4,10 +4,11 @@ // Command dedupe-enums works around a swag v2.0.0-rc5 nondeterminism bug: // loadExternalPackage (in swag's packages.go) reparses an external module's // package without memoizing by import path, which can non-deterministically -// double the "enum"/"x-enum-varnames" arrays it emits for types defined +// repeat the "enum"/"x-enum-varnames" arrays it emits for types defined // outside this module (model.ArgumentType, model.Format, model.Icon from -// github.com/modelcontextprotocol/registry). When an array is exactly two -// identical halves back to back, this collapses it to one. +// github.com/modelcontextprotocol/registry). The repeat count varies by +// machine — 2x and 3x have both been seen. When an array is the same block +// repeated back to back, this collapses it to one copy. // // Run after `swag init`, on each generated docs/server file: // @@ -62,11 +63,11 @@ func dedupeJSONBlock(match string) string { trimmed[i] = strings.TrimSuffix(l, ",") } - half, ok := firstHalfIfDoubled(trimmed) + period, ok := firstPeriodIfRepeated(trimmed) if !ok { return match } - return header + strings.Join(half, ",\n") + "\n" + footer + return header + strings.Join(period, ",\n") + "\n" + footer } func dedupeYAMLBlock(match string) string { @@ -74,29 +75,40 @@ func dedupeYAMLBlock(match string) string { header, body := g[1], g[2] lines := strings.Split(strings.TrimRight(body, "\n"), "\n") - half, ok := firstHalfIfDoubled(lines) + period, ok := firstPeriodIfRepeated(lines) if !ok { return match } - return header + strings.Join(half, "\n") + "\n" + return header + strings.Join(period, "\n") + "\n" } -// firstHalfIfDoubled reports whether lines is exactly two identical halves -// back to back, returning the first half if so. It only catches this -// specific shape (contiguous, order-preserved duplication), not any 2x -// multiset — if swag's bug ever reordered values within the second copy, -// this wouldn't fire. That matches the upstream bug: it re-appends the same -// const list a second time, never reorders it. -func firstHalfIfDoubled(lines []string) ([]string, bool) { +// firstPeriodIfRepeated reports whether lines is the same block repeated +// back to back some whole number of times, returning one copy of that block +// if so. It only catches this shape (contiguous, order-preserved +// duplication), not any repeated multiset — if swag's bug ever reordered +// values within a later copy, this wouldn't fire. That matches the upstream +// bug: it re-appends the same const list, never reorders it. The repeat +// count varies between machines (2x and 3x have both been observed), so +// this collapses any count rather than only a doubling. +func firstPeriodIfRepeated(lines []string) ([]string, bool) { n := len(lines) - if n == 0 || n%2 != 0 { + if n < 2 { return nil, false } - half := n / 2 - for i := 0; i < half; i++ { - if lines[i] != lines[half+i] { - return nil, false + for period := 1; period <= n/2; period++ { + if n%period != 0 { + continue + } + repeated := true + for i := period; i < n; i++ { + if lines[i] != lines[i-period] { + repeated = false + break + } + } + if repeated { + return lines[:period], true } } - return lines[:half], true + return nil, false } diff --git a/cmd/help/dedupe-enums/main_test.go b/cmd/help/dedupe-enums/main_test.go index c2583e8e6e..71c4bb7dca 100644 --- a/cmd/help/dedupe-enums/main_test.go +++ b/cmd/help/dedupe-enums/main_test.go @@ -8,7 +8,7 @@ import ( "testing" ) -func TestFirstHalfIfDoubled(t *testing.T) { +func TestFirstPeriodIfRepeated(t *testing.T) { t.Parallel() tests := []struct { @@ -24,8 +24,19 @@ func TestFirstHalfIfDoubled(t *testing.T) { ok: true, }, { - name: "tripled is not a doubled shape", + name: "tripled collapses to one period", lines: []string{"a", "b", "a", "b", "a", "b"}, + want: []string{"a", "b"}, + ok: true, + }, + { + name: "partial repeat is left alone", + lines: []string{"a", "b", "a", "b", "a"}, + ok: false, + }, + { + name: "non-repeating list is left alone", + lines: []string{"a", "b", "c", "d"}, ok: false, }, { @@ -53,7 +64,7 @@ func TestFirstHalfIfDoubled(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - got, ok := firstHalfIfDoubled(tt.lines) + got, ok := firstPeriodIfRepeated(tt.lines) if ok != tt.ok { t.Fatalf("ok = %v, want %v", ok, tt.ok) } diff --git a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go index 75a6783627..ac0768d8f4 100644 --- a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go +++ b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go @@ -1048,6 +1048,19 @@ type OIDCUpstreamConfig struct { // +kubebuilder:validation:MaxLength=128 // +kubebuilder:validation:Pattern=`^([a-zA-Z_][a-zA-Z0-9_]*)?$` SubjectClaim string `json:"subjectClaim,omitempty"` + + // CABundleRef references a ConfigMap containing a CA bundle added to the + // system roots when connecting to this upstream; it does not restrict trust + // to this bundle or disable public-root trust. The selected key is projected + // as ca.crt. + // +optional + CABundleRef *CABundleSource `json:"caBundleRef,omitempty"` + + // AllowPrivateIPs permits the upstream provider's HTTP client to connect to + // private IP ranges (RFC-1918, link-local). Use only when the upstream is + // hosted inside the same cluster and has no public endpoint. + // +optional + AllowPrivateIPs bool `json:"allowPrivateIPs,omitempty"` } // OAuth2UpstreamConfig contains configuration for pure OAuth 2.0 providers. @@ -1149,6 +1162,13 @@ type OAuth2UpstreamConfig struct { // +optional AdditionalAuthorizationParams map[string]string `json:"additionalAuthorizationParams,omitempty"` + // CABundleRef references a ConfigMap containing a CA bundle added to the + // system roots when connecting to this upstream; it does not restrict trust + // to this bundle or disable public-root trust. The selected key is projected + // as ca.crt. + // +optional + CABundleRef *CABundleSource `json:"caBundleRef,omitempty"` + // InsecureAllowHTTP permits plain-HTTP authorization and token endpoint URLs // for this upstream. Only for in-cluster development environments (e.g. an // OAuth2 provider served over HTTP in a kind cluster) where TLS is not @@ -1717,6 +1737,17 @@ const ( // Validate() with an error other than the enterprise-required sentinel. // Used by out-of-tree handlers; unreachable in upstream-only builds. ConditionReasonInvalidConfig = "InvalidConfig" + + // ConditionReasonInvalidCABundle: a referenced upstream CA bundle ConfigMap + // is missing its key or holds content that is not a PEM certificate. + // + // Distinct from ConditionReasonInvalidConfig because the failing input is + // ConfigMap *content*, which is covered by neither metadata.generation nor + // the referenced config's spec hash. The guards that hold a terminal + // ConditionReasonInvalidConfig steady across reconciles key off those two + // values, so reusing that reason here would pin the failure in place even + // after the ConfigMap is repaired. + ConditionReasonInvalidCABundle = "InvalidCABundle" ) // XAASpec holds configuration for the XAA (Cross-Application Access) auth strategy. @@ -2079,6 +2110,10 @@ func (*MCPExternalAuthConfig) validateUpstreamProvider(index int, provider *Upst "and oauth2Config must be set when type is 'oauth2' (and the other must not be set)", prefix) } + if err := validateUpstreamProviderCABundle(prefix, provider); err != nil { + return err + } + // Validate OAuth2-specific constraints (defense-in-depth with CEL). // The discriminator above guarantees OAuth2Config != nil when type is oauth2. if provider.Type == UpstreamProviderTypeOAuth2 { @@ -2094,6 +2129,17 @@ func (*MCPExternalAuthConfig) validateUpstreamProvider(index int, provider *Upst return ValidateAdditionalAuthorizationParams(prefix, provider.AdditionalAuthorizationParams()) } +func validateUpstreamProviderCABundle(prefix string, provider *UpstreamProviderConfig) error { + field := "oidcConfig.caBundleRef" + if provider.Type == UpstreamProviderTypeOAuth2 { + field = "oauth2Config.caBundleRef" + } + if err := validateUpstreamCABundleRef(provider.CABundleRef()); err != nil { + return fmt.Errorf("%s: %s: %w", prefix, field, err) + } + return nil +} + // Length caps for DCR-related string fields. Mirror the // +kubebuilder:validation:MaxLength markers on DCRUpstreamConfig so that // ValidateOAuth2DCRConfig is a true reconcile-time backstop for length @@ -2108,6 +2154,22 @@ const ( MaxSoftwareStatementLength = 16384 ) +// validateUpstreamCABundleRef validates the source shape shared by OIDC and +// OAuth2 upstream CA bundles. ConfigMap existence and key contents are +// resolved by Kubernetes when the generated Pod is scheduled. +func validateUpstreamCABundleRef(ref *CABundleSource) error { + if ref == nil { + return nil + } + if ref.ConfigMapRef == nil { + return fmt.Errorf("configMapRef must be specified") + } + if ref.ConfigMapRef.Name == "" { + return fmt.Errorf("configMapRef.name must not be empty") + } + return nil +} + // ValidateOAuth2DCRConfig enforces the mutual exclusivity between ClientID and // DCRConfig, between ClientSecretRef and DCRConfig, and (when DCRConfig is // present) between DiscoveryURL and RegistrationEndpoint. It also enforces the @@ -2186,6 +2248,22 @@ func (p *UpstreamProviderConfig) AdditionalAuthorizationParams() map[string]stri return nil } +// CABundleRef returns the CA bundle reference for the provider's configured +// type, or nil when the type-matched config or the reference is absent. +func (p *UpstreamProviderConfig) CABundleRef() *CABundleSource { + switch p.Type { + case UpstreamProviderTypeOIDC: + if p.OIDCConfig != nil { + return p.OIDCConfig.CABundleRef + } + case UpstreamProviderTypeOAuth2: + if p.OAuth2Config != nil { + return p.OAuth2Config.CABundleRef + } + } + return nil +} + // SyntheticIdentityUpstreams returns the names of OAuth2 upstreams running // in synthesis mode (neither userInfo nor identityFromToken configured), // sorted lexically for deterministic condition messages. OIDC upstreams are diff --git a/cmd/thv-operator/api/v1beta1/mcpserver_types.go b/cmd/thv-operator/api/v1beta1/mcpserver_types.go index 63f21fcf1c..4edbca8d4a 100644 --- a/cmd/thv-operator/api/v1beta1/mcpserver_types.go +++ b/cmd/thv-operator/api/v1beta1/mcpserver_types.go @@ -127,6 +127,11 @@ const ( // which is not supported for MCPServer (use VirtualMCPServer for multi-upstream). ConditionReasonExternalAuthConfigMultiUpstream = "MultiUpstreamNotSupported" + // ConditionReasonExternalAuthConfigValid indicates the referenced + // MCPExternalAuthConfig passed every check the MCPServer controller applies + // to it. Mirrors ConditionReasonMCPRemoteProxyExternalAuthConfigValid. + ConditionReasonExternalAuthConfigValid = "ExternalAuthConfigValid" + // ConditionReasonWebhookConfigInvalid indicates the referenced webhook config is invalid or missing ConditionReasonWebhookConfigInvalid = "WebhookConfigInvalid" @@ -666,7 +671,8 @@ type OutboundNetworkPermissions struct { // CABundleSource defines a source for CA certificate bundles. type CABundleSource struct { // ConfigMapRef references a ConfigMap containing the CA certificate bundle. - // If Key is not specified, it defaults to "ca.crt". + // The ConfigMap key is required by the API. If omitted in a stored object, it + // defaults to "ca.crt" for backwards compatibility. // +optional ConfigMapRef *corev1.ConfigMapKeySelector `json:"configMapRef,omitempty"` } diff --git a/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go b/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go index 31aa762325..064d339d0d 100644 --- a/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go +++ b/cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go @@ -2324,6 +2324,11 @@ func (in *OAuth2UpstreamConfig) DeepCopyInto(out *OAuth2UpstreamConfig) { (*out)[key] = val } } + if in.CABundleRef != nil { + in, out := &in.CABundleRef, &out.CABundleRef + *out = new(CABundleSource) + (*in).DeepCopyInto(*out) + } if in.DCRConfig != nil { in, out := &in.DCRConfig, &out.DCRConfig *out = new(DCRUpstreamConfig) @@ -2396,6 +2401,11 @@ func (in *OIDCUpstreamConfig) DeepCopyInto(out *OIDCUpstreamConfig) { (*out)[key] = val } } + if in.CABundleRef != nil { + in, out := &in.CABundleRef, &out.CABundleRef + *out = new(CABundleSource) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OIDCUpstreamConfig. diff --git a/cmd/thv-operator/app/app.go b/cmd/thv-operator/app/app.go index 2aad8b882c..db0b282fb5 100644 --- a/cmd/thv-operator/app/app.go +++ b/cmd/thv-operator/app/app.go @@ -215,6 +215,19 @@ func isStorageVersionMigratorEnabled() (bool, error) { return enabled, nil } +// setupEmbeddedAuthCABundleFieldIndex sets up the shared CA ConfigMap reference index. +func setupEmbeddedAuthCABundleFieldIndex(mgr ctrl.Manager) error { + if err := mgr.GetFieldIndexer().IndexField( + context.Background(), + &mcpv1beta1.MCPExternalAuthConfig{}, + controllers.EmbeddedAuthCABundleConfigMapIndex, + controllers.IndexEmbeddedAuthCABundleConfigMaps, + ); err != nil { + return fmt.Errorf("index MCPExternalAuthConfig CA bundle references: %w", err) + } + return nil +} + // setupGroupRefFieldIndexes sets up field indexing for spec.groupRef on all resource types // that can reference an MCPGroup. This enables efficient lookups by groupRef in controllers. func setupGroupRefFieldIndexes(mgr ctrl.Manager) error { @@ -277,6 +290,9 @@ func setupGroupRefFieldIndexes(mgr ctrl.Manager) error { // imagePullSecretsDefaults are merged with per-CR imagePullSecrets when // reconcilers construct workloads. func setupServerControllers(mgr ctrl.Manager, imagePullSecretsDefaults imagepullsecrets.Defaults) error { + if err := setupEmbeddedAuthCABundleFieldIndex(mgr); err != nil { + return err + } if err := setupGroupRefFieldIndexes(mgr); err != nil { return err } diff --git a/cmd/thv-operator/controllers/authserver_cabundle_mapper_test.go b/cmd/thv-operator/controllers/authserver_cabundle_mapper_test.go new file mode 100644 index 0000000000..7e176cd8e5 --- /dev/null +++ b/cmd/thv-operator/controllers/authserver_cabundle_mapper_test.go @@ -0,0 +1,187 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package controllers + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1/v1beta1test" + "github.com/stacklok/toolhive/cmd/thv-operator/internal/testutil" + ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" + "github.com/stacklok/toolhive/pkg/container/kubernetes" +) + +func mapperTestCertificatePEM(t *testing.T) []byte { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + template := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test"}, NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(time.Hour), IsCA: true, BasicConstraintsValid: true} + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + require.NoError(t, err) + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) +} + +func TestMCPRemoteProxyCABundleChecksumDrift(t *testing.T) { + t.Parallel() + scheme := testutil.NewScheme(t) + ca := mapperTestCertificatePEM(t) + cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "ca", Namespace: "default"}, Data: map[string]string{"ca.crt": string(ca)}} + auth := mapperCABundleConfig("ca") + proxy := v1beta1test.NewMCPRemoteProxy("proxy", "default", v1beta1test.WithRemoteProxyExternalAuthConfigRef("auth")) + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cm, auth, proxy).Build() + r := &MCPRemoteProxyReconciler{Client: c, Scheme: scheme, PlatformDetector: ctrlutil.NewSharedPlatformDetector()} + deployment := r.deploymentForMCPRemoteProxy(t.Context(), proxy, "run") + require.NotNil(t, deployment) + checksum := deployment.Spec.Template.Annotations[ctrlutil.AuthServerCABundleChecksumAnnotation] + require.NotEmpty(t, checksum) + assert.False(t, r.podTemplateMetadataNeedsUpdate(t.Context(), deployment, proxy, "run")) + cm.Data["ca.crt"] = string(mapperTestCertificatePEM(t)) + require.NoError(t, c.Update(t.Context(), cm)) + assert.True(t, r.podTemplateMetadataNeedsUpdate(t.Context(), deployment, proxy, "run")) + deployment.Spec.Template.Annotations[ctrlutil.AuthServerCABundleChecksumAnnotation] = "stale" + assert.True(t, r.podTemplateMetadataNeedsUpdate(t.Context(), deployment, proxy, "run")) + newChecksum, err := ctrlutil.EmbeddedAuthServerCABundleChecksum(t.Context(), c, "default", "auth") + require.NoError(t, err) + deployment.Spec.Template.Annotations[ctrlutil.AuthServerCABundleChecksumAnnotation] = newChecksum + assert.False(t, r.podTemplateMetadataNeedsUpdate(t.Context(), deployment, proxy, "run")) + auth.Spec.EmbeddedAuthServer.UpstreamProviders[0].OIDCConfig.CABundleRef = nil + require.NoError(t, c.Update(t.Context(), auth)) + assert.True(t, r.podTemplateMetadataNeedsUpdate(t.Context(), deployment, proxy, "run")) +} + +func mapperCABundleConfig(caName string) *mcpv1beta1.MCPExternalAuthConfig { + return &mcpv1beta1.MCPExternalAuthConfig{ObjectMeta: metav1.ObjectMeta{Name: "auth", Namespace: "default"}, Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1beta1.EmbeddedAuthServerConfig{UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{{ + Name: "issuer", Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{CABundleRef: &mcpv1beta1.CABundleSource{ConfigMapRef: &corev1.ConfigMapKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: caName}}}}, + }}}, + }} +} + +func TestMCPServerCABundleChecksumDrift(t *testing.T) { + t.Parallel() + scheme := testutil.NewScheme(t) + ca := mapperTestCertificatePEM(t) + cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "ca", Namespace: "default"}, Data: map[string]string{"ca.crt": string(ca)}} + auth := mapperCABundleConfig("ca") + server := v1beta1test.NewMCPServer("server", "default", v1beta1test.WithExternalAuthConfigRef("auth")) + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cm, auth, server).Build() + r := newTestMCPServerReconciler(c, scheme, kubernetes.PlatformKubernetes) + deployment, err := r.deploymentForMCPServer(t.Context(), server, "run") + require.NoError(t, err) + checksum := deployment.Spec.Template.Annotations[ctrlutil.AuthServerCABundleChecksumAnnotation] + require.NotEmpty(t, checksum) + assert.False(t, r.deploymentNeedsUpdate(t.Context(), deployment, server, "run"), + "matching checksum must not be reported as drift") + + // Rotating the CA content must be detected. Mutate the ConfigMap rather than + // writing a literal so the checksum function itself is exercised. + cm.Data["ca.crt"] = string(mapperTestCertificatePEM(t)) + require.NoError(t, c.Update(t.Context(), cm)) + assert.True(t, r.deploymentNeedsUpdate(t.Context(), deployment, server, "run"), + "changed CA bundle content must be reported as drift") + + rotated, err := ctrlutil.EmbeddedAuthServerCABundleChecksum(t.Context(), c, "default", "auth") + require.NoError(t, err) + require.NotEqual(t, checksum, rotated) + deployment.Spec.Template.Annotations[ctrlutil.AuthServerCABundleChecksumAnnotation] = rotated + + // A foreign annotation written by someone else (kubectl rollout restart and + // friends) must not be reverted as drift -- see #6344. + deployment.Spec.Template.Annotations["foreign.example/key"] = "preserve" + assert.False(t, r.deploymentNeedsUpdate(t.Context(), deployment, server, "run"), + "externally-written annotations must not be treated as drift") + auth.Spec.EmbeddedAuthServer.UpstreamProviders[0].OIDCConfig.CABundleRef = nil + require.NoError(t, c.Update(t.Context(), auth)) + assert.True(t, r.deploymentNeedsUpdate(t.Context(), deployment, server, "run"), + "removing caBundleRef must be reported as drift so the stale annotation is cleared") +} + +func TestMapAuthServerCABundleConfigMapToMCPServer(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + configMapNamespace string + config *mcpv1beta1.MCPExternalAuthConfig + serverNamespace string + want int + }{ + {"referenced", "default", mapperCABundleConfig("ca"), "default", 1}, + {"unreferenced", "default", mapperCABundleConfig("other"), "default", 0}, + {"different namespace", "other", mapperCABundleConfig("ca"), "default", 0}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + scheme := testutil.NewScheme(t) + cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "ca", Namespace: tc.configMapNamespace}} + server := v1beta1test.NewMCPServer("server", tc.serverNamespace, v1beta1test.WithExternalAuthConfigRef("auth")) + objects := []client.Object{cm, tc.config, server} + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).WithIndex(&mcpv1beta1.MCPExternalAuthConfig{}, EmbeddedAuthCABundleConfigMapIndex, IndexEmbeddedAuthCABundleConfigMaps).WithIndex(&mcpv1beta1.MCPServer{}, EmbeddedAuthConfigIndex, indexMCPServerEmbeddedAuthConfig).Build() + r := &MCPServerReconciler{Client: c} + requests := r.mapAuthServerCABundleConfigMapToMCPServer(t.Context(), cm) + assert.Len(t, requests, tc.want) + }) + } +} + +func TestMapAuthServerCABundleConfigMapToMCPRemoteProxy(t *testing.T) { + t.Parallel() + scheme := testutil.NewScheme(t) + auth := mapperCABundleConfig("ca") + proxy1 := v1beta1test.NewMCPRemoteProxy("proxy-one", "default", v1beta1test.WithRemoteProxyExternalAuthConfigRef("auth")) + proxy2 := v1beta1test.NewMCPRemoteProxy("proxy-two", "default", v1beta1test.WithRemoteProxyExternalAuthConfigRef("auth")) + cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "ca", Namespace: "default"}} + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cm, auth, proxy1, proxy2). + WithIndex(&mcpv1beta1.MCPExternalAuthConfig{}, EmbeddedAuthCABundleConfigMapIndex, IndexEmbeddedAuthCABundleConfigMaps). + WithIndex(&mcpv1beta1.MCPRemoteProxy{}, EmbeddedAuthConfigIndex, indexMCPRemoteProxyEmbeddedAuthConfig).Build() + r := &MCPRemoteProxyReconciler{Client: c} + requests := r.mapAuthServerCABundleConfigMapToMCPRemoteProxy(t.Context(), cm) + assert.Len(t, requests, 2) +} + +// TestVirtualMCPServerCABundleChecksumDrift guards the vMCP half of the rotation +// path. The bundle is mounted with subPath, so kubelet never refreshes it in a +// running pod: only a pod template change rolls the new trust material out. The +// checksum annotation is the mechanism, and buildPodTemplateMetadata is shared by +// the builder and the drift check so the two cannot disagree. +func TestVirtualMCPServerCABundleChecksumDrift(t *testing.T) { + t.Parallel() + scheme := testutil.NewScheme(t) + r := &VirtualMCPServerReconciler{Scheme: scheme, PlatformDetector: ctrlutil.NewSharedPlatformDetector()} + vmcp := v1beta1test.NewVirtualMCPServer("vmcp", "default", v1beta1test.WithVMCPGroupRef("group")) + + withBundle := r.deploymentForVirtualMCPServer(t.Context(), vmcp, "run", "checksum-a", nil, nil) + require.NotNil(t, withBundle) + assert.Equal(t, "checksum-a", + withBundle.Spec.Template.Annotations[ctrlutil.AuthServerCABundleChecksumAnnotation], + "the CA checksum must reach the pod template, otherwise a rotation never rolls out") + + rotated := r.deploymentForVirtualMCPServer(t.Context(), vmcp, "run", "checksum-b", nil, nil) + require.NotNil(t, rotated) + assert.NotEqual(t, + withBundle.Spec.Template.Annotations[ctrlutil.AuthServerCABundleChecksumAnnotation], + rotated.Spec.Template.Annotations[ctrlutil.AuthServerCABundleChecksumAnnotation], + "a rotated bundle must produce a different pod template") + + none := r.deploymentForVirtualMCPServer(t.Context(), vmcp, "run", "", nil, nil) + require.NotNil(t, none) + assert.NotContains(t, none.Spec.Template.Annotations, ctrlutil.AuthServerCABundleChecksumAnnotation, + "no CA bundle configured must not stamp an empty annotation") +} diff --git a/cmd/thv-operator/controllers/external_auth_mirror.go b/cmd/thv-operator/controllers/external_auth_mirror.go index 4ca5af13d9..4f3f0f0eaf 100644 --- a/cmd/thv-operator/controllers/external_auth_mirror.go +++ b/cmd/thv-operator/controllers/external_auth_mirror.go @@ -71,30 +71,37 @@ func mirroredReasonFromError(err error) string { return "" } -// ownedByEmbeddedAuthServerConfigValidation reports whether conditions' -// entry for conditionType (if any) was set by -// handleInvalidEmbeddedAuthServerConfig rather than by the mirror itself. -// That handler is a distinct owner of the same condition type — it fires -// when the assembled RunConfig fails to build (e.g. delegate clients without -// an OIDC config) — and its Reason is always the fixed -// mcpv1beta1.ConditionReasonInvalidConfig, so an exact match reliably -// identifies it regardless of the mirror's own (source-derived, unbounded) -// Reason values. -func ownedByEmbeddedAuthServerConfigValidation(conditions []metav1.Condition, conditionType string) bool { +// ownedByLocalValidation reports whether conditions' entry for conditionType +// (if any) was set by this operator's own validation rather than mirrored from +// the referenced MCPExternalAuthConfig. Two distinct owners qualify, each with +// a fixed Reason that an exact match identifies reliably regardless of the +// mirror's own (source-derived, unbounded) Reason values: +// +// - handleInvalidEmbeddedAuthServerConfig, ConditionReasonInvalidConfig, set +// when the assembled RunConfig fails to build (e.g. delegate clients +// without an OIDC config). +// - the upstream CA bundle check, ConditionReasonInvalidCABundle, set when a +// referenced bundle ConfigMap is missing its key or holds non-PEM content. +// +// Both re-derive the same condition a few lines after the mirror runs, so +// clearing it here would force a fresh LastTransitionTime on every reconcile. +func ownedByLocalValidation(conditions []metav1.Condition, conditionType string) bool { existing := meta.FindStatusCondition(conditions, conditionType) - return existing != nil && existing.Reason == mcpv1beta1.ConditionReasonInvalidConfig + if existing == nil { + return false + } + return existing.Reason == mcpv1beta1.ConditionReasonInvalidConfig || + existing.Reason == mcpv1beta1.ConditionReasonInvalidCABundle } // mirrorInvalidOnMCPServer mirrors the source's Valid=False condition onto the // MCPServer's ExternalAuthConfigValidated condition. When the source is healed // (Valid=True or absent), it clears any stale mirror so the condition does not // outlive its cause — unless the condition currently reflects a different -// owner's failure (handleInvalidEmbeddedAuthServerConfig's -// ConditionReasonInvalidConfig, set when the assembled RunConfig itself is -// invalid, e.g. delegate clients configured without OIDC). Clearing that -// condition here would erase the other owner's terminal failure a step before -// it re-derives the same failure, forcing a fresh LastTransitionTime on every -// reconcile even though nothing changed. Returns (true, err) when a False +// owner's failure (see ownedByLocalValidation). Clearing that condition here +// would erase the other owner's terminal failure a step before it re-derives +// the same failure, forcing a fresh LastTransitionTime on every reconcile even +// though nothing changed. Returns (true, err) when a False // mirror was written so the caller can mark Phase=Failed; (false, nil) // otherwise. // @@ -105,7 +112,7 @@ func mirrorInvalidOnMCPServer( ) (bool, error) { mirrored := mirroredExternalAuthConfigInvalid(externalAuthConfig) if mirrored == nil { - if !ownedByEmbeddedAuthServerConfigValidation(m.Status.Conditions, mcpv1beta1.ConditionTypeExternalAuthConfigValidated) { + if !ownedByLocalValidation(m.Status.Conditions, mcpv1beta1.ConditionTypeExternalAuthConfigValidated) { meta.RemoveStatusCondition(&m.Status.Conditions, mcpv1beta1.ConditionTypeExternalAuthConfigValidated) } return false, nil @@ -126,9 +133,7 @@ func mirrorInvalidOnMCPServer( // writer in handleExternalAuthConfig also sets the success reason, but a // future early return between this site and that writer would otherwise leak // a stale False. It does NOT clear the condition when it currently reflects a -// different owner's failure (handleInvalidEmbeddedAuthServerConfig's -// ConditionReasonInvalidConfig, set when the assembled RunConfig itself is -// invalid, e.g. delegate clients configured without OIDC): removing it here +// different owner's failure (see ownedByLocalValidation): removing it here // would erase that owner's terminal failure a step before it gets re-derived // unchanged, forcing setMCPRemoteProxyExternalAuthConfigValidCondition to // treat it as new and stamp a fresh LastTransitionTime on every reconcile @@ -141,7 +146,7 @@ func mirrorInvalidOnRemoteProxy( mirrored := mirroredExternalAuthConfigInvalid(externalAuthConfig) if mirrored == nil { condType := mcpv1beta1.ConditionTypeMCPRemoteProxyExternalAuthConfigValidated - if !ownedByEmbeddedAuthServerConfigValidation(proxy.Status.Conditions, condType) { + if !ownedByLocalValidation(proxy.Status.Conditions, condType) { meta.RemoveStatusCondition(&proxy.Status.Conditions, condType) } return false, nil diff --git a/cmd/thv-operator/controllers/mcpremoteproxy_authserver_cabundle_configmap.go b/cmd/thv-operator/controllers/mcpremoteproxy_authserver_cabundle_configmap.go new file mode 100644 index 0000000000..306a7712d6 --- /dev/null +++ b/cmd/thv-operator/controllers/mcpremoteproxy_authserver_cabundle_configmap.go @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package controllers + +import ( + "context" + + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" +) + +// mapAuthServerCABundleConfigMapToMCPRemoteProxy enqueues proxies whose +// referenced embedded auth-server configuration uses the changed ConfigMap. +// References are namespace-local and resolved through field indexes. +func (r *MCPRemoteProxyReconciler) mapAuthServerCABundleConfigMapToMCPRemoteProxy( + ctx context.Context, obj client.Object) []reconcile.Request { + var configs mcpv1beta1.MCPExternalAuthConfigList + if err := r.List(ctx, &configs, + client.InNamespace(obj.GetNamespace()), + client.MatchingFields{EmbeddedAuthCABundleConfigMapIndex: obj.GetName()}, + ); err != nil { + log.FromContext(ctx).Error(err, "Failed to list MCPExternalAuthConfigs for CA bundle ConfigMap watch") + return nil + } + + requests := make([]reconcile.Request, 0) + for i := range configs.Items { + var proxies mcpv1beta1.MCPRemoteProxyList + if err := r.List(ctx, &proxies, + client.InNamespace(obj.GetNamespace()), + client.MatchingFields{EmbeddedAuthConfigIndex: configs.Items[i].Name}, + ); err != nil { + log.FromContext(ctx).Error(err, "Failed to list MCPRemoteProxies for CA bundle ConfigMap watch") + return nil + } + for j := range proxies.Items { + requests = append(requests, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&proxies.Items[j])}) + } + } + return requests +} diff --git a/cmd/thv-operator/controllers/mcpremoteproxy_authserverref_test.go b/cmd/thv-operator/controllers/mcpremoteproxy_authserverref_test.go index 83065dd5df..380c34da0f 100644 --- a/cmd/thv-operator/controllers/mcpremoteproxy_authserverref_test.go +++ b/cmd/thv-operator/controllers/mcpremoteproxy_authserverref_test.go @@ -11,14 +11,19 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1/v1beta1test" + "github.com/stacklok/toolhive/cmd/thv-operator/internal/testutil" ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" ) @@ -256,3 +261,159 @@ func TestMCPRemoteProxyReconciler_InvalidExternalAuthConfigSteadyState(t *testin require.NoError(t, fakeClient.Get(t.Context(), req.NamespacedName, actual)) assert.Equal(t, initial.Status, actual.Status) } + +// authServerRefProxyCABundleConfig returns an embedded auth server config whose +// only defect is whatever state the referenced "bundle" ConfigMap is left in. +func authServerRefProxyCABundleConfig() *mcpv1beta1.MCPExternalAuthConfig { + return &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "auth", Namespace: "default"}, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1beta1.EmbeddedAuthServerConfig{ + Issuer: "https://auth.example.com", + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{{Name: "signing-key", Key: "private.pem"}}, + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{{Name: "hmac-secret", Key: "hmac"}}, + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{{ + Name: "upstream", + Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{ + IssuerURL: "https://idp.example.com", + ClientID: "client", + CABundleRef: &mcpv1beta1.CABundleSource{ConfigMapRef: &corev1.ConfigMapKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: "bundle"}, Key: "ca.crt", + }}, + }, + }}, + }, + }, + } +} + +// A bundle ConfigMap without the key it names is a content error no retry can +// fix, so it must be recorded on AuthServerRefValidated and not requeued. +func TestMCPRemoteProxyReconciler_AuthServerRefInvalidCABundleIsTerminal(t *testing.T) { + t.Parallel() + + proxy := v1beta1test.NewMCPRemoteProxy("proxy", "default", + v1beta1test.WithRemoteProxyURL("https://remote.example.com"), + v1beta1test.WithRemoteProxyAuthServerRef("MCPExternalAuthConfig", "auth"), + ) + bundle := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "bundle", Namespace: "default"}} + reconciler, fakeClient := newTestMCPRemoteProxyReconciler(t, proxy, authServerRefProxyCABundleConfig(), bundle) + + result, err := reconciler.Reconcile(t.Context(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(proxy)}) + require.NoError(t, err, "a terminal CA bundle failure must not requeue") + assert.Zero(t, result) + + actual := &mcpv1beta1.MCPRemoteProxy{} + require.NoError(t, fakeClient.Get(t.Context(), client.ObjectKeyFromObject(proxy), actual)) + assert.Equal(t, mcpv1beta1.MCPRemoteProxyPhaseFailed, actual.Status.Phase) + condition := meta.FindStatusCondition( + actual.Status.Conditions, mcpv1beta1.ConditionTypeMCPRemoteProxyAuthServerRefValidated) + require.NotNil(t, condition) + assert.Equal(t, metav1.ConditionFalse, condition.Status) + assert.Equal(t, mcpv1beta1.ConditionReasonInvalidCABundle, condition.Reason) + assert.Nil(t, meta.FindStatusCondition( + actual.Status.Conditions, mcpv1beta1.ConditionTypeMCPRemoteProxyExternalAuthConfigValidated), + "a bundle reached through authServerRef must not be recorded against externalAuthConfigRef") +} + +// The bundle is repaired without touching the MCPRemoteProxy or the referenced +// config, so neither generation nor the config hash changes. +func TestMCPRemoteProxyReconciler_AuthServerRefCABundleRepairRestoresTrue(t *testing.T) { + t.Parallel() + + proxy := v1beta1test.NewMCPRemoteProxy("proxy", "default", + v1beta1test.WithRemoteProxyURL("https://remote.example.com"), + v1beta1test.WithRemoteProxyAuthServerRef("MCPExternalAuthConfig", "auth"), + ) + bundle := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "bundle", Namespace: "default"}} + reconciler, fakeClient := newTestMCPRemoteProxyReconciler(t, proxy, authServerRefProxyCABundleConfig(), bundle) + + require.Error(t, reconciler.handleAuthServerRef(t.Context(), proxy)) + broken := meta.FindStatusCondition( + proxy.Status.Conditions, mcpv1beta1.ConditionTypeMCPRemoteProxyAuthServerRefValidated) + require.NotNil(t, broken) + require.Equal(t, mcpv1beta1.ConditionReasonInvalidCABundle, broken.Reason) + + repaired := &corev1.ConfigMap{} + require.NoError(t, fakeClient.Get(t.Context(), client.ObjectKeyFromObject(bundle), repaired)) + repaired.Data = map[string]string{"ca.crt": string(mapperTestCertificatePEM(t))} + require.NoError(t, fakeClient.Update(t.Context(), repaired)) + + require.NoError(t, reconciler.handleAuthServerRef(t.Context(), proxy)) + healed := meta.FindStatusCondition( + proxy.Status.Conditions, mcpv1beta1.ConditionTypeMCPRemoteProxyAuthServerRefValidated) + require.NotNil(t, healed) + assert.Equal(t, metav1.ConditionTrue, healed.Status, + "repairing the ConfigMap must clear the CA bundle failure") + assert.Equal(t, mcpv1beta1.ConditionReasonMCPRemoteProxyAuthServerRefValid, healed.Reason) +} + +// A ConfigMap read that fails for reasons unrelated to its content is transient +// and must requeue rather than be painted as a terminal spec error. +func TestMCPRemoteProxyReconciler_AuthServerRefCABundleGetErrorRequeues(t *testing.T) { + t.Parallel() + + proxy := v1beta1test.NewMCPRemoteProxy("proxy", "default", + v1beta1test.WithRemoteProxyURL("https://remote.example.com"), + v1beta1test.WithRemoteProxyAuthServerRef("MCPExternalAuthConfig", "auth"), + ) + scheme := testutil.NewScheme(t) + fakeClient := fake.NewClientBuilder().WithScheme(scheme). + WithObjects(proxy, authServerRefProxyCABundleConfig(), + &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "bundle", Namespace: "default"}}). + WithStatusSubresource(&mcpv1beta1.MCPRemoteProxy{}). + WithInterceptorFuncs(interceptor.Funcs{ + Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if _, ok := obj.(*corev1.ConfigMap); ok && key.Name == "bundle" { + return apierrors.NewServiceUnavailable("apiserver is having a moment") + } + return c.Get(ctx, key, obj, opts...) + }, + }). + Build() + reconciler := &MCPRemoteProxyReconciler{Client: fakeClient, Scheme: scheme} + + err := reconciler.handleAuthServerRef(t.Context(), proxy) + require.Error(t, err, "a transient read failure must surface so the caller requeues") + condition := meta.FindStatusCondition( + proxy.Status.Conditions, mcpv1beta1.ConditionTypeMCPRemoteProxyAuthServerRefValidated) + if condition != nil { + assert.NotEqual(t, mcpv1beta1.ConditionReasonInvalidCABundle, condition.Reason, + "a transient read failure must not be recorded as a terminal bundle error") + } +} + +// An unchanged terminal failure must be re-derived to the identical condition, +// LastTransitionTime included. +func TestMCPRemoteProxyReconciler_AuthServerRefInvalidCABundleSteadyState(t *testing.T) { + t.Parallel() + + proxy := v1beta1test.NewMCPRemoteProxy("proxy", "default", + v1beta1test.WithRemoteProxyURL("https://remote.example.com"), + v1beta1test.WithRemoteProxyAuthServerRef("MCPExternalAuthConfig", "auth"), + ) + bundle := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "bundle", Namespace: "default"}} + reconciler, fakeClient := newTestMCPRemoteProxyReconciler(t, proxy, authServerRefProxyCABundleConfig(), bundle) + req := ctrl.Request{NamespacedName: client.ObjectKeyFromObject(proxy)} + + _, err := reconciler.Reconcile(t.Context(), req) + require.NoError(t, err) + first := &mcpv1beta1.MCPRemoteProxy{} + require.NoError(t, fakeClient.Get(t.Context(), client.ObjectKeyFromObject(proxy), first)) + + _, err = reconciler.Reconcile(t.Context(), req) + require.NoError(t, err) + second := &mcpv1beta1.MCPRemoteProxy{} + require.NoError(t, fakeClient.Get(t.Context(), client.ObjectKeyFromObject(proxy), second)) + + firstCondition := meta.FindStatusCondition( + first.Status.Conditions, mcpv1beta1.ConditionTypeMCPRemoteProxyAuthServerRefValidated) + secondCondition := meta.FindStatusCondition( + second.Status.Conditions, mcpv1beta1.ConditionTypeMCPRemoteProxyAuthServerRefValidated) + require.NotNil(t, firstCondition) + require.NotNil(t, secondCondition) + assert.Equal(t, *firstCondition, *secondCondition, + "re-deriving an unchanged terminal CA failure must leave the condition untouched") +} diff --git a/cmd/thv-operator/controllers/mcpremoteproxy_controller.go b/cmd/thv-operator/controllers/mcpremoteproxy_controller.go index c46d688ae1..89e7476d1a 100644 --- a/cmd/thv-operator/controllers/mcpremoteproxy_controller.go +++ b/cmd/thv-operator/controllers/mcpremoteproxy_controller.go @@ -23,6 +23,7 @@ import ( "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/tools/events" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/log" @@ -50,6 +51,14 @@ type MCPRemoteProxyReconciler struct { var errInvalidMCPRemoteProxyPodTemplateSpec = stderrors.New("invalid MCPRemoteProxy PodTemplateSpec") +// errTerminalCABundleHandled reports that a terminal upstream CA bundle failure +// has already been recorded on the right condition, so Reconcile must stop +// without requeueing. validateAndHandleConfigs cannot let such an error escape +// unwrapped: the unwrap at the Reconcile boundary cannot tell which reference +// the bundle was reached through, and would record it against +// ExternalAuthConfigValidated even when it came from authServerRef. +var errTerminalCABundleHandled = stderrors.New("terminal upstream CA bundle failure recorded") + // +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcpremoteproxies,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcpremoteproxies/status,verbs=get;update;patch // +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcptoolconfigs,verbs=get;list;watch @@ -106,6 +115,36 @@ func (r *MCPRemoteProxyReconciler) handleInvalidEmbeddedAuthServerConfig( }) } +// handleInvalidUpstreamCABundle records an invalid upstream CA bundle as terminal +// on conditionType, which the caller picks because it knows which reference the +// bundle was reached through. Failures to persist status remain retryable. +func (r *MCPRemoteProxyReconciler) handleInvalidUpstreamCABundle( + ctx context.Context, + proxy *mcpv1beta1.MCPRemoteProxy, + conditionType string, + invalidCABundleErr *ctrlutil.InvalidCABundleError, +) error { + return ctrlutil.MutateAndPatchStatus(ctx, r.Client, proxy, func(remoteProxy *mcpv1beta1.MCPRemoteProxy) { + remoteProxy.Status.Phase = mcpv1beta1.MCPRemoteProxyPhaseFailed + remoteProxy.Status.Message = fmt.Sprintf("Failed to build configuration: %s", invalidCABundleErr) + remoteProxy.Status.ObservedGeneration = remoteProxy.Generation + meta.SetStatusCondition(&remoteProxy.Status.Conditions, metav1.Condition{ + Type: mcpv1beta1.ConditionTypeReady, + Status: metav1.ConditionFalse, + ObservedGeneration: remoteProxy.Generation, + Reason: mcpv1beta1.ConditionReasonNotReady, + Message: remoteProxy.Status.Message, + }) + meta.SetStatusCondition(&remoteProxy.Status.Conditions, metav1.Condition{ + Type: conditionType, + Status: metav1.ConditionFalse, + ObservedGeneration: remoteProxy.Generation, + Reason: mcpv1beta1.ConditionReasonInvalidCABundle, + Message: fmt.Sprintf("invalid upstream CA bundle: %v", invalidCABundleErr), + }) + }) +} + // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. func (r *MCPRemoteProxyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { @@ -125,7 +164,17 @@ func (r *MCPRemoteProxyReconciler) Reconcile(ctx context.Context, req ctrl.Reque // Validate and handle configurations if err := r.validateAndHandleConfigs(ctx, proxy); err != nil { - if stderrors.Is(err, errInvalidMCPRemoteProxyPodTemplateSpec) { + var invalidCABundleErr *ctrlutil.InvalidCABundleError + if stderrors.As(err, &invalidCABundleErr) { + if statusErr := r.handleInvalidUpstreamCABundle( + ctx, proxy, mcpv1beta1.ConditionTypeMCPRemoteProxyExternalAuthConfigValidated, invalidCABundleErr); statusErr != nil { + ctxLogger.Error(statusErr, "Failed to update MCPRemoteProxy status after invalid upstream CA bundle") + return ctrl.Result{}, statusErr + } + return ctrl.Result{}, nil + } + if stderrors.Is(err, errInvalidMCPRemoteProxyPodTemplateSpec) || + stderrors.Is(err, errTerminalCABundleHandled) { return ctrl.Result{}, nil } return ctrl.Result{}, err @@ -154,6 +203,28 @@ func (r *MCPRemoteProxyReconciler) Reconcile(ctx context.Context, req ctrl.Reque } // validateAndHandleConfigs validates spec and handles referenced configurations +// handleAuthServerRefCABundleError records a terminal upstream CA bundle failure +// reached through authServerRef, against that ref's own condition. The unwrap at +// the Reconcile boundary cannot tell the two reference paths apart, so it would +// otherwise attribute the failure to externalAuthConfigRef. +// +// Returns handled=false when err is not a terminal CA bundle error, leaving the +// caller to apply its own handling. +func (r *MCPRemoteProxyReconciler) handleAuthServerRefCABundleError( + ctx context.Context, proxy *mcpv1beta1.MCPRemoteProxy, err error, +) (bool, error) { + var invalidCABundleErr *ctrlutil.InvalidCABundleError + if !stderrors.As(err, &invalidCABundleErr) { + return false, nil + } + if statusErr := r.handleInvalidUpstreamCABundle( + ctx, proxy, mcpv1beta1.ConditionTypeMCPRemoteProxyAuthServerRefValidated, invalidCABundleErr); statusErr != nil { + log.FromContext(ctx).Error(statusErr, "Failed to update MCPRemoteProxy status after invalid CA bundle") + return true, statusErr + } + return true, errTerminalCABundleHandled +} + func (r *MCPRemoteProxyReconciler) validateAndHandleConfigs(ctx context.Context, proxy *mcpv1beta1.MCPRemoteProxy) error { ctxLogger := log.FromContext(ctx) @@ -205,6 +276,10 @@ func (r *MCPRemoteProxyReconciler) validateAndHandleConfigs(ctx context.Context, // Handle authServerRef config hash tracking if err := r.handleAuthServerRef(ctx, proxy); err != nil { + if handled, handledErr := r.handleAuthServerRefCABundleError(ctx, proxy, err); handled { + return handledErr + } + ctxLogger.Error(err, "Failed to handle authServerRef") proxy.Status.Phase = mcpv1beta1.MCPRemoteProxyPhaseFailed if statusErr := r.Status().Update(ctx, proxy); statusErr != nil { @@ -908,6 +983,24 @@ func (r *MCPRemoteProxyReconciler) handleExternalAuthConfig(ctx context.Context, return err } + // Resolve upstream CA dependencies before allowing any workload changes. + if embeddedCfg := externalAuthConfig.Spec.EmbeddedAuthServer; embeddedCfg != nil { + if err := ctrlutil.ValidateEmbeddedAuthServerCABundles(ctx, r.Client, proxy.Namespace, embeddedCfg); err != nil { + // Only a content error is terminal. A failed ConfigMap read may be + // transient, and must requeue rather than be recorded as a spec defect. + var invalidCABundleErr *ctrlutil.InvalidCABundleError + if !stderrors.As(err, &invalidCABundleErr) { + return err + } + meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{ + Type: mcpv1beta1.ConditionTypeMCPRemoteProxyExternalAuthConfigValidated, Status: metav1.ConditionFalse, + Reason: mcpv1beta1.ConditionReasonInvalidCABundle, Message: fmt.Sprintf("invalid upstream CA bundle: %v", err), + ObservedGeneration: proxy.Generation, + }) + return err + } + } + // MCPRemoteProxy supports only single-upstream embedded auth server configs. // Multi-upstream requires VirtualMCPServer. if embeddedCfg := externalAuthConfig.Spec.EmbeddedAuthServer; embeddedCfg != nil && len(embeddedCfg.UpstreamProviders) > 1 { @@ -1054,6 +1147,29 @@ func (r *MCPRemoteProxyReconciler) handleAuthServerRef(ctx context.Context, prox proxy.Namespace, proxy.Name, len(embeddedCfg.UpstreamProviders)) } + // Resolve upstream CA dependencies before allowing any workload changes. + // Mirrors handleExternalAuthConfig: without this the first notice of a bad + // bundle is Deployment construction, which reports a generic build failure + // and requeues instead of recording a terminal condition here. + if embeddedCfg := authConfig.Spec.EmbeddedAuthServer; embeddedCfg != nil { + if err := ctrlutil.ValidateEmbeddedAuthServerCABundles(ctx, r.Client, proxy.Namespace, embeddedCfg); err != nil { + // Only a content error is terminal. A failed ConfigMap read may be + // transient, and must requeue rather than be recorded as a spec defect. + var invalidCABundleErr *ctrlutil.InvalidCABundleError + if !stderrors.As(err, &invalidCABundleErr) { + return err + } + meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{ + Type: mcpv1beta1.ConditionTypeMCPRemoteProxyAuthServerRefValidated, + Status: metav1.ConditionFalse, + Reason: mcpv1beta1.ConditionReasonInvalidCABundle, + Message: fmt.Sprintf("invalid upstream CA bundle: %v", err), + ObservedGeneration: proxy.Generation, + }) + return err + } + } + setMCPRemoteProxyAuthServerRefValidCondition(proxy, authConfig.Name, authConfig.Status.ConfigHash) // Check if the config hash has changed @@ -1327,10 +1443,7 @@ func (r *MCPRemoteProxyReconciler) evaluateCABundleRef( // Verify the key exists in the ConfigMap. A missing key is a configuration state // surfaced through the condition, not a Go error, so it logs at Info (the two // branches above carry a real error and log at Error). - key := caBundleRef.ConfigMapRef.Key - if key == "" { - key = validation.OIDCCABundleDefaultKey - } + key := caBundleKey(caBundleRef) if _, exists := configMap.Data[key]; !exists { ctxLogger.Info("CA bundle key not found in ConfigMap", "configMap", cmName, "key", key) return metav1.ConditionFalse, mcpv1beta1.ConditionReasonMCPRemoteProxyCABundleRefInvalid, @@ -1634,7 +1747,7 @@ func (r *MCPRemoteProxyReconciler) deploymentNeedsUpdate( return true } - if r.podTemplateMetadataNeedsUpdate(deployment, proxy, runConfigChecksum) { + if r.podTemplateMetadataNeedsUpdate(ctx, deployment, proxy, runConfigChecksum) { return true } @@ -1821,6 +1934,7 @@ func (*MCPRemoteProxyReconciler) deploymentMetadataNeedsUpdate( // checksum annotation that triggers pod restarts when configuration changes. // Also includes any user-specified overrides from ResourceOverrides.PodTemplateMetadata. func (r *MCPRemoteProxyReconciler) podTemplateMetadataNeedsUpdate( + ctx context.Context, deployment *appsv1.Deployment, proxy *mcpv1beta1.MCPRemoteProxy, runConfigChecksum string, @@ -1833,6 +1947,25 @@ func (r *MCPRemoteProxyReconciler) podTemplateMetadataNeedsUpdate( labelsForMCPRemoteProxy(proxy.Name), proxy, runConfigChecksum, ) + configName := ctrlutil.EmbeddedAuthServerConfigName(proxy.Spec.ExternalAuthConfigRef, proxy.Spec.AuthServerRef) + if configName != "" { + caChecksum, err := ctrlutil.EmbeddedAuthServerCABundleChecksum(ctx, r.Client, proxy.Namespace, configName) + if err != nil { + log.FromContext(ctx).Error(err, "Failed to calculate auth server CA checksum") + return true + } + if caChecksum != "" { + expectedPodTemplateAnnotations[ctrlutil.AuthServerCABundleChecksumAnnotation] = caChecksum + } + } + + const caChecksumKey = ctrlutil.AuthServerCABundleChecksumAnnotation + actualCAChecksum, actualCAChecksumSet := deployment.Spec.Template.Annotations[caChecksumKey] + expectedCAChecksum, expectedCAChecksumSet := expectedPodTemplateAnnotations[caChecksumKey] + if actualCAChecksumSet != expectedCAChecksumSet || actualCAChecksum != expectedCAChecksum { + return true + } + if proxy.Spec.PodTemplateSpec != nil && len(proxy.Spec.PodTemplateSpec.Raw) > 0 { return !maps.Equal(deployment.Spec.Template.Labels, expectedPodTemplateLabels) || !ctrlutil.MapIsSubset(expectedPodTemplateAnnotations, deployment.Spec.Template.Annotations) @@ -2163,6 +2296,11 @@ func (r *MCPRemoteProxyReconciler) mapAuthzConfigToMCPRemoteProxy( // SetupWithManager sets up the controller with the Manager func (r *MCPRemoteProxyReconciler) SetupWithManager(mgr ctrl.Manager) error { + if err := mgr.GetFieldIndexer().IndexField( + context.Background(), &mcpv1beta1.MCPRemoteProxy{}, + EmbeddedAuthConfigIndex, indexMCPRemoteProxyEmbeddedAuthConfig); err != nil { + return fmt.Errorf("index MCPRemoteProxy embedded auth references: %w", err) + } // Create a handler that maps MCPExternalAuthConfig changes to MCPRemoteProxy reconciliation requests externalAuthConfigHandler := handler.EnqueueRequestsFromMapFunc( func(ctx context.Context, obj client.Object) []reconcile.Request { @@ -2249,5 +2387,10 @@ func (r *MCPRemoteProxyReconciler) SetupWithManager(mgr ctrl.Manager) error { &mcpv1beta1.MCPAuthzConfig{}, handler.EnqueueRequestsFromMapFunc(r.mapAuthzConfigToMCPRemoteProxy), ). + Watches( + &corev1.ConfigMap{}, + handler.EnqueueRequestsFromMapFunc(r.mapAuthServerCABundleConfigMapToMCPRemoteProxy), + builder.WithPredicates(configMapDataChangedPredicate()), + ). Complete(r) } diff --git a/cmd/thv-operator/controllers/mcpremoteproxy_controller_test.go b/cmd/thv-operator/controllers/mcpremoteproxy_controller_test.go index b151ceefd1..7701fed115 100644 --- a/cmd/thv-operator/controllers/mcpremoteproxy_controller_test.go +++ b/cmd/thv-operator/controllers/mcpremoteproxy_controller_test.go @@ -1159,6 +1159,46 @@ func TestGetToolConfigForMCPRemoteProxy(t *testing.T) { } // TestGetExternalAuthConfigForMCPRemoteProxy tests external auth config fetching + +func TestMCPRemoteProxyReconciler_InvalidUpstreamCABundleIsTerminal(t *testing.T) { + t.Parallel() + + proxy := v1beta1test.NewMCPRemoteProxy("proxy", "default", + v1beta1test.WithRemoteProxyURL("https://remote.example.com"), + v1beta1test.WithRemoteProxyExternalAuthConfigRef("auth"), + ) + authConfig := &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "auth", Namespace: "default"}, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1beta1.EmbeddedAuthServerConfig{UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{{ + Name: "upstream", + Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{CABundleRef: &mcpv1beta1.CABundleSource{ + ConfigMapRef: &corev1.ConfigMapKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: "bundle"}, Key: "ca.crt"}, + }}, + }}}, + }, + } + bundle := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "bundle", Namespace: "default"}} + reconciler, fakeClient := newTestMCPRemoteProxyReconciler(t, proxy, authConfig, bundle) + + result, err := reconciler.Reconcile(t.Context(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(proxy)}) + require.NoError(t, err) + assert.Zero(t, result) + + actual := &mcpv1beta1.MCPRemoteProxy{} + require.NoError(t, fakeClient.Get(t.Context(), client.ObjectKeyFromObject(proxy), actual)) + assert.Equal(t, mcpv1beta1.MCPRemoteProxyPhaseFailed, actual.Status.Phase) + ready := meta.FindStatusCondition(actual.Status.Conditions, mcpv1beta1.ConditionTypeReady) + require.NotNil(t, ready) + assert.Equal(t, metav1.ConditionFalse, ready.Status) + condition := meta.FindStatusCondition(actual.Status.Conditions, mcpv1beta1.ConditionTypeMCPRemoteProxyExternalAuthConfigValidated) + require.NotNil(t, condition) + assert.Equal(t, metav1.ConditionFalse, condition.Status) + assert.Equal(t, mcpv1beta1.ConditionReasonInvalidCABundle, condition.Reason) +} + func TestGetExternalAuthConfigForMCPRemoteProxy(t *testing.T) { t.Parallel() diff --git a/cmd/thv-operator/controllers/mcpremoteproxy_deployment.go b/cmd/thv-operator/controllers/mcpremoteproxy_deployment.go index a1fa5da8dc..299c3a083e 100644 --- a/cmd/thv-operator/controllers/mcpremoteproxy_deployment.go +++ b/cmd/thv-operator/controllers/mcpremoteproxy_deployment.go @@ -61,6 +61,16 @@ func (r *MCPRemoteProxyReconciler) deploymentForMCPRemoteProxy( resources := ctrlutil.BuildResourceRequirements(proxy.Spec.Resources) deploymentLabels, deploymentAnnotations := r.buildDeploymentMetadata(ls, proxy) deploymentTemplateLabels, deploymentTemplateAnnotations := r.buildPodTemplateMetadata(ls, proxy, runConfigChecksum) + if configName != "" { + caChecksum, err := ctrlutil.EmbeddedAuthServerCABundleChecksum(ctx, r.Client, proxy.Namespace, configName) + if err != nil { + log.FromContext(ctx).Error(err, "Failed to calculate auth server CA checksum") + return nil + } + if caChecksum != "" { + deploymentTemplateAnnotations[ctrlutil.AuthServerCABundleChecksumAnnotation] = caChecksum + } + } podSecurityContext, containerSecurityContext := r.buildSecurityContexts(ctx, proxy) dep := &appsv1.Deployment{ diff --git a/cmd/thv-operator/controllers/mcpserver_authserver_cabundle_configmap.go b/cmd/thv-operator/controllers/mcpserver_authserver_cabundle_configmap.go new file mode 100644 index 0000000000..4c5a6da75c --- /dev/null +++ b/cmd/thv-operator/controllers/mcpserver_authserver_cabundle_configmap.go @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package controllers + +import ( + "context" + + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" +) + +// mapAuthServerCABundleConfigMapToMCPServer enqueues MCPServers whose embedded +// auth-server configuration references the changed ConfigMap. References are +// namespace-local and resolved through field indexes. +func (r *MCPServerReconciler) mapAuthServerCABundleConfigMapToMCPServer( + ctx context.Context, obj client.Object) []reconcile.Request { + var configs mcpv1beta1.MCPExternalAuthConfigList + if err := r.List(ctx, &configs, + client.InNamespace(obj.GetNamespace()), + client.MatchingFields{EmbeddedAuthCABundleConfigMapIndex: obj.GetName()}, + ); err != nil { + log.FromContext(ctx).Error(err, "Failed to list MCPExternalAuthConfigs for CA bundle ConfigMap watch") + return nil + } + + requests := make([]reconcile.Request, 0) + for i := range configs.Items { + var servers mcpv1beta1.MCPServerList + if err := r.List(ctx, &servers, + client.InNamespace(obj.GetNamespace()), + client.MatchingFields{EmbeddedAuthConfigIndex: configs.Items[i].Name}, + ); err != nil { + log.FromContext(ctx).Error(err, "Failed to list MCPServers for CA bundle ConfigMap watch") + return nil + } + for j := range servers.Items { + requests = append(requests, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&servers.Items[j])}) + } + } + return requests +} diff --git a/cmd/thv-operator/controllers/mcpserver_authserverref_test.go b/cmd/thv-operator/controllers/mcpserver_authserverref_test.go index d94a538a6c..48c7ee057c 100644 --- a/cmd/thv-operator/controllers/mcpserver_authserverref_test.go +++ b/cmd/thv-operator/controllers/mcpserver_authserverref_test.go @@ -11,12 +11,16 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1/v1beta1test" @@ -317,3 +321,171 @@ func TestMCPServerReconciler_InvalidEmbeddedAuthServerConfigSteadyState(t *testi require.NoError(t, fakeClient.Get(t.Context(), req.NamespacedName, actual)) assert.Equal(t, initial.Status, actual.Status) } + +// authServerRefCABundleConfig returns an embedded auth server config whose only +// defect is whatever state the referenced "bundle" ConfigMap is left in. +func authServerRefCABundleConfig() *mcpv1beta1.MCPExternalAuthConfig { + return &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "auth", Namespace: "default"}, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1beta1.EmbeddedAuthServerConfig{ + Issuer: "https://auth.example.com", + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{{Name: "signing-key", Key: "private.pem"}}, + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{{Name: "hmac-secret", Key: "hmac"}}, + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{{ + Name: "upstream", + Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{ + IssuerURL: "https://idp.example.com", + ClientID: "client", + CABundleRef: &mcpv1beta1.CABundleSource{ConfigMapRef: &corev1.ConfigMapKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: "bundle"}, Key: "ca.crt", + }}, + }, + }}, + }, + }, + } +} + +// A bundle ConfigMap without the key it names is a content error no retry can +// fix, so it must be recorded on AuthServerRefValidated and not requeued. +func TestMCPServerReconciler_AuthServerRefInvalidCABundleIsTerminal(t *testing.T) { + t.Parallel() + + server := v1beta1test.NewMCPServer("server", "default", + v1beta1test.WithImage("test"), + v1beta1test.WithAuthServerRef("MCPExternalAuthConfig", "auth"), + ) + bundle := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "bundle", Namespace: "default"}} + scheme := testutil.NewScheme(t) + fakeClient := fake.NewClientBuilder().WithScheme(scheme). + WithObjects(server, authServerRefCABundleConfig(), bundle). + WithStatusSubresource(&mcpv1beta1.MCPServer{}). + Build() + reconciler := newTestMCPServerReconciler(fakeClient, scheme, kubernetes.PlatformKubernetes) + + result, err := reconciler.Reconcile(t.Context(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(server)}) + require.NoError(t, err, "a terminal CA bundle failure must not requeue") + assert.Zero(t, result) + + actual := &mcpv1beta1.MCPServer{} + require.NoError(t, fakeClient.Get(t.Context(), client.ObjectKeyFromObject(server), actual)) + assert.Equal(t, mcpv1beta1.MCPServerPhaseFailed, actual.Status.Phase) + condition := meta.FindStatusCondition(actual.Status.Conditions, mcpv1beta1.ConditionTypeAuthServerRefValidated) + require.NotNil(t, condition) + assert.Equal(t, metav1.ConditionFalse, condition.Status) + assert.Equal(t, mcpv1beta1.ConditionReasonInvalidCABundle, condition.Reason) + assert.Nil(t, + meta.FindStatusCondition(actual.Status.Conditions, mcpv1beta1.ConditionTypeExternalAuthConfigValidated), + "a bundle reached through authServerRef must not be recorded against externalAuthConfigRef") +} + +// The bundle is repaired without touching the MCPServer or the referenced +// config, so neither generation nor the config hash changes. +func TestMCPServerReconciler_AuthServerRefCABundleRepairRestoresTrue(t *testing.T) { + t.Parallel() + + server := v1beta1test.NewMCPServer("server", "default", + v1beta1test.WithImage("test"), + v1beta1test.WithAuthServerRef("MCPExternalAuthConfig", "auth"), + ) + bundle := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "bundle", Namespace: "default"}} + scheme := testutil.NewScheme(t) + fakeClient := fake.NewClientBuilder().WithScheme(scheme). + WithObjects(server, authServerRefCABundleConfig(), bundle). + WithStatusSubresource(&mcpv1beta1.MCPServer{}). + Build() + reconciler := newTestMCPServerReconciler(fakeClient, scheme, kubernetes.PlatformKubernetes) + + require.Error(t, reconciler.handleAuthServerRef(t.Context(), server)) + broken := meta.FindStatusCondition(server.Status.Conditions, mcpv1beta1.ConditionTypeAuthServerRefValidated) + require.NotNil(t, broken) + require.Equal(t, mcpv1beta1.ConditionReasonInvalidCABundle, broken.Reason) + + repaired := &corev1.ConfigMap{} + require.NoError(t, fakeClient.Get(t.Context(), client.ObjectKeyFromObject(bundle), repaired)) + repaired.Data = map[string]string{"ca.crt": string(mapperTestCertificatePEM(t))} + require.NoError(t, fakeClient.Update(t.Context(), repaired)) + + require.NoError(t, reconciler.handleAuthServerRef(t.Context(), server)) + healed := meta.FindStatusCondition(server.Status.Conditions, mcpv1beta1.ConditionTypeAuthServerRefValidated) + require.NotNil(t, healed) + assert.Equal(t, metav1.ConditionTrue, healed.Status, + "repairing the ConfigMap must clear the CA bundle failure") + assert.Equal(t, mcpv1beta1.ConditionReasonAuthServerRefValid, healed.Reason) +} + +// A ConfigMap read that fails for reasons unrelated to its content is transient +// and must requeue rather than be painted as a terminal spec error. +func TestMCPServerReconciler_AuthServerRefCABundleGetErrorRequeues(t *testing.T) { + t.Parallel() + + server := v1beta1test.NewMCPServer("server", "default", + v1beta1test.WithImage("test"), + v1beta1test.WithAuthServerRef("MCPExternalAuthConfig", "auth"), + ) + scheme := testutil.NewScheme(t) + fakeClient := fake.NewClientBuilder().WithScheme(scheme). + WithObjects(server, authServerRefCABundleConfig(), + &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "bundle", Namespace: "default"}}). + WithStatusSubresource(&mcpv1beta1.MCPServer{}). + WithInterceptorFuncs(interceptor.Funcs{ + Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if _, ok := obj.(*corev1.ConfigMap); ok && key.Name == "bundle" { + return apierrors.NewServiceUnavailable("apiserver is having a moment") + } + return c.Get(ctx, key, obj, opts...) + }, + }). + Build() + reconciler := newTestMCPServerReconciler(fakeClient, scheme, kubernetes.PlatformKubernetes) + + err := reconciler.handleAuthServerRef(t.Context(), server) + require.Error(t, err, "a transient read failure must surface so the caller requeues") + condition := meta.FindStatusCondition(server.Status.Conditions, mcpv1beta1.ConditionTypeAuthServerRefValidated) + if condition != nil { + assert.NotEqual(t, mcpv1beta1.ConditionReasonInvalidCABundle, condition.Reason, + "a transient read failure must not be recorded as a terminal bundle error") + } +} + +// An unchanged terminal failure must be re-derived to the identical condition, +// LastTransitionTime included. Asserted on the condition rather than the +// object's ResourceVersion: other writers in Reconcile issue unconditional +// no-op status patches, so the ResourceVersion advances regardless. +func TestMCPServerReconciler_AuthServerRefInvalidCABundleSteadyState(t *testing.T) { + t.Parallel() + + server := v1beta1test.NewMCPServer("server", "default", + v1beta1test.WithImage("test"), + v1beta1test.WithAuthServerRef("MCPExternalAuthConfig", "auth"), + ) + bundle := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "bundle", Namespace: "default"}} + scheme := testutil.NewScheme(t) + fakeClient := fake.NewClientBuilder().WithScheme(scheme). + WithObjects(server, authServerRefCABundleConfig(), bundle). + WithStatusSubresource(&mcpv1beta1.MCPServer{}). + Build() + reconciler := newTestMCPServerReconciler(fakeClient, scheme, kubernetes.PlatformKubernetes) + req := ctrl.Request{NamespacedName: client.ObjectKeyFromObject(server)} + + _, err := reconciler.Reconcile(t.Context(), req) + require.NoError(t, err) + first := &mcpv1beta1.MCPServer{} + require.NoError(t, fakeClient.Get(t.Context(), client.ObjectKeyFromObject(server), first)) + + _, err = reconciler.Reconcile(t.Context(), req) + require.NoError(t, err) + second := &mcpv1beta1.MCPServer{} + require.NoError(t, fakeClient.Get(t.Context(), client.ObjectKeyFromObject(server), second)) + + firstCondition := meta.FindStatusCondition(first.Status.Conditions, mcpv1beta1.ConditionTypeAuthServerRefValidated) + secondCondition := meta.FindStatusCondition(second.Status.Conditions, mcpv1beta1.ConditionTypeAuthServerRefValidated) + require.NotNil(t, firstCondition) + require.NotNil(t, secondCondition) + assert.Equal(t, *firstCondition, *secondCondition, + "re-deriving an unchanged terminal CA failure must leave the condition untouched") + assert.Equal(t, first.Status.Phase, second.Status.Phase) +} diff --git a/cmd/thv-operator/controllers/mcpserver_controller.go b/cmd/thv-operator/controllers/mcpserver_controller.go index 9c5b536ddd..61c4a2a4d5 100644 --- a/cmd/thv-operator/controllers/mcpserver_controller.go +++ b/cmd/thv-operator/controllers/mcpserver_controller.go @@ -206,6 +206,30 @@ func (r *MCPServerReconciler) handleInvalidEmbeddedAuthServerConfig( }) } +// handleInvalidUpstreamCABundle records an invalid upstream CA bundle as terminal +// on conditionType, which the caller picks because it knows which reference the +// bundle was reached through. Failures to persist status remain retryable. +func (r *MCPServerReconciler) handleInvalidUpstreamCABundle( + ctx context.Context, + mcpServer *mcpv1beta1.MCPServer, + conditionType string, + invalidCABundleErr *ctrlutil.InvalidCABundleError, +) error { + return ctrlutil.MutateAndPatchStatus(ctx, r.Client, mcpServer, func(server *mcpv1beta1.MCPServer) { + server.Status.Phase = mcpv1beta1.MCPServerPhaseFailed + server.Status.Message = fmt.Sprintf("Failed to build configuration: %s", invalidCABundleErr) + server.Status.ObservedGeneration = server.Generation + setReadyCondition(server, metav1.ConditionFalse, mcpv1beta1.ConditionReasonNotReady, server.Status.Message) + meta.SetStatusCondition(&server.Status.Conditions, metav1.Condition{ + Type: conditionType, + Status: metav1.ConditionFalse, + ObservedGeneration: server.Generation, + Reason: mcpv1beta1.ConditionReasonInvalidCABundle, + Message: fmt.Sprintf("invalid upstream CA bundle: %v", invalidCABundleErr), + }) + }) +} + // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. // @@ -310,6 +334,16 @@ func (r *MCPServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( // Check if MCPExternalAuthConfig is referenced and handle it if err := r.handleExternalAuthConfig(ctx, mcpServer); err != nil { + var invalidCABundleErr *ctrlutil.InvalidCABundleError + if stderrors.As(err, &invalidCABundleErr) { + if statusErr := r.handleInvalidUpstreamCABundle( + ctx, mcpServer, mcpv1beta1.ConditionTypeExternalAuthConfigValidated, invalidCABundleErr); statusErr != nil { + ctxLogger.Error(statusErr, "Failed to update MCPServer status after invalid upstream CA bundle") + return ctrl.Result{}, statusErr + } + return ctrl.Result{}, nil + } + ctxLogger.Error(err, "Failed to handle MCPExternalAuthConfig") // Update status to reflect the error mcpServer.Status.Phase = mcpv1beta1.MCPServerPhaseFailed @@ -334,6 +368,16 @@ func (r *MCPServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( // Check if authServerRef is referenced and handle config hash tracking if err := r.handleAuthServerRef(ctx, mcpServer); err != nil { + var invalidCABundleErr *ctrlutil.InvalidCABundleError + if stderrors.As(err, &invalidCABundleErr) { + if statusErr := r.handleInvalidUpstreamCABundle( + ctx, mcpServer, mcpv1beta1.ConditionTypeAuthServerRefValidated, invalidCABundleErr); statusErr != nil { + ctxLogger.Error(statusErr, "Failed to update MCPServer status after invalid upstream CA bundle") + return ctrl.Result{}, statusErr + } + return ctrl.Result{}, nil + } + ctxLogger.Error(err, "Failed to handle authServerRef") mcpServer.Status.Phase = mcpv1beta1.MCPServerPhaseFailed setReadyCondition(mcpServer, metav1.ConditionFalse, mcpv1beta1.ConditionReasonNotReady, err.Error()) @@ -693,10 +737,7 @@ func (r *MCPServerReconciler) validateCABundleRef(ctx context.Context, mcpServer } // Verify the key exists in the ConfigMap - key := caBundleRef.ConfigMapRef.Key - if key == "" { - key = validation.OIDCCABundleDefaultKey - } + key := caBundleKey(caBundleRef) if _, exists := configMap.Data[key]; !exists { ctxLogger.Error(nil, "CA bundle key not found in ConfigMap", "configMap", cmName, "key", key) setCABundleRefCondition(mcpServer, metav1.ConditionFalse, mcpv1beta1.ConditionReasonCABundleRefInvalid, @@ -1110,7 +1151,7 @@ func (r *MCPServerReconciler) deploymentForMCPServer( // Using ConfigMap mode for all configuration // Pod template patch for secrets and service account - ptsBuilder, err := ctrlutil.NewPodTemplateSpecBuilder(m.Spec.PodTemplateSpec, mcpContainerName) + podTemplateBuilder, err := ctrlutil.NewPodTemplateSpecBuilder(m.Spec.PodTemplateSpec, mcpContainerName) if err != nil { return nil, fmt.Errorf("failed to build PodTemplateSpec: %w", err) } @@ -1120,7 +1161,7 @@ func (r *MCPServerReconciler) deploymentForMCPServer( defaultSA := mcpServerServiceAccountName(m.Name) serviceAccount = &defaultSA } - finalPodTemplateSpec := ptsBuilder. + finalPodTemplateSpec := podTemplateBuilder. WithServiceAccount(serviceAccount). WithSecrets(m.Spec.Secrets). Build() @@ -1387,6 +1428,15 @@ func (r *MCPServerReconciler) deploymentForMCPServer( // Add RunConfig checksum annotation to trigger pod rollout when config changes deploymentTemplateAnnotations = checksum.AddRunConfigChecksumToPodTemplate(deploymentTemplateAnnotations, runConfigChecksum) + if configName := ctrlutil.EmbeddedAuthServerConfigName(m.Spec.ExternalAuthConfigRef, m.Spec.AuthServerRef); configName != "" { + caChecksum, err := ctrlutil.EmbeddedAuthServerCABundleChecksum(ctx, r.Client, m.Namespace, configName) + if err != nil { + return nil, fmt.Errorf("failed to calculate auth server CA checksum: %w", err) + } + if caChecksum != "" { + deploymentTemplateAnnotations[ctrlutil.AuthServerCABundleChecksumAnnotation] = caChecksum + } + } // Stamp the MCPServer generation on the proxy Deployment's pod template so the // downward-API env var below resolves to a value that is frozen at pod creation @@ -1991,13 +2041,13 @@ func (r *MCPServerReconciler) deploymentNeedsUpdate( serviceAccount = &defaultSA } - ptsBuilder, err := ctrlutil.NewPodTemplateSpecBuilder(mcpServer.Spec.PodTemplateSpec, mcpContainerName) + podTemplateBuilder, err := ctrlutil.NewPodTemplateSpecBuilder(mcpServer.Spec.PodTemplateSpec, mcpContainerName) if err != nil { // If we can't parse the PodTemplateSpec, consider it as needing update return true } - expectedPodTemplateSpec := ptsBuilder. + expectedPodTemplateSpec := podTemplateBuilder. WithServiceAccount(serviceAccount). WithSecrets(mcpServer.Spec.Secrets). Build() @@ -2084,6 +2134,17 @@ func (r *MCPServerReconciler) deploymentNeedsUpdate( // Check if pod template annotations have changed (including runconfig checksum) expectedPodTemplateAnnotations := make(map[string]string) expectedPodTemplateAnnotations = checksum.AddRunConfigChecksumToPodTemplate(expectedPodTemplateAnnotations, runConfigChecksum) + configName := ctrlutil.EmbeddedAuthServerConfigName( + mcpServer.Spec.ExternalAuthConfigRef, mcpServer.Spec.AuthServerRef) + if configName != "" { + caChecksum, err := ctrlutil.EmbeddedAuthServerCABundleChecksum(ctx, r.Client, mcpServer.Namespace, configName) + if err != nil { + return true + } + if caChecksum != "" { + expectedPodTemplateAnnotations[ctrlutil.AuthServerCABundleChecksumAnnotation] = caChecksum + } + } // Mirrors deploymentForMCPServer: stamp the MCPServer generation so the // downward-API env var injected into the proxyrunner container resolves // to a frozen-per-pod value (#5360). @@ -2106,6 +2167,13 @@ func (r *MCPServerReconciler) deploymentNeedsUpdate( return true } + const caChecksumKey = ctrlutil.AuthServerCABundleChecksumAnnotation + actualCAChecksum, actualCAChecksumSet := deployment.Spec.Template.Annotations[caChecksumKey] + expectedCAChecksum, expectedCAChecksumSet := expectedPodTemplateAnnotations[caChecksumKey] + if actualCAChecksumSet != expectedCAChecksumSet || actualCAChecksum != expectedCAChecksum { + return true + } + // Check if spec.replicas has changed. Only compare when spec.replicas is non-nil; // nil means hands-off mode (HPA/KEDA manages replicas) and the live count is authoritative. expectedReplicas := resolveDeploymentReplicas(mcpServer.Spec.Transport, mcpServer.Spec.Replicas) @@ -2268,6 +2336,24 @@ func (r *MCPServerReconciler) handleExternalAuthConfig(ctx context.Context, m *m return err } + // Resolve upstream CA dependencies before allowing any workload changes. + if embeddedCfg := externalAuthConfig.Spec.EmbeddedAuthServer; embeddedCfg != nil { + if err := ctrlutil.ValidateEmbeddedAuthServerCABundles(ctx, r.Client, m.Namespace, embeddedCfg); err != nil { + // Only a content error is terminal. A failed ConfigMap read may be + // transient, and must requeue rather than be recorded as a spec defect. + var invalidCABundleErr *ctrlutil.InvalidCABundleError + if !stderrors.As(err, &invalidCABundleErr) { + return err + } + meta.SetStatusCondition(&m.Status.Conditions, metav1.Condition{ + Type: mcpv1beta1.ConditionTypeExternalAuthConfigValidated, Status: metav1.ConditionFalse, + Reason: mcpv1beta1.ConditionReasonInvalidCABundle, Message: fmt.Sprintf("invalid upstream CA bundle: %v", err), + ObservedGeneration: m.Generation, + }) + return err + } + } + // MCPServer supports only single-upstream embedded auth server configs. // Multi-upstream requires VirtualMCPServer. if embeddedCfg := externalAuthConfig.Spec.EmbeddedAuthServer; embeddedCfg != nil && len(embeddedCfg.UpstreamProviders) > 1 { @@ -2287,6 +2373,8 @@ func (r *MCPServerReconciler) handleExternalAuthConfig(ctx context.Context, m *m m.Namespace, m.Name, len(embeddedCfg.UpstreamProviders)) } + setMCPServerExternalAuthConfigValidCondition(m, externalAuthConfig.Name, externalAuthConfig.Status.ConfigHash) + // Check if the MCPExternalAuthConfig hash has changed if m.Status.ExternalAuthConfigHash != externalAuthConfig.Status.ConfigHash { ctxLogger.Info("MCPExternalAuthConfig has changed, updating MCPServer", @@ -2308,6 +2396,37 @@ func (r *MCPServerReconciler) handleExternalAuthConfig(ctx context.Context, m *m return nil } +// setMCPServerExternalAuthConfigValidCondition preserves a terminal RunConfig validation failure +// until its referenced configuration or this resource generation changes. +// +// A CA bundle failure (ConditionReasonInvalidCABundle) is deliberately not +// preserved here: its failing input is ConfigMap content, which neither +// generation nor configHash tracks, and the check that records it re-runs +// earlier in this same function. Holding it would make the failure permanent. +func setMCPServerExternalAuthConfigValidCondition( + m *mcpv1beta1.MCPServer, + configName, configHash string, +) { + previousCondition := meta.FindStatusCondition( + m.Status.Conditions, mcpv1beta1.ConditionTypeExternalAuthConfigValidated, + ) + if previousCondition != nil && + previousCondition.Status == metav1.ConditionFalse && + previousCondition.Reason == mcpv1beta1.ConditionReasonInvalidConfig && + previousCondition.ObservedGeneration == m.Generation && + m.Status.ExternalAuthConfigHash == configHash { + return + } + + meta.SetStatusCondition(&m.Status.Conditions, metav1.Condition{ + Type: mcpv1beta1.ConditionTypeExternalAuthConfigValidated, + Status: metav1.ConditionTrue, + Reason: mcpv1beta1.ConditionReasonExternalAuthConfigValid, + Message: fmt.Sprintf("MCPExternalAuthConfig '%s' is valid", configName), + ObservedGeneration: m.Generation, + }) +} + // handleAuthServerRef validates and tracks the hash of the referenced authServerRef config. // It updates the MCPServer status when the auth server configuration changes and sets // the AuthServerRefValidated condition. @@ -2392,6 +2511,29 @@ func (r *MCPServerReconciler) handleAuthServerRef(ctx context.Context, m *mcpv1b m.Namespace, m.Name, len(embeddedCfg.UpstreamProviders)) } + // Resolve upstream CA dependencies before allowing any workload changes. + // Mirrors handleExternalAuthConfig: without this the first notice of a bad + // bundle is Deployment construction, which reports a generic build failure + // and requeues instead of recording a terminal condition here. + if embeddedCfg := authConfig.Spec.EmbeddedAuthServer; embeddedCfg != nil { + if err := ctrlutil.ValidateEmbeddedAuthServerCABundles(ctx, r.Client, m.Namespace, embeddedCfg); err != nil { + // Only a content error is terminal. A failed ConfigMap read may be + // transient, and must requeue rather than be recorded as a spec defect. + var invalidCABundleErr *ctrlutil.InvalidCABundleError + if !stderrors.As(err, &invalidCABundleErr) { + return err + } + meta.SetStatusCondition(&m.Status.Conditions, metav1.Condition{ + Type: mcpv1beta1.ConditionTypeAuthServerRefValidated, + Status: metav1.ConditionFalse, + Reason: mcpv1beta1.ConditionReasonInvalidCABundle, + Message: fmt.Sprintf("invalid upstream CA bundle: %v", err), + ObservedGeneration: m.Generation, + }) + return err + } + } + setMCPServerAuthServerRefValidCondition(m, authConfig.Name, authConfig.Status.ConfigHash) // Check if the config hash has changed @@ -2970,6 +3112,11 @@ func (r *MCPServerReconciler) mapToolConfigToServers( // SetupWithManager sets up the controller with the Manager. func (r *MCPServerReconciler) SetupWithManager(mgr ctrl.Manager) error { + if err := mgr.GetFieldIndexer().IndexField( + context.Background(), &mcpv1beta1.MCPServer{}, + EmbeddedAuthConfigIndex, indexMCPServerEmbeddedAuthConfig); err != nil { + return fmt.Errorf("index MCPServer embedded auth references: %w", err) + } // Create a handler that maps MCPExternalAuthConfig changes to MCPServer reconciliation requests externalAuthConfigHandler := handler.EnqueueRequestsFromMapFunc( func(ctx context.Context, obj client.Object) []reconcile.Request { @@ -3059,5 +3206,10 @@ func (r *MCPServerReconciler) SetupWithManager(mgr ctrl.Manager) error { Watches(&mcpv1beta1.MCPTelemetryConfig{}, telemetryConfigHandler). Watches(&mcpv1alpha1.MCPWebhookConfig{}, webhookConfigHandler). Watches(&mcpv1beta1.MCPToolConfig{}, toolConfigHandler). + Watches( + &corev1.ConfigMap{}, + handler.EnqueueRequestsFromMapFunc(r.mapAuthServerCABundleConfigMapToMCPServer), + builder.WithPredicates(configMapDataChangedPredicate()), + ). Complete(r) } diff --git a/cmd/thv-operator/controllers/mcpserver_externalauth_test.go b/cmd/thv-operator/controllers/mcpserver_externalauth_test.go index 3f9766314c..ff1a6a8ed2 100644 --- a/cmd/thv-operator/controllers/mcpserver_externalauth_test.go +++ b/cmd/thv-operator/controllers/mcpserver_externalauth_test.go @@ -404,13 +404,12 @@ func TestMCPServerReconciler_handleExternalAuthConfig_MirrorsInvalidCondition(t ) tests := []struct { - name string - sourceValid *metav1.Condition - preexisting []metav1.Condition - wantMirrored bool - wantReason string - wantMessage string - wantPreexistingCleared bool + name string + sourceValid *metav1.Condition + preexisting []metav1.Condition + wantMirrored bool + wantReason string + wantMessage string }{ { name: "source Valid=False/EnterpriseRequired is mirrored", @@ -467,8 +466,7 @@ func TestMCPServerReconciler_handleExternalAuthConfig_MirrorsInvalidCondition(t Reason: mcpv1beta1.ConditionReasonEnterpriseRequired, Message: "stale mirror from a previous reconcile", }}, - wantMirrored: false, - wantPreexistingCleared: true, + wantMirrored: false, }, } @@ -514,11 +512,13 @@ func TestMCPServerReconciler_handleExternalAuthConfig_MirrorsInvalidCondition(t if !tt.wantMirrored { assert.NoError(t, err, "no error expected when source is valid") - if tt.wantPreexistingCleared { - assert.Nil(t, cond, "stale mirror condition must be cleared once source has healed") - } else { - assert.Nil(t, cond, "no mirror condition expected when source is valid") - } + // The mirror clears any stale False; handleExternalAuthConfig's own + // success writer then records the validated state, so the condition + // ends up True rather than absent. + require.NotNil(t, cond, "success writer must record the validated state") + assert.Equal(t, metav1.ConditionTrue, cond.Status, + "no mirrored failure must remain once the source is valid") + assert.Equal(t, mcpv1beta1.ConditionReasonExternalAuthConfigValid, cond.Reason) return } @@ -620,7 +620,178 @@ func TestMCPServerReconciler_handleExternalAuthConfig_ClearsMirrorOnSourceNotFou "stale mirror must be cleared when the referenced source is NotFound") } -// TestMCPServerReconciler_ExternalAuthConfigRefInvalidEmbeddedConfigSteadyState +func TestMCPServerReconciler_InvalidUpstreamCABundleIsTerminal(t *testing.T) { + t.Parallel() + + server := v1beta1test.NewMCPServer("server", "default", + v1beta1test.WithImage("test"), + v1beta1test.WithExternalAuthConfigRef("auth"), + ) + authConfig := &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "auth", Namespace: "default"}, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1beta1.EmbeddedAuthServerConfig{UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{{ + Name: "upstream", + Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{CABundleRef: &mcpv1beta1.CABundleSource{ + ConfigMapRef: &corev1.ConfigMapKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: "bundle"}, Key: "ca.crt"}, + }}, + }}}, + }, + } + bundle := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "bundle", Namespace: "default"}} + scheme := testutil.NewScheme(t) + fakeClient := fake.NewClientBuilder().WithScheme(scheme). + WithObjects(server, authConfig, bundle). + WithStatusSubresource(&mcpv1beta1.MCPServer{}). + Build() + reconciler := newTestMCPServerReconciler(fakeClient, scheme, kubernetes.PlatformKubernetes) + + result, err := reconciler.Reconcile(t.Context(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(server)}) + require.NoError(t, err) + assert.Zero(t, result) + + actual := &mcpv1beta1.MCPServer{} + require.NoError(t, fakeClient.Get(t.Context(), client.ObjectKeyFromObject(server), actual)) + assert.Equal(t, mcpv1beta1.MCPServerPhaseFailed, actual.Status.Phase) + ready := meta.FindStatusCondition(actual.Status.Conditions, mcpv1beta1.ConditionTypeReady) + require.NotNil(t, ready) + assert.Equal(t, metav1.ConditionFalse, ready.Status) + condition := meta.FindStatusCondition(actual.Status.Conditions, mcpv1beta1.ConditionTypeExternalAuthConfigValidated) + require.NotNil(t, condition) + assert.Equal(t, metav1.ConditionFalse, condition.Status) + assert.Equal(t, mcpv1beta1.ConditionReasonInvalidCABundle, condition.Reason) +} + +// guards against mirrorInvalidOnMCPServer's clear branch clobbering the +// ExternalAuthConfigValidated condition that the upstream CA bundle check owns. +// The referenced MCPExternalAuthConfig is healthy, so the mirror takes its clear +// branch on every reconcile; without ownedByLocalValidation recognising +// ConditionReasonInvalidCABundle the condition would be removed and immediately +// re-added, restamping LastTransitionTime each time. +// caBundleRepairAuthConfig returns an embedded auth server config that is valid +// apart from its CA bundle, so a reconcile that gets past the bundle check does +// not then fail on unrelated missing fields. +func caBundleRepairAuthConfig() *mcpv1beta1.MCPExternalAuthConfig { + return &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "auth", Namespace: "default"}, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1beta1.EmbeddedAuthServerConfig{ + Issuer: "https://auth.example.com", + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{{Name: "signing-key", Key: "private.pem"}}, + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{{Name: "hmac-secret", Key: "hmac"}}, + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{{ + Name: "upstream", + Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{ + IssuerURL: "https://idp.example.com", + ClientID: "client", + CABundleRef: &mcpv1beta1.CABundleSource{ConfigMapRef: &corev1.ConfigMapKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: "bundle"}, Key: "ca.crt"}}, + }, + }}, + }, + }, + } +} + +// The referenced ConfigMap starts without the key the bundle names, then gains +// valid PEM. Neither metadata.generation nor the MCPExternalAuthConfig spec hash +// changes across the repair, so this only passes if the condition is re-derived +// from observed state rather than held by a hash guard — and only if a True +// writer exists at all. +func TestMCPServerReconciler_ExternalAuthConfigCABundleRepairRestoresTrue(t *testing.T) { + t.Parallel() + + server := v1beta1test.NewMCPServer("server", "default", + v1beta1test.WithImage("test"), + v1beta1test.WithExternalAuthConfigRef("auth"), + ) + authConfig := caBundleRepairAuthConfig() + bundle := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "bundle", Namespace: "default"}} + scheme := testutil.NewScheme(t) + fakeClient := fake.NewClientBuilder().WithScheme(scheme). + WithObjects(server, authConfig, bundle). + WithStatusSubresource(&mcpv1beta1.MCPServer{}). + Build() + reconciler := newTestMCPServerReconciler(fakeClient, scheme, kubernetes.PlatformKubernetes) + + err := reconciler.handleExternalAuthConfig(t.Context(), server) + require.Error(t, err, "a bundle ConfigMap without the named key must fail validation") + brokenCondition := meta.FindStatusCondition(server.Status.Conditions, mcpv1beta1.ConditionTypeExternalAuthConfigValidated) + require.NotNil(t, brokenCondition) + require.Equal(t, metav1.ConditionFalse, brokenCondition.Status) + require.Equal(t, mcpv1beta1.ConditionReasonInvalidCABundle, brokenCondition.Reason) + + repaired := &corev1.ConfigMap{} + require.NoError(t, fakeClient.Get(t.Context(), client.ObjectKeyFromObject(bundle), repaired)) + repaired.Data = map[string]string{"ca.crt": string(mapperTestCertificatePEM(t))} + require.NoError(t, fakeClient.Update(t.Context(), repaired)) + + require.NoError(t, reconciler.handleExternalAuthConfig(t.Context(), server)) + healedCondition := meta.FindStatusCondition(server.Status.Conditions, mcpv1beta1.ConditionTypeExternalAuthConfigValidated) + require.NotNil(t, healedCondition) + assert.Equal(t, metav1.ConditionTrue, healedCondition.Status, + "repairing the ConfigMap must clear the CA bundle failure") + assert.Equal(t, mcpv1beta1.ConditionReasonExternalAuthConfigValid, healedCondition.Reason) +} + +func TestMCPServerReconciler_InvalidCABundleDoesNotFlapLastTransitionTime(t *testing.T) { + t.Parallel() + + // Seeded well in the past: metav1.Time has second resolution, so a + // remove-then-re-add within one reconcile is only observable against a + // timestamp that predates the test run. + seeded := metav1.NewTime(time.Now().Add(-time.Hour).Truncate(time.Second)) + server := v1beta1test.NewMCPServer("server", "default", + v1beta1test.WithImage("test"), + v1beta1test.WithExternalAuthConfigRef("auth"), + v1beta1test.WithStatus(mcpv1beta1.MCPServerStatus{ + Conditions: []metav1.Condition{{ + Type: mcpv1beta1.ConditionTypeExternalAuthConfigValidated, + Status: metav1.ConditionFalse, + Reason: mcpv1beta1.ConditionReasonInvalidCABundle, + Message: "invalid upstream CA bundle: already recorded", + LastTransitionTime: seeded, + }}, + }), + ) + authConfig := &mcpv1beta1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "auth", Namespace: "default"}, + Spec: mcpv1beta1.MCPExternalAuthConfigSpec{ + Type: mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer, + EmbeddedAuthServer: &mcpv1beta1.EmbeddedAuthServerConfig{UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{{ + Name: "upstream", + Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{CABundleRef: &mcpv1beta1.CABundleSource{ + ConfigMapRef: &corev1.ConfigMapKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: "bundle"}, Key: "ca.crt"}, + }}, + }}}, + }, + } + // Present but keyless: the same terminal CA failure the seeded condition + // records, so the reconcile re-derives it unchanged. + bundle := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "bundle", Namespace: "default"}} + scheme := testutil.NewScheme(t) + fakeClient := fake.NewClientBuilder().WithScheme(scheme). + WithObjects(server, authConfig, bundle). + WithStatusSubresource(&mcpv1beta1.MCPServer{}). + Build() + reconciler := newTestMCPServerReconciler(fakeClient, scheme, kubernetes.PlatformKubernetes) + + _, err := reconciler.Reconcile(t.Context(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(server)}) + require.NoError(t, err) + + actual := &mcpv1beta1.MCPServer{} + require.NoError(t, fakeClient.Get(t.Context(), client.ObjectKeyFromObject(server), actual)) + condition := meta.FindStatusCondition(actual.Status.Conditions, mcpv1beta1.ConditionTypeExternalAuthConfigValidated) + require.NotNil(t, condition, "condition must survive the mirror's clear branch") + assert.Equal(t, mcpv1beta1.ConditionReasonInvalidCABundle, condition.Reason) + assert.Equal(t, seeded, condition.LastTransitionTime, + "an unchanged terminal CA failure must not restamp LastTransitionTime") +} + // guards against mirrorInvalidOnMCPServer's clear branch clobbering the // ExternalAuthConfigValidated condition that handleInvalidEmbeddedAuthServerConfig // owns for a different reason (delegate clients configured without OIDC). diff --git a/cmd/thv-operator/controllers/upstream_ca_bundle.go b/cmd/thv-operator/controllers/upstream_ca_bundle.go new file mode 100644 index 0000000000..f0d836a486 --- /dev/null +++ b/cmd/thv-operator/controllers/upstream_ca_bundle.go @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package controllers + +import ( + "sigs.k8s.io/controller-runtime/pkg/client" + + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" + "github.com/stacklok/toolhive/cmd/thv-operator/pkg/validation" +) + +const ( + // EmbeddedAuthCABundleConfigMapIndex indexes auth configs by referenced CA ConfigMap name. + EmbeddedAuthCABundleConfigMapIndex = "toolhive.stacklok.dev/embedded-auth-ca-configmap" + // EmbeddedAuthConfigIndex indexes workloads by referenced embedded auth config name. + EmbeddedAuthConfigIndex = "toolhive.stacklok.dev/embedded-auth-config" +) + +func providerCABundleConfigMapName(provider mcpv1beta1.UpstreamProviderConfig) string { + ref := provider.CABundleRef() + if ref == nil || ref.ConfigMapRef == nil { + return "" + } + return ref.ConfigMapRef.Name +} + +func caBundleKey(ref *mcpv1beta1.CABundleSource) string { + if ref == nil || ref.ConfigMapRef == nil || ref.ConfigMapRef.Key == "" { + return validation.OIDCCABundleDefaultKey + } + return ref.ConfigMapRef.Key +} + +// IndexEmbeddedAuthCABundleConfigMaps returns the ConfigMap names referenced by +// an MCPExternalAuthConfig's embedded-auth upstream CA bundles. +func IndexEmbeddedAuthCABundleConfigMaps(obj client.Object) []string { + config, ok := obj.(*mcpv1beta1.MCPExternalAuthConfig) + if !ok || config.Spec.EmbeddedAuthServer == nil { + return nil + } + keys := make([]string, 0) + for _, provider := range config.Spec.EmbeddedAuthServer.UpstreamProviders { + if name := providerCABundleConfigMapName(provider); name != "" { + keys = append(keys, name) + } + } + return keys +} + +func indexMCPServerEmbeddedAuthConfig(obj client.Object) []string { + server, ok := obj.(*mcpv1beta1.MCPServer) + if !ok { + return nil + } + if name := ctrlutil.EmbeddedAuthServerConfigName(server.Spec.ExternalAuthConfigRef, server.Spec.AuthServerRef); name != "" { + return []string{name} + } + return nil +} + +func indexMCPRemoteProxyEmbeddedAuthConfig(obj client.Object) []string { + proxy, ok := obj.(*mcpv1beta1.MCPRemoteProxy) + if !ok { + return nil + } + if name := ctrlutil.EmbeddedAuthServerConfigName(proxy.Spec.ExternalAuthConfigRef, proxy.Spec.AuthServerRef); name != "" { + return []string{name} + } + return nil +} diff --git a/cmd/thv-operator/controllers/virtualmcpserver_authz_configmap.go b/cmd/thv-operator/controllers/virtualmcpserver_authz_configmap.go index 65cebfd69d..5b1ff26b5d 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_authz_configmap.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_authz_configmap.go @@ -42,7 +42,7 @@ func (r *VirtualMCPServerReconciler) mapAuthzConfigMapToVirtualMCPServer( var requests []reconcile.Request for _, vmcp := range vmcpList.Items { - if !vmcpReferencesAuthzConfigMap(&vmcp, cm.Name) { + if !vmcpReferencesAuthzConfigMap(&vmcp, cm.Name) && !vmcpReferencesAuthServerCABundle(&vmcp, cm.Name) { continue } requests = append(requests, reconcile.Request{ @@ -68,7 +68,22 @@ func vmcpReferencesAuthzConfigMap(vmcp *mcpv1beta1.VirtualMCPServer, configMapNa return vmcp.Spec.IncomingAuth.AuthzConfig.ConfigMap.Name == configMapName } -// configMapDataChangedPredicate admits ConfigMap events that may affect a VirtualMCPServer's +// vmcpReferencesAuthServerCABundle reports whether an inline auth-server upstream +// selects the named ConfigMap for its CA bundle. +func vmcpReferencesAuthServerCABundle(vmcp *mcpv1beta1.VirtualMCPServer, configMapName string) bool { + if vmcp.Spec.AuthServerConfig == nil { + return false + } + for i := range vmcp.Spec.AuthServerConfig.UpstreamProviders { + provider := &vmcp.Spec.AuthServerConfig.UpstreamProviders[i] + ref := provider.CABundleRef() + if ref != nil && ref.ConfigMapRef != nil && ref.ConfigMapRef.Name == configMapName { + return true + } + } + return false +} + // resolved authz config. Update events are admitted only when .Data or .BinaryData actually // change, so metadata-only updates (labels, annotations, resourceVersion bumps) do not trigger // reconciliation. Create and Delete events are passed through so the controller can pick up a diff --git a/cmd/thv-operator/controllers/virtualmcpserver_authz_configmap_test.go b/cmd/thv-operator/controllers/virtualmcpserver_authz_configmap_test.go index b8b78dac58..dddbfb542a 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_authz_configmap_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_authz_configmap_test.go @@ -134,6 +134,20 @@ func TestMapAuthzConfigMapToVirtualMCPServer(t *testing.T) { } } +func TestMapAuthzConfigMapToVirtualMCPServer_InlineCABundle(t *testing.T) { + t.Parallel() + ns := "default" + vmcp := v1beta1test.NewVirtualMCPServer("vmcp", ns) + vmcp.Spec.AuthServerConfig = &mcpv1beta1.EmbeddedAuthServerConfig{UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{{ + Name: "idp", Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{CABundleRef: &mcpv1beta1.CABundleSource{ConfigMapRef: &corev1.ConfigMapKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: "ca-map"}}}}, + }}} + scheme := testutil.NewScheme(t) + r := &VirtualMCPServerReconciler{Client: fake.NewClientBuilder().WithScheme(scheme).WithObjects(vmcp).Build()} + requests := r.mapAuthzConfigMapToVirtualMCPServer(t.Context(), &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "ca-map", Namespace: ns}}) + require.Equal(t, []types.NamespacedName{{Name: "vmcp", Namespace: ns}}, []types.NamespacedName{requests[0].NamespacedName}) +} + func TestConfigMapDataChangedPredicate(t *testing.T) { t.Parallel() diff --git a/cmd/thv-operator/controllers/virtualmcpserver_controller.go b/cmd/thv-operator/controllers/virtualmcpserver_controller.go index 8f475dc184..6698b50208 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_controller.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_controller.go @@ -445,7 +445,9 @@ func (r *VirtualMCPServerReconciler) runValidations( } // Validate auth-related spec fields (AuthServerConfig + AuthzConfig coherence). - if ok := r.runAuthValidations(ctx, vmcp, statusManager); !ok { + if ok, err := r.runAuthValidations(ctx, vmcp, statusManager); err != nil { + return false, err + } else if !ok { return false, nil } @@ -457,13 +459,14 @@ func (r *VirtualMCPServerReconciler) runValidations( // runAuthValidations runs the auth-related spec validations: the inline // AuthServerConfig (when specified) and the AuthzConfig/upstream coherence -// check. Returns false when a validation fails and the caller should stop -// reconciliation (user must fix the spec); true to continue. +// check. Returns (true, nil) to continue; (false, nil) when a spec validation +// failed and the user must fix it, so reconciliation stops without requeue; +// (false, err) for a transient failure the caller should requeue on. func (r *VirtualMCPServerReconciler) runAuthValidations( ctx context.Context, vmcp *mcpv1beta1.VirtualMCPServer, statusManager virtualmcpserverstatus.StatusManager, -) bool { +) (bool, error) { ctxLogger := log.FromContext(ctx) // Validate inline AuthServerConfig (when specified). @@ -481,7 +484,16 @@ func (r *VirtualMCPServerReconciler) runAuthValidations( if applyErr := r.applyStatusUpdates(ctx, vmcp, statusManager); applyErr != nil { ctxLogger.Error(applyErr, "Failed to apply status updates after AuthServerConfig validation error") } - return false + return false, nil + } + if terminal, err := r.validateAuthServerConfigCABundles(ctx, vmcp, statusManager); err != nil { + if applyErr := r.applyStatusUpdates(ctx, vmcp, statusManager); applyErr != nil { + ctxLogger.Error(applyErr, "Failed to apply status updates after CA bundle validation error") + } + if terminal { + return false, nil + } + return false, err } } else { // Remove stale conditions if AuthServerConfig was previously set then removed. @@ -497,10 +509,38 @@ func (r *VirtualMCPServerReconciler) runAuthValidations( if applyErr := r.applyStatusUpdates(ctx, vmcp, statusManager); applyErr != nil { ctxLogger.Error(applyErr, "Failed to apply status updates after AuthzUpstreamAvailable validation error") } - return false + return false, nil } - return true + return true, nil +} + +// validateAuthServerConfigCABundles resolves inline upstream CA dependencies. +// +// Returns (true, err) when the failure is terminal — a malformed reference or +// non-PEM content, which no retry fixes — with the failure already stamped on +// status. Returns (false, err) when the ConfigMap read itself failed, which may +// be transient: nothing is stamped, because recording a spec defect for an +// unavailable apiserver would outlive its cause. Returns (false, nil) on +// success. +func (r *VirtualMCPServerReconciler) validateAuthServerConfigCABundles( + ctx context.Context, vmcp *mcpv1beta1.VirtualMCPServer, statusManager virtualmcpserverstatus.StatusManager, +) (bool, error) { + err := ctrlutil.ValidateEmbeddedAuthServerCABundles(ctx, r.Client, vmcp.Namespace, vmcp.Spec.AuthServerConfig) + if err == nil { + return false, nil + } + var invalidCABundleErr *ctrlutil.InvalidCABundleError + if !stderrors.As(err, &invalidCABundleErr) { + return false, err + } + message := fmt.Sprintf("invalid upstream CA bundle: %v", err) + statusManager.SetPhase(mcpv1beta1.VirtualMCPServerPhaseFailed) + statusManager.SetMessage(message) + statusManager.SetAuthServerConfigValidatedCondition( + mcpv1beta1.ConditionReasonAuthServerConfigInvalid, message, metav1.ConditionFalse) + statusManager.SetObservedGeneration(vmcp.Generation) + return true, err } // validateSessionStorageForReplicas emits a SessionStorageWarning condition when @@ -1525,85 +1565,102 @@ func (r *VirtualMCPServerReconciler) ensureDeployment( return ctrl.Result{}, err } + // Fetch the selected inline auth-server CA content checksum. This is intentionally + // computed from the current ConfigMaps so both rollout and drift detection use + // the same value. + caBundleChecksum, err := ctrlutil.EmbeddedAuthServerCABundleChecksumForConfig( + ctx, r.Client, vmcp.Namespace, vmcp.Spec.AuthServerConfig) + if err != nil { + // runAuthValidations gates this path, so a terminal bundle error should + // not reach here. Stop rather than requeue if one does: retrying cannot + // fix malformed content, and the validation above has already recorded it. + var invalidCABundleErr *ctrlutil.InvalidCABundleError + if stderrors.As(err, &invalidCABundleErr) { + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + deployment := &appsv1.Deployment{} err = r.Get(ctx, types.NamespacedName{Name: vmcp.Name, Namespace: vmcp.Namespace}, deployment) if errors.IsNotFound(err) { - dep := r.deploymentForVirtualMCPServer(ctx, vmcp, vmcpConfigChecksum, telemetryCfg, typedWorkloads) - if dep == nil { - return ctrl.Result{}, fmt.Errorf("failed to create Deployment object") - } - ctxLogger.Info("Creating a new Deployment", "Deployment.Namespace", dep.Namespace, "Deployment.Name", dep.Name) - if err := r.Create(ctx, dep); err != nil { - ctxLogger.Error(err, "Failed to create new Deployment") - // Record event for deployment creation failure - if r.Recorder != nil { - r.Recorder.Eventf(vmcp, nil, corev1.EventTypeWarning, "DeploymentCreationFailed", "CreateDeployment", - "Failed to create Deployment: %v", err) - } - return ctrl.Result{}, err - } - // Record event for successful deployment creation - if r.Recorder != nil { - r.Recorder.Eventf(vmcp, nil, corev1.EventTypeNormal, "DeploymentCreated", "CreateDeployment", - "Deployment created successfully") - } - // Return empty result to continue with rest of reconciliation (Service, status update, etc.) - // Kubernetes will automatically requeue when Deployment status changes - return ctrl.Result{}, nil - } else if err != nil { + return r.createDeployment(ctx, vmcp, telemetryCfg, typedWorkloads, vmcpConfigChecksum, caBundleChecksum) + } + if err != nil { ctxLogger.Error(err, "Failed to get Deployment") return ctrl.Result{}, err } - // Deployment exists - check if it needs to be updated - // deploymentNeedsUpdate performs a detailed comparison to avoid unnecessary updates - if r.deploymentNeedsUpdate(ctx, deployment, vmcp, vmcpConfigChecksum, telemetryCfg, typedWorkloads) { - newDeployment := r.deploymentForVirtualMCPServer(ctx, vmcp, vmcpConfigChecksum, telemetryCfg, typedWorkloads) - if newDeployment == nil { - return ctrl.Result{}, fmt.Errorf("failed to create updated Deployment object") - } + if r.deploymentNeedsUpdate(ctx, deployment, vmcp, vmcpConfigChecksum, caBundleChecksum, telemetryCfg, typedWorkloads) { + return r.updateDeployment(ctx, vmcp, deployment, telemetryCfg, typedWorkloads, vmcpConfigChecksum, caBundleChecksum) + } - // Selective field update strategy: - // - Update Spec.Template: Contains container spec, volumes, pod metadata (triggers rollout) - // - Update Labels: For label selectors and queries - // - Update Annotations: For metadata and tooling - // - Sync Spec.Replicas when spec.replicas is non-nil (operator authoritative) - // - Preserve Spec.Replicas when spec.replicas is nil (HPA or external controller manages scaling) - // - Preserve ResourceVersion, UID: Required for optimistic concurrency control - // - // Note: If update conflicts occur due to concurrent modifications, the reconcile - // loop will retry automatically. Kubernetes' optimistic locking prevents data loss. - newDeployment.Spec.Template.Annotations = ctrlutil.PreserveKubectlRestartedAt( - newDeployment.Spec.Template.Annotations, deployment.Spec.Template.Annotations) - deployment.Spec.Template = newDeployment.Spec.Template - deployment.Labels = newDeployment.Labels - deployment.Annotations = mergeDeploymentAnnotations(newDeployment.Annotations, deployment.Annotations) - if newDeployment.Spec.Replicas != nil { - deployment.Spec.Replicas = newDeployment.Spec.Replicas - } - - ctxLogger.Info("Updating Deployment", "Deployment.Namespace", deployment.Namespace, "Deployment.Name", deployment.Name) - if err := r.Update(ctx, deployment); err != nil { - ctxLogger.Error(err, "Failed to update Deployment") - // Record event for deployment update failure - if r.Recorder != nil { - r.Recorder.Eventf(vmcp, nil, corev1.EventTypeWarning, "DeploymentUpdateFailed", "UpdateDeployment", - "Failed to update Deployment: %v", err) - } - // Return error to trigger reconcile retry (handles transient failures and conflicts) - return ctrl.Result{}, err - } - // Record event for successful deployment update (config change triggers rollout) + return ctrl.Result{}, nil +} + +func (r *VirtualMCPServerReconciler) createDeployment( + ctx context.Context, + vmcp *mcpv1beta1.VirtualMCPServer, + telemetryCfg *mcpv1beta1.MCPTelemetryConfig, + typedWorkloads []workloads.TypedWorkload, + vmcpConfigChecksum, caBundleChecksum string, +) (ctrl.Result, error) { + ctxLogger := log.FromContext(ctx) + dep := r.deploymentForVirtualMCPServer(ctx, vmcp, vmcpConfigChecksum, caBundleChecksum, telemetryCfg, typedWorkloads) + if dep == nil { + return ctrl.Result{}, fmt.Errorf("failed to create Deployment object") + } + ctxLogger.Info("Creating a new Deployment", "Deployment.Namespace", dep.Namespace, "Deployment.Name", dep.Name) + if err := r.Create(ctx, dep); err != nil { + ctxLogger.Error(err, "Failed to create new Deployment") if r.Recorder != nil { - r.Recorder.Eventf(vmcp, nil, corev1.EventTypeNormal, "DeploymentUpdated", "UpdateDeployment", - "Deployment updated, rolling out new configuration") + r.Recorder.Eventf(vmcp, nil, corev1.EventTypeWarning, "DeploymentCreationFailed", "CreateDeployment", + "Failed to create Deployment: %v", err) } - // Return empty result to continue with rest of reconciliation - // Deployment rollout will be monitored when Kubernetes triggers subsequent reconciles - return ctrl.Result{}, nil + return ctrl.Result{}, err + } + if r.Recorder != nil { + r.Recorder.Eventf(vmcp, nil, corev1.EventTypeNormal, "DeploymentCreated", "CreateDeployment", + "Deployment created successfully") } + return ctrl.Result{}, nil +} +func (r *VirtualMCPServerReconciler) updateDeployment( + ctx context.Context, + vmcp *mcpv1beta1.VirtualMCPServer, + deployment *appsv1.Deployment, + telemetryCfg *mcpv1beta1.MCPTelemetryConfig, + typedWorkloads []workloads.TypedWorkload, + vmcpConfigChecksum, caBundleChecksum string, +) (ctrl.Result, error) { + ctxLogger := log.FromContext(ctx) + newDeployment := r.deploymentForVirtualMCPServer(ctx, vmcp, vmcpConfigChecksum, caBundleChecksum, telemetryCfg, typedWorkloads) + if newDeployment == nil { + return ctrl.Result{}, fmt.Errorf("failed to create updated Deployment object") + } + newDeployment.Spec.Template.Annotations = ctrlutil.PreserveKubectlRestartedAt( + newDeployment.Spec.Template.Annotations, deployment.Spec.Template.Annotations) + deployment.Spec.Template = newDeployment.Spec.Template + deployment.Labels = newDeployment.Labels + deployment.Annotations = mergeDeploymentAnnotations(newDeployment.Annotations, deployment.Annotations) + if newDeployment.Spec.Replicas != nil { + deployment.Spec.Replicas = newDeployment.Spec.Replicas + } + ctxLogger.Info("Updating Deployment", "Deployment.Namespace", deployment.Namespace, "Deployment.Name", deployment.Name) + if err := r.Update(ctx, deployment); err != nil { + ctxLogger.Error(err, "Failed to update Deployment") + if r.Recorder != nil { + r.Recorder.Eventf(vmcp, nil, corev1.EventTypeWarning, "DeploymentUpdateFailed", "UpdateDeployment", + "Failed to update Deployment: %v", err) + } + return ctrl.Result{}, err + } + if r.Recorder != nil { + r.Recorder.Eventf(vmcp, nil, corev1.EventTypeNormal, "DeploymentUpdated", "UpdateDeployment", + "Deployment updated, rolling out new configuration") + } return ctrl.Result{}, nil } @@ -1702,6 +1759,7 @@ func (r *VirtualMCPServerReconciler) deploymentNeedsUpdate( deployment *appsv1.Deployment, vmcp *mcpv1beta1.VirtualMCPServer, vmcpConfigChecksum string, + caBundleChecksum string, telemetryCfg *mcpv1beta1.MCPTelemetryConfig, typedWorkloads []workloads.TypedWorkload, ) bool { @@ -1721,7 +1779,7 @@ func (r *VirtualMCPServerReconciler) deploymentNeedsUpdate( return true } - if r.podTemplateMetadataNeedsUpdate(deployment, vmcp, vmcpConfigChecksum) { + if r.podTemplateMetadataNeedsUpdate(deployment, vmcp, vmcpConfigChecksum, caBundleChecksum) { return true } @@ -1828,13 +1886,14 @@ func (r *VirtualMCPServerReconciler) podTemplateMetadataNeedsUpdate( deployment *appsv1.Deployment, vmcp *mcpv1beta1.VirtualMCPServer, vmcpConfigChecksum string, + caBundleChecksum string, ) bool { if deployment == nil || vmcp == nil { return true } expectedPodTemplateLabels, expectedPodTemplateAnnotations := r.buildPodTemplateMetadata( - labelsForVirtualMCPServer(vmcp.Name), vmcp, vmcpConfigChecksum, + labelsForVirtualMCPServer(vmcp.Name), vmcp, vmcpConfigChecksum, caBundleChecksum, ) if !maps.Equal(deployment.Spec.Template.Labels, expectedPodTemplateLabels) { diff --git a/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go b/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go index e37886394c..fe3e2300e6 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go @@ -25,6 +25,7 @@ import ( appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" @@ -34,6 +35,7 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1/v1beta1test" @@ -1728,7 +1730,7 @@ func TestVirtualMCPServerPodTemplateMetadataNeedsUpdate(t *testing.T) { vmcpConfigChecksum := testChecksumValue expectedLabels, expectedAnnotations := reconciler.buildPodTemplateMetadata( - labelsForVirtualMCPServer(vmcp.Name), vmcp, vmcpConfigChecksum, + labelsForVirtualMCPServer(vmcp.Name), vmcp, vmcpConfigChecksum, "", ) tests := []struct { @@ -1883,7 +1885,7 @@ func TestVirtualMCPServerPodTemplateMetadataNeedsUpdate(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - needsUpdate := reconciler.podTemplateMetadataNeedsUpdate(tt.deployment, tt.vmcp, tt.checksum) + needsUpdate := reconciler.podTemplateMetadataNeedsUpdate(tt.deployment, tt.vmcp, tt.checksum, "") assert.Equal(t, tt.expectedUpdate, needsUpdate) }) } @@ -1904,7 +1906,7 @@ func TestVirtualMCPServerDeploymentNeedsUpdate(t *testing.T) { vmcpConfigChecksum := testChecksumValue expectedLabels, expectedAnnotations := reconciler.buildPodTemplateMetadata( - labelsForVirtualMCPServer(vmcp.Name), vmcp, vmcpConfigChecksum, + labelsForVirtualMCPServer(vmcp.Name), vmcp, vmcpConfigChecksum, "", ) tests := []struct { @@ -2112,7 +2114,7 @@ func TestVirtualMCPServerDeploymentNeedsUpdate(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - needsUpdate := reconciler.deploymentNeedsUpdate(context.Background(), tt.deployment, vmcp, vmcpConfigChecksum, nil, []workloads.TypedWorkload{}) + needsUpdate := reconciler.deploymentNeedsUpdate(context.Background(), tt.deployment, vmcp, vmcpConfigChecksum, "", nil, []workloads.TypedWorkload{}) assert.Equal(t, tt.expectedUpdate, needsUpdate) }) } @@ -2586,7 +2588,7 @@ func TestVirtualMCPServerEnsureDeployment_NoUpdateNeeded(t *testing.T) { // Create deployment matching current spec expectedLabels, expectedAnnotations := reconciler.buildPodTemplateMetadata( - labelsForVirtualMCPServer(vmcp.Name), vmcp, "test-checksum", + labelsForVirtualMCPServer(vmcp.Name), vmcp, "test-checksum", "", ) correctDeployment := &appsv1.Deployment{ @@ -2680,7 +2682,7 @@ func TestVirtualMCPServerEnsureDeployment_RemovesStaleHashAnnotation(t *testing. } expectedLabels, expectedAnnotations := reconciler.buildPodTemplateMetadata( - labelsForVirtualMCPServer(vmcp.Name), vmcp, "test-checksum", + labelsForVirtualMCPServer(vmcp.Name), vmcp, "test-checksum", "", ) // Deployment otherwise matches the desired state exactly, except it @@ -4284,3 +4286,85 @@ func TestVirtualMCPServerReconciler_handleInvalidEmbeddedAuthServerConfig(t *tes require.NotNil(t, ready) assert.Equal(t, metav1.ConditionFalse, ready.Status) } + +// vmcpCABundleAuthServerConfig returns an inline auth server config whose only +// defect is whatever state the referenced "bundle" ConfigMap is left in. +func vmcpCABundleAuthServerConfig() *mcpv1beta1.EmbeddedAuthServerConfig { + return &mcpv1beta1.EmbeddedAuthServerConfig{ + Issuer: "https://auth.example.com", + SigningKeySecretRefs: []mcpv1beta1.SecretKeyRef{{Name: "signing-key", Key: "private.pem"}}, + HMACSecretRefs: []mcpv1beta1.SecretKeyRef{{Name: "hmac-secret", Key: "hmac"}}, + UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{{ + Name: "upstream", + Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{ + IssuerURL: "https://idp.example.com", + ClientID: "client", + CABundleRef: &mcpv1beta1.CABundleSource{ConfigMapRef: &corev1.ConfigMapKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: "bundle"}, Key: "ca.crt", + }}, + }, + }}, + } +} + +// A bundle ConfigMap without the key it names cannot be fixed by retrying, so +// the failure is recorded and reported as terminal rather than propagated. +func TestVirtualMCPServer_AuthServerConfigCABundleInvalidIsTerminal(t *testing.T) { + t.Parallel() + + vmcp := v1beta1test.NewVirtualMCPServer(testVmcpName, "default", + v1beta1test.MutateVMCP(func(v *mcpv1beta1.VirtualMCPServer) { + v.Generation = 1 + v.Spec.AuthServerConfig = vmcpCABundleAuthServerConfig() + }), + ) + bundle := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "bundle", Namespace: "default"}} + reconciler, _ := newTestVirtualMCPServerReconciler(t, vmcp, bundle) + statusManager := virtualmcpserverstatus.NewStatusManager(vmcp) + + terminal, err := reconciler.validateAuthServerConfigCABundles(t.Context(), vmcp, statusManager) + + require.Error(t, err) + assert.True(t, terminal, "malformed bundle content is terminal") +} + +// A ConfigMap read that fails for reasons unrelated to its content is transient: +// it must propagate so the caller requeues, and must not be painted onto status +// as a spec defect that would outlive the outage. +func TestVirtualMCPServer_AuthServerConfigCABundleGetErrorIsTransient(t *testing.T) { + t.Parallel() + + vmcp := v1beta1test.NewVirtualMCPServer(testVmcpName, "default", + v1beta1test.MutateVMCP(func(v *mcpv1beta1.VirtualMCPServer) { + v.Generation = 1 + v.Spec.AuthServerConfig = vmcpCABundleAuthServerConfig() + }), + ) + scheme := testutil.NewScheme(t) + fakeClient := fake.NewClientBuilder().WithScheme(scheme). + WithObjects(vmcp, &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "bundle", Namespace: "default"}}). + WithStatusSubresource(&mcpv1beta1.VirtualMCPServer{}). + WithInterceptorFuncs(interceptor.Funcs{ + Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if _, ok := obj.(*corev1.ConfigMap); ok && key.Name == "bundle" { + return apierrors.NewServiceUnavailable("apiserver is having a moment") + } + return c.Get(ctx, key, obj, opts...) + }, + }). + Build() + reconciler := &VirtualMCPServerReconciler{ + Client: fakeClient, + Scheme: scheme, + PlatformDetector: ctrlutil.NewSharedPlatformDetector(), + } + statusManager := virtualmcpserverstatus.NewStatusManager(vmcp) + + terminal, err := reconciler.validateAuthServerConfigCABundles(t.Context(), vmcp, statusManager) + + require.Error(t, err, "a transient read failure must surface so the caller requeues") + assert.False(t, terminal, "an unavailable apiserver is not a spec defect") + assert.NotEqual(t, mcpv1beta1.VirtualMCPServerPhaseFailed, vmcp.Status.Phase, + "a transient read failure must not stamp a terminal phase") +} diff --git a/cmd/thv-operator/controllers/virtualmcpserver_default_imagepullsecrets_test.go b/cmd/thv-operator/controllers/virtualmcpserver_default_imagepullsecrets_test.go index 3b35843a0e..e05f9ec9eb 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_default_imagepullsecrets_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_default_imagepullsecrets_test.go @@ -81,7 +81,7 @@ func TestVirtualMCPServer_DefaultImagePullSecrets(t *testing.T) { } // Verify Deployment PodSpec carries the merged list. - dep := r.deploymentForVirtualMCPServer(t.Context(), vmcp, "test-checksum", nil, []workloads.TypedWorkload{}) + dep := r.deploymentForVirtualMCPServer(t.Context(), vmcp, "test-checksum", "", nil, []workloads.TypedWorkload{}) require.NotNil(t, dep) assert.Equal(t, tt.wantSecrets, dep.Spec.Template.Spec.ImagePullSecrets, "vMCP Deployment ImagePullSecrets must reflect merged defaults+CR") diff --git a/cmd/thv-operator/controllers/virtualmcpserver_deployment.go b/cmd/thv-operator/controllers/virtualmcpserver_deployment.go index 87656b7f7c..2d090d9b25 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_deployment.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_deployment.go @@ -136,6 +136,7 @@ func (r *VirtualMCPServerReconciler) deploymentForVirtualMCPServer( ctx context.Context, vmcp *mcpv1beta1.VirtualMCPServer, vmcpConfigChecksum string, + caBundleChecksum string, telemetryCfg *mcpv1beta1.MCPTelemetryConfig, typedWorkloads []workloads.TypedWorkload, ) *appsv1.Deployment { @@ -174,12 +175,17 @@ func (r *VirtualMCPServerReconciler) deploymentForVirtualMCPServer( // env vars are injected by buildEnvVarsForVmcp above so the drift check stays // symmetric with what is built here (see #5616). if vmcp.Spec.AuthServerConfig != nil { - authServerVolumes, authServerMounts := ctrlutil.GenerateAuthServerVolumes(vmcp.Spec.AuthServerConfig) + authServerVolumes, authServerMounts, err := ctrlutil.GenerateAuthServerVolumes(vmcp.Spec.AuthServerConfig) + if err != nil { + log.FromContext(ctx).Error(err, "Failed to build embedded auth server CA volumes") + return nil + } volumes = append(volumes, authServerVolumes...) volumeMounts = append(volumeMounts, authServerMounts...) } deploymentLabels, deploymentAnnotations := r.buildDeploymentMetadataForVmcp(ls, vmcp) - deploymentTemplateLabels, deploymentTemplateAnnotations := r.buildPodTemplateMetadata(ls, vmcp, vmcpConfigChecksum) + deploymentTemplateLabels, deploymentTemplateAnnotations := r.buildPodTemplateMetadata( + ls, vmcp, vmcpConfigChecksum, caBundleChecksum) podSecurityContext, containerSecurityContext := r.buildSecurityContextsForVmcp(ctx, vmcp) serviceAccountName := r.serviceAccountNameForVmcp(vmcp) @@ -980,12 +986,16 @@ func (*VirtualMCPServerReconciler) buildPodTemplateMetadata( baseLabels map[string]string, _ *mcpv1beta1.VirtualMCPServer, vmcpConfigChecksum string, + caBundleChecksum string, ) (map[string]string, map[string]string) { templateLabels := baseLabels // Add vmcp Config checksum annotation to trigger pod rollout when config changes // Use the standard checksum package helper for consistency templateAnnotations := checksum.AddRunConfigChecksumToPodTemplate(nil, vmcpConfigChecksum) + if caBundleChecksum != "" { + templateAnnotations[ctrlutil.AuthServerCABundleChecksumAnnotation] = caBundleChecksum + } return templateLabels, templateAnnotations } diff --git a/cmd/thv-operator/controllers/virtualmcpserver_deployment_test.go b/cmd/thv-operator/controllers/virtualmcpserver_deployment_test.go index 4ec79339d0..f1496e6127 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_deployment_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_deployment_test.go @@ -53,7 +53,7 @@ func TestDeploymentForVirtualMCPServer(t *testing.T) { PlatformDetector: ctrlutil.NewSharedPlatformDetector(), } - deployment := r.deploymentForVirtualMCPServer(context.Background(), vmcp, "test-checksum", nil, []workloads.TypedWorkload{}) + deployment := r.deploymentForVirtualMCPServer(context.Background(), vmcp, "test-checksum", "", nil, []workloads.TypedWorkload{}) require.NotNil(t, deployment) assert.Equal(t, vmcp.Name, deployment.Name) @@ -107,7 +107,7 @@ func TestDeploymentForVirtualMCPServer_WithDelegateClientSecrets(t *testing.T) { Scheme: testutil.NewScheme(t), PlatformDetector: ctrlutil.NewSharedPlatformDetector(), } - deployment := r.deploymentForVirtualMCPServer(context.Background(), vmcp, "test-checksum", nil, nil) + deployment := r.deploymentForVirtualMCPServer(context.Background(), vmcp, "test-checksum", "", nil, nil) require.NotNil(t, deployment) for _, envVar := range deployment.Spec.Template.Spec.Containers[0].Env { @@ -147,7 +147,7 @@ func TestDeploymentForVirtualMCPServer_WithRedisPassword(t *testing.T) { PlatformDetector: ctrlutil.NewSharedPlatformDetector(), } - deployment := r.deploymentForVirtualMCPServer(context.Background(), vmcp, "test-checksum", nil, []workloads.TypedWorkload{}) + deployment := r.deploymentForVirtualMCPServer(context.Background(), vmcp, "test-checksum", "", nil, []workloads.TypedWorkload{}) require.NotNil(t, deployment) require.Len(t, deployment.Spec.Template.Spec.Containers, 1) @@ -386,7 +386,7 @@ func TestBuildPodTemplateMetadata(t *testing.T) { checksumValue := "test-checksum-123" r := &VirtualMCPServerReconciler{} - labels, annotations := r.buildPodTemplateMetadata(baseLabels, vmcp, checksumValue) + labels, annotations := r.buildPodTemplateMetadata(baseLabels, vmcp, checksumValue, "") assert.Equal(t, baseLabels, labels) assert.Equal(t, checksumValue, annotations[checksum.RunConfigChecksumAnnotation]) @@ -541,12 +541,12 @@ func TestDeploymentNeedsUpdate(t *testing.T) { } // Test nil inputs - assert.True(t, r.deploymentNeedsUpdate(context.Background(), nil, nil, "", nil, []workloads.TypedWorkload{})) + assert.True(t, r.deploymentNeedsUpdate(context.Background(), nil, nil, "", "", nil, []workloads.TypedWorkload{})) vmcp := v1beta1test.NewVirtualMCPServer("test-vmcp", "default") // Test with nil deployment - assert.True(t, r.deploymentNeedsUpdate(context.Background(), nil, vmcp, "checksum", nil, []workloads.TypedWorkload{})) + assert.True(t, r.deploymentNeedsUpdate(context.Background(), nil, vmcp, "checksum", "", nil, []workloads.TypedWorkload{})) } // TestServiceNeedsUpdate tests service update detection @@ -953,7 +953,7 @@ func TestDeploymentForVirtualMCPServer_ImagePullSecrets(t *testing.T) { PlatformDetector: ctrlutil.NewSharedPlatformDetector(), } - deployment := r.deploymentForVirtualMCPServer(t.Context(), vmcp, "test-checksum", nil, []workloads.TypedWorkload{}) + deployment := r.deploymentForVirtualMCPServer(t.Context(), vmcp, "test-checksum", "", nil, []workloads.TypedWorkload{}) require.NotNil(t, deployment) assert.ElementsMatch(t, tt.expected, deployment.Spec.Template.Spec.ImagePullSecrets) @@ -1041,7 +1041,7 @@ func TestDeploymentForVirtualMCPServer_ImagePullSecrets_UpdatePath(t *testing.T) } // Step 1: build the initial Deployment, simulating the create path. - initialDep := r.deploymentForVirtualMCPServer(t.Context(), vmcp, "test-checksum", nil, []workloads.TypedWorkload{}) + initialDep := r.deploymentForVirtualMCPServer(t.Context(), vmcp, "test-checksum", "", nil, []workloads.TypedWorkload{}) require.NotNil(t, initialDep) // Step 2: mutate the spec, then assert drift detection. @@ -1058,13 +1058,13 @@ func TestDeploymentForVirtualMCPServer_ImagePullSecrets_UpdatePath(t *testing.T) // out env/checksum so the rest of the chain doesn't trigger drift on // other axes for unrelated reasons. parentNeedsUpdate := r.deploymentNeedsUpdate( - t.Context(), initialDep, vmcp, "test-checksum", nil, []workloads.TypedWorkload{}, + t.Context(), initialDep, vmcp, "test-checksum", "", nil, []workloads.TypedWorkload{}, ) assert.True(t, parentNeedsUpdate, "deploymentNeedsUpdate must propagate imagePullSecrets drift") // Step 3: rebuild the Deployment with the updated spec and assert the // live PodSpec.ImagePullSecrets reflects the new value. - updatedDep := r.deploymentForVirtualMCPServer(t.Context(), vmcp, "test-checksum", nil, []workloads.TypedWorkload{}) + updatedDep := r.deploymentForVirtualMCPServer(t.Context(), vmcp, "test-checksum", "", nil, []workloads.TypedWorkload{}) require.NotNil(t, updatedDep) assert.ElementsMatch(t, tt.expectedDeployedSecret, updatedDep.Spec.Template.Spec.ImagePullSecrets) @@ -1119,7 +1119,7 @@ func TestDeploymentForVirtualMCPServer_AuthServerConfig_NoUpdateLoop(t *testing. ) const cfgChecksum = "test-checksum" - dep := r.deploymentForVirtualMCPServer(t.Context(), vmcp, cfgChecksum, nil, []workloads.TypedWorkload{}) + dep := r.deploymentForVirtualMCPServer(t.Context(), vmcp, cfgChecksum, "", nil, []workloads.TypedWorkload{}) require.NotNil(t, dep) // Sanity: the auth-server client-secret env var must actually be on the built @@ -1136,7 +1136,7 @@ func TestDeploymentForVirtualMCPServer_AuthServerConfig_NoUpdateLoop(t *testing. // The freshly-built Deployment is steady state: a drift check against it must // not request an update. Before the fix this returned true on every reconcile. - needsUpdate := r.deploymentNeedsUpdate(t.Context(), dep, vmcp, cfgChecksum, nil, []workloads.TypedWorkload{}) + needsUpdate := r.deploymentNeedsUpdate(t.Context(), dep, vmcp, cfgChecksum, "", nil, []workloads.TypedWorkload{}) assert.False(t, needsUpdate, "deploymentNeedsUpdate must not loop on a vMCP with AuthServerConfig (regression #5616)") } diff --git a/cmd/thv-operator/controllers/virtualmcpserver_podtemplatespec_reconcile_test.go b/cmd/thv-operator/controllers/virtualmcpserver_podtemplatespec_reconcile_test.go index 691b28ad1d..9511fc4bd4 100644 --- a/cmd/thv-operator/controllers/virtualmcpserver_podtemplatespec_reconcile_test.go +++ b/cmd/thv-operator/controllers/virtualmcpserver_podtemplatespec_reconcile_test.go @@ -79,8 +79,8 @@ func TestVirtualMCPServerPodTemplateSpecDeterministic(t *testing.T) { } // Generate deployment twice with same input - dep1 := reconciler.deploymentForVirtualMCPServer(context.Background(), vmcp, "test-checksum", nil, []workloads.TypedWorkload{}) - dep2 := reconciler.deploymentForVirtualMCPServer(context.Background(), vmcp, "test-checksum", nil, []workloads.TypedWorkload{}) + dep1 := reconciler.deploymentForVirtualMCPServer(context.Background(), vmcp, "test-checksum", "", nil, []workloads.TypedWorkload{}) + dep2 := reconciler.deploymentForVirtualMCPServer(context.Background(), vmcp, "test-checksum", "", nil, []workloads.TypedWorkload{}) // Both should be non-nil assert.NotNil(t, dep1, "First deployment should not be nil") @@ -145,7 +145,7 @@ func TestVirtualMCPServerPodTemplateSpecPreservesContainer(t *testing.T) { Scheme: scheme, } - dep := reconciler.deploymentForVirtualMCPServer(context.Background(), vmcp, "test-checksum", nil, []workloads.TypedWorkload{}) + dep := reconciler.deploymentForVirtualMCPServer(context.Background(), vmcp, "test-checksum", "", nil, []workloads.TypedWorkload{}) // Verify deployment was created assert.NotNil(t, dep, "Deployment should not be nil") @@ -361,7 +361,7 @@ func TestVirtualMCPServerPodTemplateSpecPreservesUndetectedFields(t *testing.T) } dep := reconciler.deploymentForVirtualMCPServer( - context.Background(), vmcp, "test-checksum", nil, []workloads.TypedWorkload{}) + context.Background(), vmcp, "test-checksum", "", nil, []workloads.TypedWorkload{}) require.NotNil(t, dep, "Deployment should not be nil") assert.Len(t, dep.Spec.Template.Spec.Containers, 1, "vmcp container should be preserved") @@ -416,7 +416,7 @@ func TestVirtualMCPServerPodTemplateSpecResourceOverride(t *testing.T) { Scheme: scheme, } - dep := reconciler.deploymentForVirtualMCPServer(context.Background(), vmcp, "test-checksum", nil, []workloads.TypedWorkload{}) + dep := reconciler.deploymentForVirtualMCPServer(context.Background(), vmcp, "test-checksum", "", nil, []workloads.TypedWorkload{}) require.NotNil(t, dep, "Deployment should not be nil") require.Len(t, dep.Spec.Template.Spec.Containers, 1, "Should have exactly one container") diff --git a/cmd/thv-operator/pkg/controllerutil/authserver.go b/cmd/thv-operator/pkg/controllerutil/authserver.go index 0056f0806f..62ad53bd52 100644 --- a/cmd/thv-operator/pkg/controllerutil/authserver.go +++ b/cmd/thv-operator/pkg/controllerutil/authserver.go @@ -5,6 +5,8 @@ package controllerutil import ( "context" + "crypto/sha256" + "encoding/hex" "fmt" "strings" @@ -15,6 +17,7 @@ import ( mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/oidc" + "github.com/stacklok/toolhive/cmd/thv-operator/pkg/validation" "github.com/stacklok/toolhive/pkg/authserver" authrunner "github.com/stacklok/toolhive/pkg/authserver/runner" "github.com/stacklok/toolhive/pkg/authserver/server/tokenexchange" @@ -54,6 +57,18 @@ const ( // AuthServerHMACFilePattern is the pattern for HMAC secret filenames AuthServerHMACFilePattern = "hmac-%d" + // AuthServerUpstreamCABundleVolumePrefix is the prefix for upstream CA bundle volume names. + AuthServerUpstreamCABundleVolumePrefix = "authserver-upstream-ca-" + + // AuthServerUpstreamCABundleMountPath is the base path for upstream CA bundles. + AuthServerUpstreamCABundleMountPath = "/etc/toolhive/authserver/upstream-ca" + + // AuthServerUpstreamCABundleFileName is the fixed projected filename for upstream CA bundles. + AuthServerUpstreamCABundleFileName = validation.OIDCCABundleDefaultKey + + // AuthServerCABundleChecksumAnnotation triggers a rollout when a selected upstream CA changes. + AuthServerCABundleChecksumAnnotation = "toolhive.stacklok.dev/authserver-ca-checksum" + // UpstreamClientSecretEnvVar is the prefix for upstream client secret environment variables. // Actual names are TOOLHIVE_UPSTREAM_CLIENT_SECRET_ where PROVIDER is the // upstream name uppercased with hyphens replaced by underscores (e.g., @@ -328,14 +343,79 @@ func GenerateAuthServerConfigByName( return nil, nil, nil, fmt.Errorf("embedded auth server configuration is nil for type embeddedAuthServer") } - volumes, volumeMounts := GenerateAuthServerVolumes(authServerConfig) + if err := ValidateEmbeddedAuthServerCABundles(ctx, c, namespace, authServerConfig); err != nil { + return nil, nil, nil, fmt.Errorf("failed to validate embedded auth server CA bundles: %w", err) + } + + volumes, volumeMounts, err := GenerateAuthServerVolumes(authServerConfig) + if err != nil { + return nil, nil, nil, err + } envVars := GenerateAuthServerEnvVars(authServerConfig) return volumes, volumeMounts, envVars, nil } -// GenerateAuthServerVolumes creates volumes and volume mounts for embedded auth server -// signing keys and HMAC secrets. Returns slices of volumes and volume mounts. +// EmbeddedAuthServerCABundleChecksum returns a checksum of the selected bytes in all +// upstream CA ConfigMaps used by an embedded auth server. ConfigMap metadata and +// unselected keys are deliberately excluded. +func EmbeddedAuthServerCABundleChecksum( + ctx context.Context, c client.Client, namespace, configName string, +) (string, error) { + config, err := GetExternalAuthConfigByName(ctx, c, namespace, configName) + if err != nil { + return "", fmt.Errorf("failed to get MCPExternalAuthConfig: %w", err) + } + if config.Spec.Type != mcpv1beta1.ExternalAuthTypeEmbeddedAuthServer || config.Spec.EmbeddedAuthServer == nil { + return "", nil + } + return EmbeddedAuthServerCABundleChecksumForConfig(ctx, c, namespace, config.Spec.EmbeddedAuthServer) +} + +// EmbeddedAuthServerCABundleChecksumForConfig returns a checksum of the selected +// bytes in all upstream CA ConfigMaps used by an inline embedded auth server. +// ConfigMap metadata and unselected keys are deliberately excluded. +func EmbeddedAuthServerCABundleChecksumForConfig( + ctx context.Context, c client.Client, namespace string, config *mcpv1beta1.EmbeddedAuthServerConfig, +) (string, error) { + if config == nil { + return "", nil + } + + hash := sha256.New() + found := false + for _, provider := range config.UpstreamProviders { + value, err := embeddedAuthServerCABundleValue(ctx, c, namespace, &provider) + if err != nil { + return "", err + } + if value == nil { + continue + } + _, _ = hash.Write(value) + _, _ = hash.Write([]byte{0}) + found = true + } + if !found { + return "", nil + } + return hex.EncodeToString(hash.Sum(nil)), nil +} + +func embeddedAuthServerCABundleValue( + ctx context.Context, c client.Client, namespace string, + provider *mcpv1beta1.UpstreamProviderConfig, +) ([]byte, error) { + ref := provider.CABundleRef() + if ref == nil { + return nil, nil + } + return ResolveCABundle(ctx, c, namespace, ref) +} + +// GenerateAuthServerVolumes generates volumes and mounts for auth server +// signing keys, HMAC secrets, Redis CA certificates, and upstream CA bundles. +// Returns an error when an upstream CA bundle reference is malformed. // The volumes are configured with 0400 permissions for security. // // For signing keys, files are mounted at /etc/toolhive/authserver/keys/key-{N}.pem @@ -344,9 +424,9 @@ func GenerateAuthServerConfigByName( // Returns nil slices if authConfig is nil. func GenerateAuthServerVolumes( authConfig *mcpv1beta1.EmbeddedAuthServerConfig, -) ([]corev1.Volume, []corev1.VolumeMount) { +) ([]corev1.Volume, []corev1.VolumeMount, error) { if authConfig == nil { - return nil, nil + return nil, nil, nil } var volumes []corev1.Volume @@ -457,7 +537,53 @@ func GenerateAuthServerVolumes( } } - return volumes, volumeMounts + upstreamVolumes, upstreamMounts, err := generateUpstreamCABundleVolumes(authConfig.UpstreamProviders) + if err != nil { + return nil, nil, err + } + volumes = append(volumes, upstreamVolumes...) + volumeMounts = append(volumeMounts, upstreamMounts...) + + return volumes, volumeMounts, nil +} + +func generateUpstreamCABundleVolumes( + providers []mcpv1beta1.UpstreamProviderConfig, +) ([]corev1.Volume, []corev1.VolumeMount, error) { + var volumes []corev1.Volume + var mounts []corev1.VolumeMount + for index, provider := range providers { + ref := provider.CABundleRef() + if ref == nil { + continue + } + if ref.ConfigMapRef == nil || ref.ConfigMapRef.Name == "" { + return nil, nil, fmt.Errorf("upstreamProviders[%d].caBundleRef.configMapRef.name is required", index) + } + key := ref.ConfigMapRef.Key + if key == "" { + key = AuthServerUpstreamCABundleFileName + } + volumeName := fmt.Sprintf("%s%d", AuthServerUpstreamCABundleVolumePrefix, index) + mountPath := upstreamCABundleFilePath(index) + volumes = append(volumes, corev1.Volume{ + Name: volumeName, + VolumeSource: corev1.VolumeSource{ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: ref.ConfigMapRef.Name}, + Items: []corev1.KeyToPath{{Key: key, Path: AuthServerUpstreamCABundleFileName}}, + }}, + }) + mounts = append(mounts, corev1.VolumeMount{ + Name: volumeName, MountPath: mountPath, SubPath: AuthServerUpstreamCABundleFileName, ReadOnly: true, + }) + } + return volumes, mounts, nil +} + +// upstreamCABundleFilePath returns the path for the provider at the given index. +// The index must match the provider's position in UpstreamProviders. +func upstreamCABundleFilePath(index int) string { + return fmt.Sprintf("%s/%d/%s", AuthServerUpstreamCABundleMountPath, index, AuthServerUpstreamCABundleFileName) } // GenerateAuthServerEnvVars creates environment variables for embedded auth server. @@ -714,8 +840,8 @@ func BuildAuthServerRunConfig( // Build upstream provider configs using shared bindings bindings := buildUpstreamSecretBindings(authConfig.UpstreamProviders) config.Upstreams = make([]authserver.UpstreamRunConfig, 0, len(bindings)) - for _, b := range bindings { - upstream, err := buildUpstreamRunConfig(&b, resourceURL) + for index, b := range bindings { + upstream, err := buildUpstreamRunConfig(&b, index, resourceURL) if err != nil { return nil, fmt.Errorf("upstream %q: %w", b.Provider.Name, err) } @@ -945,6 +1071,7 @@ func defaultRedirectURI(resourceURL string) string { // the project convention of rejecting malformed objects as early as possible. func buildUpstreamRunConfig( b *upstreamSecretBinding, + index int, resourceURL string, ) (*authserver.UpstreamRunConfig, error) { provider := b.Provider @@ -956,12 +1083,12 @@ func buildUpstreamRunConfig( switch provider.Type { case mcpv1beta1.UpstreamProviderTypeOIDC: if provider.OIDCConfig != nil { - config.OIDCConfig = buildOIDCUpstreamRunConfig(provider.OIDCConfig, b.EnvVarName, resourceURL) + config.OIDCConfig = buildOIDCUpstreamRunConfig(provider.OIDCConfig, b.EnvVarName, index, resourceURL) } case mcpv1beta1.UpstreamProviderTypeOAuth2: if provider.OAuth2Config != nil { oauth2, err := buildOAuth2UpstreamRunConfig( - provider.OAuth2Config, b.EnvVarName, b.DCRInitialAccessTokenEnvVar, resourceURL) + provider.OAuth2Config, b.EnvVarName, b.DCRInitialAccessTokenEnvVar, index, resourceURL) if err != nil { return nil, err } @@ -978,6 +1105,7 @@ func buildUpstreamRunConfig( func buildOIDCUpstreamRunConfig( cfg *mcpv1beta1.OIDCUpstreamConfig, clientSecretEnvVar string, + index int, resourceURL string, ) *authserver.OIDCUpstreamRunConfig { redirectURI := cfg.RedirectURI @@ -991,10 +1119,14 @@ func buildOIDCUpstreamRunConfig( Scopes: cfg.Scopes, AdditionalAuthorizationParams: cfg.AdditionalAuthorizationParams, SubjectClaim: cfg.SubjectClaim, + AllowPrivateIPs: cfg.AllowPrivateIPs, } if cfg.ClientSecretRef != nil { runConfig.ClientSecretEnvVar = clientSecretEnvVar } + if cfg.CABundleRef != nil { + runConfig.CAFilePath = upstreamCABundleFilePath(index) + } if cfg.UserInfoOverride != nil { runConfig.UserInfoOverride = buildUserInfoRunConfig(cfg.UserInfoOverride) } @@ -1018,6 +1150,7 @@ func buildOAuth2UpstreamRunConfig( cfg *mcpv1beta1.OAuth2UpstreamConfig, clientSecretEnvVar string, initialAccessTokenEnvVar string, + index int, resourceURL string, ) (*authserver.OAuth2UpstreamRunConfig, error) { if err := mcpv1beta1.ValidateOAuth2DCRConfig(cfg); err != nil { @@ -1035,10 +1168,15 @@ func buildOAuth2UpstreamRunConfig( RedirectURI: redirectURI, Scopes: cfg.Scopes, AdditionalAuthorizationParams: cfg.AdditionalAuthorizationParams, + InsecureAllowHTTP: cfg.InsecureAllowHTTP, + AllowPrivateIPs: cfg.AllowPrivateIPs, } if cfg.ClientSecretRef != nil { runConfig.ClientSecretEnvVar = clientSecretEnvVar } + if cfg.CABundleRef != nil { + runConfig.CAFilePath = upstreamCABundleFilePath(index) + } if cfg.UserInfo != nil { runConfig.UserInfo = buildUserInfoRunConfig(cfg.UserInfo) } @@ -1062,8 +1200,6 @@ func buildOAuth2UpstreamRunConfig( if cfg.DCRConfig != nil { runConfig.DCRConfig = buildDCRUpstreamRunConfig(cfg.DCRConfig, initialAccessTokenEnvVar) } - runConfig.InsecureAllowHTTP = cfg.InsecureAllowHTTP - runConfig.AllowPrivateIPs = cfg.AllowPrivateIPs return runConfig, nil } diff --git a/cmd/thv-operator/pkg/controllerutil/authserver_test.go b/cmd/thv-operator/pkg/controllerutil/authserver_test.go index e6ad08b8f8..1654e06852 100644 --- a/cmd/thv-operator/pkg/controllerutil/authserver_test.go +++ b/cmd/thv-operator/pkg/controllerutil/authserver_test.go @@ -31,6 +31,150 @@ import ( "github.com/stacklok/toolhive/pkg/runner" ) +func TestEmbeddedAuthServerCABundleChecksumForConfig(t *testing.T) { + t.Parallel() + + pemData := testCertificatePEM(t) + ref := caBundleTestRef("") + config := &mcpv1beta1.EmbeddedAuthServerConfig{UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{{ + Name: "issuer", Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{CABundleRef: ref}, + }}} + newClient := func(objects ...client.Object) client.Client { + scheme := runtime.NewScheme() + require.NoError(t, corev1.AddToScheme(scheme)) + builder := fake.NewClientBuilder().WithScheme(scheme) + for _, object := range objects { + if object != nil { + builder = builder.WithObjects(object) + } + } + return builder.Build() + } + + tests := []struct { + name string + configMap *corev1.ConfigMap + noBundleRef bool + wantErr bool + wantEmpty bool + }{ + {name: "no bundle", configMap: nil, noBundleRef: true, wantEmpty: true}, + {name: "selected value", configMap: &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "bundle", Namespace: "ns"}, Data: map[string]string{"ca.crt": string(pemData)}}}, + {name: "missing key", configMap: &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "bundle", Namespace: "ns"}, Data: map[string]string{"other": string(pemData)}}, wantErr: true}, + {name: "missing ConfigMap", wantErr: true}, + {name: "binary data", configMap: &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "bundle", Namespace: "ns"}, BinaryData: map[string][]byte{"ca.crt": pemData}}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + cfg := config.DeepCopy() + if tt.noBundleRef { + cfg.UpstreamProviders[0].OIDCConfig.CABundleRef = nil + } + var c client.Client + if tt.configMap == nil { + c = newClient() + } else { + c = newClient(tt.configMap) + } + got, err := EmbeddedAuthServerCABundleChecksumForConfig(t.Context(), c, "ns", cfg) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + if tt.wantEmpty { + assert.Empty(t, got) + } else { + assert.NotEmpty(t, got) + } + }) + } +} + +func TestEmbeddedAuthServerCABundleChecksumStability(t *testing.T) { + t.Parallel() + + pemData := testCertificatePEM(t) + config := &mcpv1beta1.EmbeddedAuthServerConfig{UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{{ + Name: "issuer", Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{CABundleRef: caBundleTestRef("")}, + }}} + newClient := func(objects ...client.Object) client.Client { + scheme := runtime.NewScheme() + require.NoError(t, corev1.AddToScheme(scheme)) + builder := fake.NewClientBuilder().WithScheme(scheme) + for _, object := range objects { + if object != nil { + builder = builder.WithObjects(object) + } + } + return builder.Build() + } + changed := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "bundle", Namespace: "ns", Labels: map[string]string{"initial": "yes"}}, Data: map[string]string{"ca.crt": string(pemData), "other": "unchanged"}} + first, err := EmbeddedAuthServerCABundleChecksumForConfig(t.Context(), newClient(changed), "ns", config) + require.NoError(t, err) + changed.Data["other"] = "changed" + changed.Labels["changed"] = "yes" + second, err := EmbeddedAuthServerCABundleChecksumForConfig(t.Context(), newClient(changed), "ns", config) + require.NoError(t, err) + assert.Equal(t, first, second) + changed.Data["ca.crt"] = string(testCertificatePEM(t)) + second, err = EmbeddedAuthServerCABundleChecksumForConfig(t.Context(), newClient(changed), "ns", config) + require.NoError(t, err) + assert.NotEqual(t, first, second) + + secondConfig := config.DeepCopy() + secondConfig.UpstreamProviders = append(secondConfig.UpstreamProviders, mcpv1beta1.UpstreamProviderConfig{ + Name: "oauth", Type: mcpv1beta1.UpstreamProviderTypeOAuth2, + OAuth2Config: &mcpv1beta1.OAuth2UpstreamConfig{CABundleRef: &mcpv1beta1.CABundleSource{ConfigMapRef: &corev1.ConfigMapKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: "bundle-two"}}}}, + }) + secondMap := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "bundle-two", Namespace: "ns"}, Data: map[string]string{"ca.crt": string(pemData)}} + baseline, err := EmbeddedAuthServerCABundleChecksumForConfig(t.Context(), newClient(changed, secondMap), "ns", secondConfig) + require.NoError(t, err) + secondMap.Data["ca.crt"] = string(testCertificatePEM(t)) + rotated, err := EmbeddedAuthServerCABundleChecksumForConfig(t.Context(), newClient(changed, secondMap), "ns", secondConfig) + require.NoError(t, err) + assert.NotEqual(t, baseline, rotated) +} + +func TestGenerateUpstreamCABundleVolumes(t *testing.T) { + t.Parallel() + + providers := []mcpv1beta1.UpstreamProviderConfig{ + {Name: "oidc", Type: mcpv1beta1.UpstreamProviderTypeOIDC, OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{CABundleRef: caBundleTestRef("")}}, + {Name: "oauth", Type: mcpv1beta1.UpstreamProviderTypeOAuth2, OAuth2Config: &mcpv1beta1.OAuth2UpstreamConfig{CABundleRef: caBundleTestRef("custom.pem")}}, + } + volumes, mounts, err := generateUpstreamCABundleVolumes(providers) + require.NoError(t, err) + require.Len(t, volumes, 2) + require.Len(t, mounts, 2) + seen := map[string]bool{} + for i, mount := range mounts { + assert.Equal(t, upstreamCABundleFilePath(i), mount.MountPath) + assert.Equal(t, AuthServerUpstreamCABundleFileName, mount.SubPath) + assert.Equal(t, fmt.Sprintf("authserver-upstream-ca-%d", i), volumes[i].Name) + assert.False(t, seen[volumes[i].Name]) + seen[volumes[i].Name] = true + cm := volumes[i].ConfigMap + require.NotNil(t, cm) + assert.Equal(t, "bundle", cm.Name) + key := "ca.crt" + if i == 1 { + key = "custom.pem" + } + assert.Equal(t, []corev1.KeyToPath{{Key: key, Path: AuthServerUpstreamCABundleFileName}}, cm.Items) + } + + providers[0].OIDCConfig.CABundleRef.ConfigMapRef = nil + _, _, err = generateUpstreamCABundleVolumes(providers) + require.Error(t, err) + providers[0].OIDCConfig.CABundleRef.ConfigMapRef = &corev1.ConfigMapKeySelector{} + _, _, err = generateUpstreamCABundleVolumes(providers) + require.Error(t, err) +} + func TestGenerateAuthServerVolumes(t *testing.T) { t.Parallel() @@ -141,7 +285,8 @@ func TestGenerateAuthServerVolumes(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - volumes, mounts := GenerateAuthServerVolumes(tt.authConfig) + volumes, mounts, err := GenerateAuthServerVolumes(tt.authConfig) + require.NoError(t, err) assert.Len(t, volumes, tt.wantVolumes) assert.Len(t, mounts, tt.wantMounts) @@ -301,7 +446,8 @@ func TestGenerateAuthServerVolumes_RedisTLS(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - volumes, mounts := GenerateAuthServerVolumes(tt.authConfig) + volumes, mounts, err := GenerateAuthServerVolumes(tt.authConfig) + require.NoError(t, err) // Count TLS-specific volumes tlsVolCount := 0 @@ -1817,7 +1963,7 @@ func TestBuildOAuth2UpstreamRunConfig_TransportOptions(t *testing.T) { ClientID: "client-id", InsecureAllowHTTP: true, AllowPrivateIPs: true, - }, "", "", "") + }, "", "", 0, "") require.NoError(t, err) assert.True(t, runConfig.InsecureAllowHTTP) assert.True(t, runConfig.AllowPrivateIPs) @@ -2109,7 +2255,8 @@ func TestVolumePathPatterns(t *testing.T) { }, } - volumes, mounts := GenerateAuthServerVolumes(authConfig) + volumes, mounts, err := GenerateAuthServerVolumes(authConfig) + require.NoError(t, err) require.Len(t, volumes, 4) require.Len(t, mounts, 4) diff --git a/cmd/thv-operator/pkg/controllerutil/ca_bundle.go b/cmd/thv-operator/pkg/controllerutil/ca_bundle.go new file mode 100644 index 0000000000..a52020c698 --- /dev/null +++ b/cmd/thv-operator/pkg/controllerutil/ca_bundle.go @@ -0,0 +1,129 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package controllerutil + +import ( + "bytes" + "context" + "crypto/x509" + "encoding/pem" + "fmt" + + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + "github.com/stacklok/toolhive/cmd/thv-operator/pkg/validation" +) + +// InvalidCABundleError identifies a terminal CA bundle configuration or content error. +// ConfigMap read errors are deliberately excluded because they may be transient. +type InvalidCABundleError struct { + err error +} + +func (e *InvalidCABundleError) Error() string { + return e.err.Error() +} + +func (e *InvalidCABundleError) Unwrap() error { + return e.err +} + +func invalidCABundleError(err error) error { + return &InvalidCABundleError{err: err} +} + +// ResolveCABundle reads and validates a CA bundle referenced by a workload. +// ConfigMapKeySelector is deliberately resolved here rather than left to pod +// admission: an optional or malformed reference must never result in a +// workload without the trust roots its upstream requires. +// +// The returned bytes are not retained by the operator. Callers only need the +// successful result to gate reconciliation; the workload still mounts the +// ConfigMap so updates can be observed by kubelet. +func ResolveCABundle(ctx context.Context, c client.Reader, namespace string, ref *mcpv1beta1.CABundleSource) ([]byte, error) { + // Shape-only: the upstream CA volume name is index-derived, so the OIDC + // ConfigMap-name length cap does not apply here. + if err := validation.ValidateCABundleSourceShape(ref); err != nil { + return nil, invalidCABundleError(err) + } + if ref == nil || ref.ConfigMapRef == nil { + return nil, nil + } + if ref.ConfigMapRef.Optional != nil && *ref.ConfigMapRef.Optional { + return nil, invalidCABundleError(fmt.Errorf("caBundleRef.configMapRef.optional must be false or omitted")) + } + + configMap := &corev1.ConfigMap{} + name := ref.ConfigMapRef.Name + if err := c.Get(ctx, client.ObjectKey{Namespace: namespace, Name: name}, configMap); err != nil { + return nil, fmt.Errorf("failed to get CA bundle ConfigMap %q in namespace %q: %w", name, namespace, err) + } + + key := ref.ConfigMapRef.Key + if key == "" { + key = validation.OIDCCABundleDefaultKey + } + value, ok := configMap.Data[key] + if !ok { + binaryValue, binaryOK := configMap.BinaryData[key] + if binaryOK { + value = string(binaryValue) + ok = true + } + } + if !ok { + return nil, invalidCABundleError(fmt.Errorf("CA bundle ConfigMap %q does not contain key %q in data or binaryData", name, key)) + } + if err := validatePEMCertificates([]byte(value)); err != nil { + return nil, invalidCABundleError(fmt.Errorf("CA bundle ConfigMap %q key %q is invalid: %w", name, key, err)) + } + return []byte(value), nil +} + +func validatePEMCertificates(value []byte) error { + if len(bytes.TrimSpace(value)) == 0 { + return fmt.Errorf("certificate data is empty") + } + remaining := value + count := 0 + for len(bytes.TrimSpace(remaining)) > 0 { + block, rest := pem.Decode(remaining) + if block == nil { + return fmt.Errorf("contains non-PEM certificate data") + } + if block.Type != "CERTIFICATE" { + return fmt.Errorf("contains PEM block of type %q, expected CERTIFICATE", block.Type) + } + if _, err := x509.ParseCertificate(block.Bytes); err != nil { + return fmt.Errorf("contains an invalid certificate: %w", err) + } + count++ + remaining = rest + } + if count == 0 { + return fmt.Errorf("certificate data is empty") + } + return nil +} + +// ValidateEmbeddedAuthServerCABundles resolves every upstream CA reference. +func ValidateEmbeddedAuthServerCABundles( + ctx context.Context, c client.Reader, namespace string, cfg *mcpv1beta1.EmbeddedAuthServerConfig, +) error { + if cfg == nil { + return nil + } + for i := range cfg.UpstreamProviders { + provider := &cfg.UpstreamProviders[i] + ref := provider.CABundleRef() + if ref != nil { + if _, err := ResolveCABundle(ctx, c, namespace, ref); err != nil { + return fmt.Errorf("upstreamProviders[%d] (%q) caBundleRef: %w", i, provider.Name, err) + } + } + } + return nil +} diff --git a/cmd/thv-operator/pkg/controllerutil/ca_bundle_test.go b/cmd/thv-operator/pkg/controllerutil/ca_bundle_test.go new file mode 100644 index 0000000000..63eeebfeeb --- /dev/null +++ b/cmd/thv-operator/pkg/controllerutil/ca_bundle_test.go @@ -0,0 +1,208 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package controllerutil + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "math/big" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + + mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + "github.com/stacklok/toolhive/cmd/thv-operator/pkg/validation" +) + +func TestResolveCABundle(t *testing.T) { + t.Parallel() + + validPEM := testCertificatePEM(t) + optional := true + tests := []struct { + name string + namespace string + ref *mcpv1beta1.CABundleSource + objects []client.Object + want []byte + wantErr string + }{ + { + name: "default key from Data", + namespace: "workload", + ref: caBundleTestRef(""), + objects: []client.Object{&corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "bundle", Namespace: "workload"}, Data: map[string]string{validation.OIDCCABundleDefaultKey: string(validPEM)}}}, + want: validPEM, + }, + { + name: "explicit key from BinaryData", + namespace: "workload", + ref: caBundleTestRef("custom.pem"), + objects: []client.Object{&corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "bundle", Namespace: "workload"}, BinaryData: map[string][]byte{"custom.pem": validPEM}}}, + want: validPEM, + }, + { + // The OIDC volume-name cap (48 chars) must not apply here: upstream CA + // volumes are named by provider index, never by ConfigMap name. + name: "name longer than the OIDC volume-name cap resolves", + namespace: "workload", + ref: &mcpv1beta1.CABundleSource{ConfigMapRef: &corev1.ConfigMapKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: strings.Repeat("a", 60)}, + }}, + objects: []client.Object{&corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: strings.Repeat("a", 60), Namespace: "workload"}, + Data: map[string]string{validation.OIDCCABundleDefaultKey: string(validPEM)}, + }}, + want: validPEM, + }, + { + name: "missing key", + namespace: "workload", + ref: caBundleTestRef("missing"), + objects: []client.Object{&corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "bundle", Namespace: "workload"}, Data: map[string]string{}}}, + wantErr: "does not contain key", + }, + { + name: "empty PEM", + namespace: "workload", + ref: caBundleTestRef("ca.crt"), + objects: []client.Object{&corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "bundle", Namespace: "workload"}, Data: map[string]string{"ca.crt": " "}}}, + wantErr: "certificate data is empty", + }, + { + name: "invalid PEM", + namespace: "workload", + ref: caBundleTestRef("ca.crt"), + objects: []client.Object{&corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "bundle", Namespace: "workload"}, Data: map[string]string{"ca.crt": "not a certificate"}}}, + wantErr: "non-PEM certificate data", + }, + { + name: "optional is rejected", + namespace: "workload", + ref: &mcpv1beta1.CABundleSource{ConfigMapRef: &corev1.ConfigMapKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: "bundle"}, Key: "ca.crt", Optional: &optional}}, + wantErr: "optional must be false or omitted", + }, + { + name: "same namespace is required", + namespace: "workload", + ref: caBundleTestRef("ca.crt"), + objects: []client.Object{&corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "bundle", Namespace: "other"}, Data: map[string]string{"ca.crt": string(validPEM)}}}, + wantErr: "failed to get CA bundle ConfigMap", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := ResolveCABundle( + context.Background(), + fake.NewClientBuilder().WithObjects(tt.objects...).Build(), + tt.namespace, + tt.ref, + ) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + return + } + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } +} + +func TestResolveCABundlePreservesTransientAPIError(t *testing.T) { + t.Parallel() + transient := errors.New("temporary apiserver failure") + c := fake.NewClientBuilder().WithInterceptorFuncs(interceptor.Funcs{ + Get: func(context.Context, client.WithWatch, client.ObjectKey, client.Object, ...client.GetOption) error { + return transient + }, + }).Build() + + _, err := ResolveCABundle(context.Background(), c, "workload", caBundleTestRef("ca.crt")) + require.Error(t, err) + require.ErrorIs(t, err, transient) +} + +func TestValidateEmbeddedAuthServerCABundlesClassifiesErrors(t *testing.T) { + t.Parallel() + + config := &mcpv1beta1.EmbeddedAuthServerConfig{UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{{ + Name: "upstream", + Type: mcpv1beta1.UpstreamProviderTypeOIDC, + OIDCConfig: &mcpv1beta1.OIDCUpstreamConfig{ + CABundleRef: caBundleTestRef("ca.crt"), + }, + }}} + transient := errors.New("temporary apiserver failure") + tests := []struct { + name string + client client.Reader + wantInvalidCA bool + wantUnderlying error + }{ + { + name: "invalid content is terminal through wrapping", + client: fake.NewClientBuilder().WithObjects(&corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "bundle", Namespace: "workload"}, + Data: map[string]string{"ca.crt": "not a certificate"}, + }).Build(), + wantInvalidCA: true, + }, + { + name: "ConfigMap Get failure remains transient", + client: fake.NewClientBuilder().WithInterceptorFuncs(interceptor.Funcs{ + Get: func(context.Context, client.WithWatch, client.ObjectKey, client.Object, ...client.GetOption) error { + return transient + }, + }).Build(), + wantUnderlying: transient, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := ValidateEmbeddedAuthServerCABundles(context.Background(), tt.client, "workload", config) + require.Error(t, err) + var invalidCABundleErr *InvalidCABundleError + assert.Equal(t, tt.wantInvalidCA, errors.As(err, &invalidCABundleErr)) + if tt.wantUnderlying != nil { + require.ErrorIs(t, err, tt.wantUnderlying) + } + }) + } +} + +func caBundleTestRef(key string) *mcpv1beta1.CABundleSource { + return &mcpv1beta1.CABundleSource{ConfigMapRef: &corev1.ConfigMapKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: "bundle"}, + Key: key, + }} +} + +func testCertificatePEM(t *testing.T) []byte { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + template := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test"}, NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(time.Hour), IsCA: true, BasicConstraintsValid: true} + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + require.NoError(t, err) + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) +} diff --git a/cmd/thv-operator/pkg/validation/oidc_validation.go b/cmd/thv-operator/pkg/validation/oidc_validation.go index 8ca6d6a32b..9f7d0b87ec 100644 --- a/cmd/thv-operator/pkg/validation/oidc_validation.go +++ b/cmd/thv-operator/pkg/validation/oidc_validation.go @@ -31,11 +31,16 @@ const ( maxConfigMapNameForCABundle = maxK8sVolumeName - len(OIDCCABundleVolumePrefix) ) -// ValidateCABundleSource validates the CABundleSource configuration. -// It ensures that configMapRef is specified when CABundleRef is provided, -// and that the ConfigMap name is short enough to fit in a Kubernetes volume name. +// ValidateCABundleSourceShape validates that a CABundleSource names a ConfigMap, +// without applying the OIDC volume-name length cap. // Returns nil if ref is nil (no CA bundle configured). -func ValidateCABundleSource(ref *mcpv1beta1.CABundleSource) error { +// +// Use this for CA bundles whose volume name does not embed the ConfigMap name. +// Embedded auth server upstream bundles are mounted under volumes named by +// provider index (AuthServerUpstreamCABundleVolumePrefix + index), so the +// ConfigMap name never reaches a Kubernetes volume name and the cap enforced by +// ValidateCABundleSource would reject otherwise-valid names. +func ValidateCABundleSourceShape(ref *mcpv1beta1.CABundleSource) error { if ref == nil { return nil } @@ -45,6 +50,24 @@ func ValidateCABundleSource(ref *mcpv1beta1.CABundleSource) error { if ref.ConfigMapRef.Name == "" { return fmt.Errorf("configMapRef.name must be specified") } + return nil +} + +// ValidateCABundleSource validates the CABundleSource configuration. +// It ensures that configMapRef is specified when CABundleRef is provided, +// and that the ConfigMap name is short enough to fit in a Kubernetes volume name. +// Returns nil if ref is nil (no CA bundle configured). +// +// The length cap applies only because OIDC CA bundle volumes are named +// OIDCCABundleVolumePrefix + configMapName. Callers whose volume names do not +// embed the ConfigMap name want ValidateCABundleSourceShape instead. +func ValidateCABundleSource(ref *mcpv1beta1.CABundleSource) error { + if err := ValidateCABundleSourceShape(ref); err != nil { + return err + } + if ref == nil { + return nil + } // Check that the ConfigMap name won't cause the volume name to exceed K8s limits if len(ref.ConfigMapRef.Name) > maxConfigMapNameForCABundle { return fmt.Errorf("configMapRef.name %q is too long (%d chars); maximum is %d characters to fit in Kubernetes volume name", diff --git a/cmd/thv-operator/pkg/validation/oidc_validation_test.go b/cmd/thv-operator/pkg/validation/oidc_validation_test.go index 393dacf4b9..82475da7c2 100644 --- a/cmd/thv-operator/pkg/validation/oidc_validation_test.go +++ b/cmd/thv-operator/pkg/validation/oidc_validation_test.go @@ -86,6 +86,66 @@ func TestValidateCABundleSource(t *testing.T) { } } +func TestValidateCABundleSourceShape(t *testing.T) { + t.Parallel() + + // Longer than the 48-char cap ValidateCABundleSource enforces for OIDC + // volume names, which must not apply to the shape-only check. + const overOIDCCapLength = 60 + + tests := []struct { + name string + ref *mcpv1beta1.CABundleSource + wantErr bool + errContains string + }{ + { + name: "nil ref is valid", + ref: nil, + wantErr: false, + }, + { + name: "valid configMapRef with name and key", + ref: makeCABundleSource("my-ca", "ca.crt"), + wantErr: false, + }, + { + name: "missing configMapRef", + ref: &mcpv1beta1.CABundleSource{}, + wantErr: true, + errContains: "configMapRef must be specified in caBundleRef", + }, + { + name: "empty configMapRef name", + ref: makeCABundleSource("", ""), + wantErr: true, + errContains: "configMapRef.name must be specified", + }, + { + name: "name longer than the OIDC volume-name cap is accepted", + ref: makeCABundleSource(strings.Repeat("a", overOIDCCapLength), ""), + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := validation.ValidateCABundleSourceShape(tt.ref) + + if tt.wantErr { + assert.Error(t, err) + if tt.errContains != "" { + assert.ErrorContains(t, err, tt.errContains) + } + } else { + assert.NoError(t, err) + } + }) + } +} + func TestValidateOIDCIssuerURL(t *testing.T) { t.Parallel() diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml index a7435ff006..bc17ce60bb 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml @@ -1055,6 +1055,40 @@ spec: OAuth authorization endpoint. pattern: ^https?://.*$ type: string + caBundleRef: + description: |- + CABundleRef references a ConfigMap containing a CA bundle added to the + system roots when connecting to this upstream; it does not restrict trust + to this bundle or disable public-root trust. The selected key is projected + as ca.crt. + properties: + configMapRef: + description: |- + ConfigMapRef references a ConfigMap containing the CA certificate bundle. + The ConfigMap key is required by the API. If omitted in a stored object, it + defaults to "ca.crt" for backwards compatibility. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object clientId: description: |- ClientID is the OAuth 2.0 client identifier registered with the upstream IDP. @@ -1358,6 +1392,46 @@ spec: scope, state, code_challenge, code_challenge_method, nonce) are not allowed. maxProperties: 16 type: object + allowPrivateIPs: + description: |- + AllowPrivateIPs permits the upstream provider's HTTP client to connect to + private IP ranges (RFC-1918, link-local). Use only when the upstream is + hosted inside the same cluster and has no public endpoint. + type: boolean + caBundleRef: + description: |- + CABundleRef references a ConfigMap containing a CA bundle added to the + system roots when connecting to this upstream; it does not restrict trust + to this bundle or disable public-root trust. The selected key is projected + as ca.crt. + properties: + configMapRef: + description: |- + ConfigMapRef references a ConfigMap containing the CA certificate bundle. + The ConfigMap key is required by the API. If omitted in a stored object, it + defaults to "ca.crt" for backwards compatibility. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object clientId: description: ClientID is the OAuth 2.0 client identifier registered with the upstream IdP. @@ -3047,6 +3121,40 @@ spec: OAuth authorization endpoint. pattern: ^https?://.*$ type: string + caBundleRef: + description: |- + CABundleRef references a ConfigMap containing a CA bundle added to the + system roots when connecting to this upstream; it does not restrict trust + to this bundle or disable public-root trust. The selected key is projected + as ca.crt. + properties: + configMapRef: + description: |- + ConfigMapRef references a ConfigMap containing the CA certificate bundle. + The ConfigMap key is required by the API. If omitted in a stored object, it + defaults to "ca.crt" for backwards compatibility. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object clientId: description: |- ClientID is the OAuth 2.0 client identifier registered with the upstream IDP. @@ -3350,6 +3458,46 @@ spec: scope, state, code_challenge, code_challenge_method, nonce) are not allowed. maxProperties: 16 type: object + allowPrivateIPs: + description: |- + AllowPrivateIPs permits the upstream provider's HTTP client to connect to + private IP ranges (RFC-1918, link-local). Use only when the upstream is + hosted inside the same cluster and has no public endpoint. + type: boolean + caBundleRef: + description: |- + CABundleRef references a ConfigMap containing a CA bundle added to the + system roots when connecting to this upstream; it does not restrict trust + to this bundle or disable public-root trust. The selected key is projected + as ca.crt. + properties: + configMapRef: + description: |- + ConfigMapRef references a ConfigMap containing the CA certificate bundle. + The ConfigMap key is required by the API. If omitted in a stored object, it + defaults to "ca.crt" for backwards compatibility. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object clientId: description: ClientID is the OAuth 2.0 client identifier registered with the upstream IdP. diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpoidcconfigs.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpoidcconfigs.yaml index a0da4c26a8..717507970b 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpoidcconfigs.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpoidcconfigs.yaml @@ -74,7 +74,8 @@ spec: configMapRef: description: |- ConfigMapRef references a ConfigMap containing the CA certificate bundle. - If Key is not specified, it defaults to "ca.crt". + The ConfigMap key is required by the API. If omitted in a stored object, it + defaults to "ca.crt" for backwards compatibility. properties: key: description: The key to select. @@ -339,7 +340,8 @@ spec: configMapRef: description: |- ConfigMapRef references a ConfigMap containing the CA certificate bundle. - If Key is not specified, it defaults to "ca.crt". + The ConfigMap key is required by the API. If omitted in a stored object, it + defaults to "ca.crt" for backwards compatibility. properties: key: description: The key to select. diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpserverentries.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpserverentries.yaml index 5b7b92204b..f345aed4bb 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpserverentries.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpserverentries.yaml @@ -87,7 +87,8 @@ spec: configMapRef: description: |- ConfigMapRef references a ConfigMap containing the CA certificate bundle. - If Key is not specified, it defaults to "ca.crt". + The ConfigMap key is required by the API. If omitted in a stored object, it + defaults to "ca.crt" for backwards compatibility. properties: key: description: The key to select. @@ -353,7 +354,8 @@ spec: configMapRef: description: |- ConfigMapRef references a ConfigMap containing the CA certificate bundle. - If Key is not specified, it defaults to "ca.crt". + The ConfigMap key is required by the API. If omitted in a stored object, it + defaults to "ca.crt" for backwards compatibility. properties: key: description: The key to select. diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcptelemetryconfigs.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcptelemetryconfigs.yaml index 9382db66be..f44fcf55b6 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcptelemetryconfigs.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcptelemetryconfigs.yaml @@ -81,7 +81,8 @@ spec: configMapRef: description: |- ConfigMapRef references a ConfigMap containing the CA certificate bundle. - If Key is not specified, it defaults to "ca.crt". + The ConfigMap key is required by the API. If omitted in a stored object, it + defaults to "ca.crt" for backwards compatibility. properties: key: description: The key to select. @@ -369,7 +370,8 @@ spec: configMapRef: description: |- ConfigMapRef references a ConfigMap containing the CA certificate bundle. - If Key is not specified, it defaults to "ca.crt". + The ConfigMap key is required by the API. If omitted in a stored object, it + defaults to "ca.crt" for backwards compatibility. properties: key: description: The key to select. diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml index 7c1a777605..1364eb94da 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -931,6 +931,40 @@ spec: OAuth authorization endpoint. pattern: ^https?://.*$ type: string + caBundleRef: + description: |- + CABundleRef references a ConfigMap containing a CA bundle added to the + system roots when connecting to this upstream; it does not restrict trust + to this bundle or disable public-root trust. The selected key is projected + as ca.crt. + properties: + configMapRef: + description: |- + ConfigMapRef references a ConfigMap containing the CA certificate bundle. + The ConfigMap key is required by the API. If omitted in a stored object, it + defaults to "ca.crt" for backwards compatibility. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object clientId: description: |- ClientID is the OAuth 2.0 client identifier registered with the upstream IDP. @@ -1234,6 +1268,46 @@ spec: scope, state, code_challenge, code_challenge_method, nonce) are not allowed. maxProperties: 16 type: object + allowPrivateIPs: + description: |- + AllowPrivateIPs permits the upstream provider's HTTP client to connect to + private IP ranges (RFC-1918, link-local). Use only when the upstream is + hosted inside the same cluster and has no public endpoint. + type: boolean + caBundleRef: + description: |- + CABundleRef references a ConfigMap containing a CA bundle added to the + system roots when connecting to this upstream; it does not restrict trust + to this bundle or disable public-root trust. The selected key is projected + as ca.crt. + properties: + configMapRef: + description: |- + ConfigMapRef references a ConfigMap containing the CA certificate bundle. + The ConfigMap key is required by the API. If omitted in a stored object, it + defaults to "ca.crt" for backwards compatibility. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object clientId: description: ClientID is the OAuth 2.0 client identifier registered with the upstream IdP. @@ -4934,6 +5008,40 @@ spec: OAuth authorization endpoint. pattern: ^https?://.*$ type: string + caBundleRef: + description: |- + CABundleRef references a ConfigMap containing a CA bundle added to the + system roots when connecting to this upstream; it does not restrict trust + to this bundle or disable public-root trust. The selected key is projected + as ca.crt. + properties: + configMapRef: + description: |- + ConfigMapRef references a ConfigMap containing the CA certificate bundle. + The ConfigMap key is required by the API. If omitted in a stored object, it + defaults to "ca.crt" for backwards compatibility. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object clientId: description: |- ClientID is the OAuth 2.0 client identifier registered with the upstream IDP. @@ -5237,6 +5345,46 @@ spec: scope, state, code_challenge, code_challenge_method, nonce) are not allowed. maxProperties: 16 type: object + allowPrivateIPs: + description: |- + AllowPrivateIPs permits the upstream provider's HTTP client to connect to + private IP ranges (RFC-1918, link-local). Use only when the upstream is + hosted inside the same cluster and has no public endpoint. + type: boolean + caBundleRef: + description: |- + CABundleRef references a ConfigMap containing a CA bundle added to the + system roots when connecting to this upstream; it does not restrict trust + to this bundle or disable public-root trust. The selected key is projected + as ca.crt. + properties: + configMapRef: + description: |- + ConfigMapRef references a ConfigMap containing the CA certificate bundle. + The ConfigMap key is required by the API. If omitted in a stored object, it + defaults to "ca.crt" for backwards compatibility. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object clientId: description: ClientID is the OAuth 2.0 client identifier registered with the upstream IdP. diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml index b3f9d12e6b..c25695fea3 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml @@ -1058,6 +1058,40 @@ spec: OAuth authorization endpoint. pattern: ^https?://.*$ type: string + caBundleRef: + description: |- + CABundleRef references a ConfigMap containing a CA bundle added to the + system roots when connecting to this upstream; it does not restrict trust + to this bundle or disable public-root trust. The selected key is projected + as ca.crt. + properties: + configMapRef: + description: |- + ConfigMapRef references a ConfigMap containing the CA certificate bundle. + The ConfigMap key is required by the API. If omitted in a stored object, it + defaults to "ca.crt" for backwards compatibility. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object clientId: description: |- ClientID is the OAuth 2.0 client identifier registered with the upstream IDP. @@ -1361,6 +1395,46 @@ spec: scope, state, code_challenge, code_challenge_method, nonce) are not allowed. maxProperties: 16 type: object + allowPrivateIPs: + description: |- + AllowPrivateIPs permits the upstream provider's HTTP client to connect to + private IP ranges (RFC-1918, link-local). Use only when the upstream is + hosted inside the same cluster and has no public endpoint. + type: boolean + caBundleRef: + description: |- + CABundleRef references a ConfigMap containing a CA bundle added to the + system roots when connecting to this upstream; it does not restrict trust + to this bundle or disable public-root trust. The selected key is projected + as ca.crt. + properties: + configMapRef: + description: |- + ConfigMapRef references a ConfigMap containing the CA certificate bundle. + The ConfigMap key is required by the API. If omitted in a stored object, it + defaults to "ca.crt" for backwards compatibility. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object clientId: description: ClientID is the OAuth 2.0 client identifier registered with the upstream IdP. @@ -3050,6 +3124,40 @@ spec: OAuth authorization endpoint. pattern: ^https?://.*$ type: string + caBundleRef: + description: |- + CABundleRef references a ConfigMap containing a CA bundle added to the + system roots when connecting to this upstream; it does not restrict trust + to this bundle or disable public-root trust. The selected key is projected + as ca.crt. + properties: + configMapRef: + description: |- + ConfigMapRef references a ConfigMap containing the CA certificate bundle. + The ConfigMap key is required by the API. If omitted in a stored object, it + defaults to "ca.crt" for backwards compatibility. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object clientId: description: |- ClientID is the OAuth 2.0 client identifier registered with the upstream IDP. @@ -3353,6 +3461,46 @@ spec: scope, state, code_challenge, code_challenge_method, nonce) are not allowed. maxProperties: 16 type: object + allowPrivateIPs: + description: |- + AllowPrivateIPs permits the upstream provider's HTTP client to connect to + private IP ranges (RFC-1918, link-local). Use only when the upstream is + hosted inside the same cluster and has no public endpoint. + type: boolean + caBundleRef: + description: |- + CABundleRef references a ConfigMap containing a CA bundle added to the + system roots when connecting to this upstream; it does not restrict trust + to this bundle or disable public-root trust. The selected key is projected + as ca.crt. + properties: + configMapRef: + description: |- + ConfigMapRef references a ConfigMap containing the CA certificate bundle. + The ConfigMap key is required by the API. If omitted in a stored object, it + defaults to "ca.crt" for backwards compatibility. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object clientId: description: ClientID is the OAuth 2.0 client identifier registered with the upstream IdP. diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpoidcconfigs.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpoidcconfigs.yaml index c04a73286f..e7ec90fc28 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpoidcconfigs.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpoidcconfigs.yaml @@ -77,7 +77,8 @@ spec: configMapRef: description: |- ConfigMapRef references a ConfigMap containing the CA certificate bundle. - If Key is not specified, it defaults to "ca.crt". + The ConfigMap key is required by the API. If omitted in a stored object, it + defaults to "ca.crt" for backwards compatibility. properties: key: description: The key to select. @@ -342,7 +343,8 @@ spec: configMapRef: description: |- ConfigMapRef references a ConfigMap containing the CA certificate bundle. - If Key is not specified, it defaults to "ca.crt". + The ConfigMap key is required by the API. If omitted in a stored object, it + defaults to "ca.crt" for backwards compatibility. properties: key: description: The key to select. diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpserverentries.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpserverentries.yaml index c1f1616f9f..7030fe7ddb 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpserverentries.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpserverentries.yaml @@ -90,7 +90,8 @@ spec: configMapRef: description: |- ConfigMapRef references a ConfigMap containing the CA certificate bundle. - If Key is not specified, it defaults to "ca.crt". + The ConfigMap key is required by the API. If omitted in a stored object, it + defaults to "ca.crt" for backwards compatibility. properties: key: description: The key to select. @@ -356,7 +357,8 @@ spec: configMapRef: description: |- ConfigMapRef references a ConfigMap containing the CA certificate bundle. - If Key is not specified, it defaults to "ca.crt". + The ConfigMap key is required by the API. If omitted in a stored object, it + defaults to "ca.crt" for backwards compatibility. properties: key: description: The key to select. diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcptelemetryconfigs.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcptelemetryconfigs.yaml index 4a0f2ce91a..ba633dd4cf 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcptelemetryconfigs.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcptelemetryconfigs.yaml @@ -84,7 +84,8 @@ spec: configMapRef: description: |- ConfigMapRef references a ConfigMap containing the CA certificate bundle. - If Key is not specified, it defaults to "ca.crt". + The ConfigMap key is required by the API. If omitted in a stored object, it + defaults to "ca.crt" for backwards compatibility. properties: key: description: The key to select. @@ -372,7 +373,8 @@ spec: configMapRef: description: |- ConfigMapRef references a ConfigMap containing the CA certificate bundle. - If Key is not specified, it defaults to "ca.crt". + The ConfigMap key is required by the API. If omitted in a stored object, it + defaults to "ca.crt" for backwards compatibility. properties: key: description: The key to select. diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml index 9ebbf20aed..cde010fffe 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -934,6 +934,40 @@ spec: OAuth authorization endpoint. pattern: ^https?://.*$ type: string + caBundleRef: + description: |- + CABundleRef references a ConfigMap containing a CA bundle added to the + system roots when connecting to this upstream; it does not restrict trust + to this bundle or disable public-root trust. The selected key is projected + as ca.crt. + properties: + configMapRef: + description: |- + ConfigMapRef references a ConfigMap containing the CA certificate bundle. + The ConfigMap key is required by the API. If omitted in a stored object, it + defaults to "ca.crt" for backwards compatibility. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object clientId: description: |- ClientID is the OAuth 2.0 client identifier registered with the upstream IDP. @@ -1237,6 +1271,46 @@ spec: scope, state, code_challenge, code_challenge_method, nonce) are not allowed. maxProperties: 16 type: object + allowPrivateIPs: + description: |- + AllowPrivateIPs permits the upstream provider's HTTP client to connect to + private IP ranges (RFC-1918, link-local). Use only when the upstream is + hosted inside the same cluster and has no public endpoint. + type: boolean + caBundleRef: + description: |- + CABundleRef references a ConfigMap containing a CA bundle added to the + system roots when connecting to this upstream; it does not restrict trust + to this bundle or disable public-root trust. The selected key is projected + as ca.crt. + properties: + configMapRef: + description: |- + ConfigMapRef references a ConfigMap containing the CA certificate bundle. + The ConfigMap key is required by the API. If omitted in a stored object, it + defaults to "ca.crt" for backwards compatibility. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object clientId: description: ClientID is the OAuth 2.0 client identifier registered with the upstream IdP. @@ -4937,6 +5011,40 @@ spec: OAuth authorization endpoint. pattern: ^https?://.*$ type: string + caBundleRef: + description: |- + CABundleRef references a ConfigMap containing a CA bundle added to the + system roots when connecting to this upstream; it does not restrict trust + to this bundle or disable public-root trust. The selected key is projected + as ca.crt. + properties: + configMapRef: + description: |- + ConfigMapRef references a ConfigMap containing the CA certificate bundle. + The ConfigMap key is required by the API. If omitted in a stored object, it + defaults to "ca.crt" for backwards compatibility. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object clientId: description: |- ClientID is the OAuth 2.0 client identifier registered with the upstream IDP. @@ -5240,6 +5348,46 @@ spec: scope, state, code_challenge, code_challenge_method, nonce) are not allowed. maxProperties: 16 type: object + allowPrivateIPs: + description: |- + AllowPrivateIPs permits the upstream provider's HTTP client to connect to + private IP ranges (RFC-1918, link-local). Use only when the upstream is + hosted inside the same cluster and has no public endpoint. + type: boolean + caBundleRef: + description: |- + CABundleRef references a ConfigMap containing a CA bundle added to the + system roots when connecting to this upstream; it does not restrict trust + to this bundle or disable public-root trust. The selected key is projected + as ca.crt. + properties: + configMapRef: + description: |- + ConfigMapRef references a ConfigMap containing the CA certificate bundle. + The ConfigMap key is required by the API. If omitted in a stored object, it + defaults to "ca.crt" for backwards compatibility. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object clientId: description: ClientID is the OAuth 2.0 client identifier registered with the upstream IdP. diff --git a/docs/arch/03-transport-architecture.md b/docs/arch/03-transport-architecture.md index 75db441c78..865132b3bb 100644 --- a/docs/arch/03-transport-architecture.md +++ b/docs/arch/03-transport-architecture.md @@ -297,6 +297,19 @@ Remote MCP servers can require OAuth 2.0 authentication. The architecture uses: - `pkg/transport/http.go` - `SetTokenSource` method - `pkg/auth/oauth/flow.go` - OAuth flow and TokenSource creation +### TLS and CA trust + +Outgoing HTTPS clients can use either a **pinned** or an **additive** CA bundle: + +- `WithCABundle` replaces the system root pool. Only certificates chaining to the supplied bundle are trusted. +- `WithSystemRootsPlusCABundle` starts with the system root pool and appends the supplied certificates. This supports an upstream that may use either a publicly trusted certificate or a private CA. + +The `caBundleRef` fields on embedded auth-server upstreams use additive trust. A referenced ConfigMap augments, rather than replaces, the system roots for that upstream's OAuth/OIDC requests, including discovery, token, user-info, and dynamic client registration requests. The reference is per upstream, so a bundle is not implicitly trusted for other upstreams or for unrelated ToolHive traffic. Existing callers that need strict CA pinning continue to use the pinned mode. + +In Kubernetes, the operator projects each selected ConfigMap key as a read-only `ca.crt` file in the proxyrunner pod and passes its path to the upstream client. The ConfigMap must contain PEM-encoded CA certificates. + +**Implementation:** `pkg/networking/http_client.go`, `pkg/authserver/upstream/`, and `pkg/auth/dcr/` + ### Remote vs Container Workloads | Feature | Container Workload | Remote Workload | @@ -770,9 +783,13 @@ when delivery lands it does not also require rewriting the fan-out primitives. **Architecture:** - **Remote MCP servers**: Full HTTPS support with certificate validation -- **Custom CA bundles**: Configurable via RunConfig for self-signed certificates +- **Custom CA bundles**: Configurable for clients that connect to private-CA or self-signed endpoints - **Local proxy**: HTTP only (localhost binding for security) -- **Trust store**: System CA bundle or custom CA bundle from configuration +- **Trust store**: Clients either use the system CA bundle, a pinned custom bundle, or (for embedded auth-server upstreams) system roots plus a custom bundle + +A custom CA bundle does not disable the HTTPS and network protections applied to the client. In particular, the server-supplied endpoint paths retain redirect and private-IP safeguards unless the corresponding explicit development or in-cluster options are configured. + +See [TLS and CA trust](#tls-and-ca-trust) for the distinction between pinned and additive trust and the Kubernetes `caBundleRef` configuration. ### Trust Proxy Headers diff --git a/docs/arch/09-operator-architecture.md b/docs/arch/09-operator-architecture.md index 01be9ec8d3..9c1540ddaa 100644 --- a/docs/arch/09-operator-architecture.md +++ b/docs/arch/09-operator-architecture.md @@ -222,7 +222,11 @@ Manages external authentication configurations that can be shared across multipl **Implementation**: `cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go` -MCPExternalAuthConfig allows you to define reusable authentication configurations that can be referenced by multiple MCPServer and MCPRemoteProxy resources. When using the embedded auth server type, the `storage` field supports configuring Redis Sentinel as a shared storage backend for horizontal scaling. See [Auth Server Storage](11-auth-server-storage.md) for details. +MCPExternalAuthConfig allows you to define reusable authentication configurations that can be referenced by multiple MCPServer and MCPRemoteProxy resources. For an `embeddedAuthServer`, each entry in `embeddedAuthServer.upstreamProviders` can set `oidcConfig.caBundleRef` or `oauth2Config.caBundleRef` to reference a namespace-local ConfigMap containing PEM-encoded CA certificates. The ConfigMap key is required and is projected read-only for that upstream, then added to the system trust pool; it does not replace public system roots. This allows different upstreams to use different private CAs without widening trust for other upstreams. When using the embedded auth server type, the `storage` field supports configuring Redis Sentinel as a shared storage backend for horizontal scaling. See [Auth Server Storage](11-auth-server-storage.md) for details. + +When the referenced CA data changes, the operator watches the ConfigMap, computes a checksum from the selected bundle bytes, and places that checksum on the proxyrunner pod template. A changed checksum causes a Deployment rollout so new pods use the updated trust material. The operator mounts the bundle for MCPServer and MCPRemoteProxy embedded auth servers; VirtualMCPServer uses the same upstream configuration when constructing its auth-server deployment. + +For a complete example, see [`examples/operator/external-auth/mcpexternalauthconfig_private_ca.yaml`](../../examples/operator/external-auth/mcpexternalauthconfig_private_ca.yaml). MCPExternalAuthConfig resources can be referenced via two paths: - `externalAuthConfigRef` — for outgoing auth types (token exchange, AWS STS, bearer token injection). This is the original reference path. diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md index 7272102c33..26bab8f5ed 100644 --- a/docs/operator/crd-api.md +++ b/docs/operator/crd-api.md @@ -1831,10 +1831,12 @@ _Appears in:_ - [api.v1beta1.InlineOIDCSharedConfig](#apiv1beta1inlineoidcsharedconfig) - [api.v1beta1.MCPServerEntrySpec](#apiv1beta1mcpserverentryspec) - [api.v1beta1.MCPTelemetryOTelConfig](#apiv1beta1mcptelemetryotelconfig) +- [api.v1beta1.OAuth2UpstreamConfig](#apiv1beta1oauth2upstreamconfig) +- [api.v1beta1.OIDCUpstreamConfig](#apiv1beta1oidcupstreamconfig) | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `configMapRef` _[ConfigMapKeySelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#configmapkeyselector-v1-core)_ | ConfigMapRef references a ConfigMap containing the CA certificate bundle.
If Key is not specified, it defaults to "ca.crt". | | Optional: \{\}
| +| `configMapRef` _[ConfigMapKeySelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#configmapkeyselector-v1-core)_ | ConfigMapRef references a ConfigMap containing the CA certificate bundle.
The ConfigMap key is required by the API. If omitted in a stored object, it
defaults to "ca.crt" for backwards compatibility. | | Optional: \{\}
| #### api.v1beta1.ConfigMapAuthzRef @@ -3670,6 +3672,7 @@ _Appears in:_ | `tokenResponseMapping` _[api.v1beta1.TokenResponseMapping](#apiv1beta1tokenresponsemapping)_ | TokenResponseMapping configures custom field extraction from non-standard token responses.
Some OAuth providers (e.g., GovSlack) nest token fields under non-standard paths
instead of returning them at the top level. When set, ToolHive performs the token
exchange HTTP call directly and extracts fields using the configured dot-notation paths.
If nil, standard OAuth 2.0 token response parsing is used.
For extracting user identity from the token response, see IdentityFromToken. | | Optional: \{\}
| | `identityFromToken` _[api.v1beta1.IdentityFromTokenConfig](#apiv1beta1identityfromtokenconfig)_ | IdentityFromToken extracts user identity (subject, name, email) directly
from the OAuth2 token-endpoint response body using gjson dot-notation paths.
When set, the embedded auth server skips the userinfo HTTP call entirely
and resolves identity from the token response. See IdentityFromTokenConfig
for trust-model and uniqueness considerations. | | Optional: \{\}
| | `additionalAuthorizationParams` _object (keys:string, values:string)_ | AdditionalAuthorizationParams are extra query parameters to include in
authorization requests sent to the upstream provider.
This is useful for providers that require custom parameters, such as
Google's access_type=offline for obtaining refresh tokens.
Framework-managed parameters (response_type, client_id, redirect_uri,
scope, state, code_challenge, code_challenge_method, nonce) are not allowed. | | MaxProperties: 16
Optional: \{\}
| +| `caBundleRef` _[api.v1beta1.CABundleSource](#apiv1beta1cabundlesource)_ | CABundleRef references a ConfigMap containing a CA bundle added to the
system roots when connecting to this upstream; it does not restrict trust
to this bundle or disable public-root trust. The selected key is projected
as ca.crt. | | Optional: \{\}
| | `insecureAllowHTTP` _boolean_ | InsecureAllowHTTP permits plain-HTTP authorization and token endpoint URLs
for this upstream. Only for in-cluster development environments (e.g. an
OAuth2 provider served over HTTP in a kind cluster) where TLS is not
available. Never set this in production. | | Optional: \{\}
| | `allowPrivateIPs` _boolean_ | AllowPrivateIPs permits the upstream provider's HTTP client to connect to
private IP ranges (RFC-1918, link-local). Use only when the upstream is
hosted inside the same cluster and has no public endpoint. HTTP-scheme
restrictions are unchanged — HTTPS is still required for non-localhost
hosts unless InsecureAllowHTTP is set. Defaults to false. | | Optional: \{\}
| | `dcrConfig` _[api.v1beta1.DCRUpstreamConfig](#apiv1beta1dcrupstreamconfig)_ | DCRConfig enables RFC 7591 Dynamic Client Registration against the upstream
authorization server. When set, the client credentials are obtained at
runtime rather than being pre-provisioned, and ClientID must be left empty.
Mutually exclusive with ClientID. | | Optional: \{\}
| @@ -3754,6 +3757,8 @@ _Appears in:_ | `userInfoOverride` _[api.v1beta1.UserInfoConfig](#apiv1beta1userinfoconfig)_ | UserInfoOverride allows customizing UserInfo fetching behavior for OIDC providers.
By default, the UserInfo endpoint is discovered automatically via OIDC discovery.
Use this to override the endpoint URL, HTTP method, or field mappings for providers
that return non-standard claim names in their UserInfo response. | | Optional: \{\}
| | `additionalAuthorizationParams` _object (keys:string, values:string)_ | AdditionalAuthorizationParams are extra query parameters to include in
authorization requests sent to the upstream provider.
This is useful for providers that require custom parameters, such as
Google's access_type=offline for obtaining refresh tokens.
Note: when using access_type=offline, also set explicit scopes to avoid
the default offline_access scope being sent alongside it.
Framework-managed parameters (response_type, client_id, redirect_uri,
scope, state, code_challenge, code_challenge_method, nonce) are not allowed. | | MaxProperties: 16
Optional: \{\}
| | `subjectClaim` _string_ | SubjectClaim names the validated ID-token claim to use as the upstream
subject. Defaults to "sub" when empty. Set it for IdPs where "sub" isn't
stable per user — e.g. Entra/Azure AD, whose "sub" rotates per application
and whose stable identifier is "oid".
The value is looked up verbatim as a top-level claim name, so it is
constrained to a claim-name shape: it must start with a letter or
underscore and contain only letters, digits, and underscores. This rejects
dotted, colon-namespaced, or whitespace-containing values at admission
rather than letting a typo silently miss the claim at login, and keeps the
field aligned with the directory service's per-issuer bindingClaim.
Changing this on a live deployment re-keys existing users (the value
resolves to the internal user ID), so treat it as immutable once users
exist.
Per-IdP notes:
- Entra/Azure AD: use "oid"; it is only emitted when the upstream scopes
include "profile". "oid" is unique within a single tenant — multi-tenant
apps need oid+tid, which this single-claim field cannot express.
- Okta: the org auth server already puts the stable id in "sub" (default
works). A custom auth server's "sub" is the mutable login/email and the
stable "uid" lives only in the access token, not the ID token — map a
custom ID-token claim and point subjectClaim at it.
The pattern matches the claim-name shape and allows empty (defaults to
"sub"). Using Pattern rather than a CEL XValidation rule keeps this off the
CRD's CEL cost budget — a single-field format check via CEL is rejected by
the apiserver as too expensive once multiplied across the upstreams list. | | MaxLength: 128
Pattern: `^([a-zA-Z_][a-zA-Z0-9_]*)?$`
Optional: \{\}
| +| `caBundleRef` _[api.v1beta1.CABundleSource](#apiv1beta1cabundlesource)_ | CABundleRef references a ConfigMap containing a CA bundle added to the
system roots when connecting to this upstream; it does not restrict trust
to this bundle or disable public-root trust. The selected key is projected
as ca.crt. | | Optional: \{\}
| +| `allowPrivateIPs` _boolean_ | AllowPrivateIPs permits the upstream provider's HTTP client to connect to
private IP ranges (RFC-1918, link-local). Use only when the upstream is
hosted inside the same cluster and has no public endpoint. | | Optional: \{\}
| #### api.v1beta1.OpenTelemetryMetricsConfig diff --git a/docs/server/docs.go b/docs/server/docs.go index b3cacf59c5..9dd75ae94c 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -8,171 +8,7 @@ const docTemplate = `{ "schemes": {{ marshal .Schemes }}, "components": { "schemas": { - "auth.TokenValidatorConfig": { - "description": "DEPRECATED: Middleware configuration.\nOIDCConfig contains OIDC configuration", - "properties": { - "allowPrivateIP": { - "description": "AllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses", - "type": "boolean" - }, - "audience": { - "description": "Audience is the expected audience for the token", - "type": "string" - }, - "authTokenFile": { - "description": "AuthTokenFile is the path to file containing bearer token for authentication", - "type": "string" - }, - "cacertPath": { - "description": "CACertPath is the path to the CA certificate bundle for HTTPS requests", - "type": "string" - }, - "clientID": { - "description": "ClientID is the OIDC client ID", - "type": "string" - }, - "clientSecret": { - "description": "ClientSecret is the optional OIDC client secret for introspection", - "type": "string" - }, - "insecureAllowHTTP": { - "description": "InsecureAllowHTTP allows HTTP (non-HTTPS) OIDC issuers for development/testing\nWARNING: This is insecure and should NEVER be used in production", - "type": "boolean" - }, - "introspectionURL": { - "description": "IntrospectionURL is the optional introspection endpoint for validating tokens", - "type": "string" - }, - "issuer": { - "description": "Issuer is the OIDC issuer URL (e.g., https://accounts.google.com)", - "type": "string" - }, - "jwksurl": { - "description": "JWKSURL is the URL to fetch the JWKS from", - "type": "string" - }, - "resourceURL": { - "description": "ResourceURL is the explicit resource URL for OAuth discovery (RFC 9728)", - "type": "string" - }, - "scopes": { - "description": "Scopes is the list of OAuth scopes to advertise in the well-known endpoint (RFC 9728)\nIf empty, defaults to [\"openid\"]", - "items": { - "type": "string" - }, - "type": "array" - } - }, - "type": "object" - }, - "core.Workload": { - "properties": { - "created_at": { - "description": "CreatedAt is the timestamp when the workload was created.", - "type": "string" - }, - "group": { - "description": "Group is the name of the group this workload belongs to, if any.", - "type": "string" - }, - "labels": { - "additionalProperties": { - "type": "string" - }, - "description": "Labels are the container labels (excluding standard ToolHive labels)", - "type": "object" - }, - "name": { - "description": "Name is the name of the workload.\nIt is used as a unique identifier.", - "type": "string" - }, - "package": { - "description": "Package specifies the Workload Package used to create this Workload.", - "type": "string" - }, - "port": { - "description": "Port is the port on which the workload is exposed.\nThis is embedded in the URL.", - "type": "integer" - }, - "proxy_mode": { - "description": "ProxyMode is the proxy mode that clients should use to connect.\nFor stdio transports, this will be the proxy mode (sse or streamable-http).\nFor direct transports (sse/streamable-http), this will be the same as TransportType.", - "type": "string" - }, - "remote": { - "description": "Remote indicates whether this is a remote workload (true) or a container workload (false).", - "type": "boolean" - }, - "started_at": { - "description": "StartedAt is when the container was last started (changes on restart)", - "type": "string" - }, - "status": { - "description": "Status is the current status of the workload.", - "enum": [ - "running", - "stopped", - "error", - "starting", - "stopping", - "unhealthy", - "removing", - "unknown", - "unauthenticated", - "auth_retrying", - "policy_stopped" - ], - "type": "string" - }, - "status_context": { - "description": "StatusContext provides additional context about the workload's status.\nThe exact meaning is determined by the status and the underlying runtime.", - "type": "string" - }, - "tools": { - "description": "ToolsFilter is the filter on tools applied to the workload.", - "items": { - "type": "string" - }, - "type": "array", - "uniqueItems": false - }, - "transport_type": { - "description": "TransportType is the type of transport used for this workload.", - "enum": [ - "stdio", - "sse", - "streamable-http", - "inspector" - ], - "type": "string" - }, - "url": { - "description": "URL is the URL of the workload exposed by the ToolHive proxy.", - "type": "string" - } - }, - "type": "object" - }, - "github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.RateLimitConfig": { - "description": "RateLimitConfig contains the CRD rate limiting configuration.\nWhen set, rate limiting middleware is added to the proxy middleware chain.", - "properties": { - "perUser": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_ratelimit_types.RateLimitBucket" - }, - "shared": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_ratelimit_types.RateLimitBucket" - }, - "tools": { - "description": "Tools defines per-tool rate limit overrides.\nEach entry applies additional rate limits to calls targeting a specific tool name.\nA request must pass both the server-level limit and the per-tool limit.\n+listType=map\n+listMapKey=name\n+optional", - "items": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_ratelimit_types.ToolRateLimitConfig" - }, - "type": "array", - "uniqueItems": false - } - }, - "type": "object" - }, - "github_com_stacklok_toolhive_pkg_audit.Config": { + "audit.Config": { "description": "DEPRECATED: Middleware configuration.\nAuditConfig contains the audit logging configuration", "properties": { "component": { @@ -226,88 +62,64 @@ const docTemplate = `{ }, "type": "object" }, - "github_com_stacklok_toolhive_pkg_auth_awssts.Config": { - "description": "AWSStsConfig contains AWS STS token exchange configuration for accessing AWS services", + "auth.TokenValidatorConfig": { + "description": "DEPRECATED: Middleware configuration.\nOIDCConfig contains OIDC configuration", "properties": { - "fallback_role_arn": { - "description": "FallbackRoleArn is the IAM role ARN to assume when no role mapping matches.", - "type": "string" - }, - "region": { - "description": "Region is the AWS region for STS and SigV4 signing.", - "type": "string" + "allowPrivateIP": { + "description": "AllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses", + "type": "boolean" }, - "role_claim": { - "description": "RoleClaim is the JWT claim to use for role mapping (default: \"groups\").", + "audience": { + "description": "Audience is the expected audience for the token", "type": "string" }, - "role_mappings": { - "description": "RoleMappings maps JWT claim values to IAM roles with priority.", - "items": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_auth_awssts.RoleMapping" - }, - "type": "array", - "uniqueItems": false - }, - "service": { - "description": "Service is the AWS service name for SigV4 signing (default: \"aws-mcp\").", + "authTokenFile": { + "description": "AuthTokenFile is the path to file containing bearer token for authentication", "type": "string" }, - "session_duration": { - "description": "SessionDuration is the duration in seconds for assumed role credentials (default: 3600).", - "type": "integer" - }, - "session_name_claim": { - "description": "SessionNameClaim is the JWT claim to use for role session name (default: \"sub\").", + "cacertPath": { + "description": "CACertPath is the path to the CA certificate bundle for HTTPS requests", "type": "string" }, - "subject_provider_name": { - "description": "SubjectProviderName identifies which upstream provider's access token to use\nfor STS AssumeRoleWithWebIdentity. Used by vMCP only. When empty, the bearer\ntoken from the incoming HTTP request is used.", - "type": "string" - } - }, - "type": "object" - }, - "github_com_stacklok_toolhive_pkg_auth_awssts.RoleMapping": { - "properties": { - "claim": { - "description": "Claim is the simple claim value to match (e.g., group name).\nInternally compiles to a CEL expression: \"\u003cclaim_value\u003e\" in claims[\"\u003crole_claim\u003e\"]\nMutually exclusive with Matcher.", + "clientID": { + "description": "ClientID is the OIDC client ID", "type": "string" }, - "matcher": { - "description": "Matcher is a CEL expression for complex matching against JWT claims.\nThe expression has access to a \"claims\" variable containing all JWT claims.\nExamples:\n - \"admins\" in claims[\"groups\"]\n - claims[\"sub\"] == \"user123\" \u0026\u0026 !(\"act\" in claims)\nMutually exclusive with Claim.", + "clientSecret": { + "description": "ClientSecret is the optional OIDC client secret for introspection", "type": "string" }, - "priority": { - "description": "Priority determines selection order (lower number = higher priority).\nWhen multiple mappings match, the one with the lowest priority is selected.\nWhen nil (omitted), the mapping has the lowest possible priority, and\nconfiguration order acts as tie-breaker via stable sort.", - "type": "integer" + "insecureAllowHTTP": { + "description": "InsecureAllowHTTP allows HTTP (non-HTTPS) OIDC issuers for development/testing\nWARNING: This is insecure and should NEVER be used in production", + "type": "boolean" }, - "role_arn": { - "description": "RoleArn is the IAM role ARN to assume when this mapping matches.", + "introspectionURL": { + "description": "IntrospectionURL is the optional introspection endpoint for validating tokens", "type": "string" - } - }, - "type": "object" - }, - "github_com_stacklok_toolhive_pkg_auth_upstreamswap.Config": { - "description": "UpstreamSwapConfig contains configuration for upstream token swap middleware.\nWhen set along with EmbeddedAuthServerConfig, this middleware exchanges ToolHive JWTs\nfor upstream IdP tokens before forwarding requests to the MCP server.", - "properties": { - "custom_header_name": { - "description": "CustomHeaderName is the header name when HeaderStrategy is \"custom\".", + }, + "issuer": { + "description": "Issuer is the OIDC issuer URL (e.g., https://accounts.google.com)", "type": "string" }, - "header_strategy": { - "description": "HeaderStrategy determines how to inject the token: \"replace\" (default) or \"custom\".", + "jwksurl": { + "description": "JWKSURL is the URL to fetch the JWKS from", "type": "string" }, - "provider_name": { - "description": "ProviderName identifies which upstream provider's tokens to retrieve for injection.\nThis is required and must match a configured upstream provider name.", + "resourceURL": { + "description": "ResourceURL is the explicit resource URL for OAuth discovery (RFC 9728)", "type": "string" + }, + "scopes": { + "description": "Scopes is the list of OAuth scopes to advertise in the well-known endpoint (RFC 9728)\nIf empty, defaults to [\"openid\"]", + "items": { + "type": "string" + }, + "type": "array" } }, "type": "object" }, - "github_com_stacklok_toolhive_pkg_authserver.CIMDRunConfig": { + "authserver.CIMDRunConfig": { "description": "CIMD controls client_id metadata document support. When enabled, the\nembedded authorization server accepts HTTPS URLs as client_id values\nand resolves them via the CIMD protocol instead of requiring DCR.", "properties": { "cache_fallback_ttl": { @@ -326,7 +138,7 @@ const docTemplate = `{ }, "type": "object" }, - "github_com_stacklok_toolhive_pkg_authserver.DCRUpstreamConfig": { + "authserver.DCRUpstreamConfig": { "description": "DCRConfig enables RFC 7591 Dynamic Client Registration against the\nupstream authorization server. When set, the client credentials are\nobtained at runtime rather than being pre-provisioned via ClientID /\nClientSecretFile / ClientSecretEnvVar, and ClientID must be left empty.\nMutually exclusive with ClientID.", "properties": { "discovery_url": { @@ -356,7 +168,7 @@ const docTemplate = `{ }, "type": "object" }, - "github_com_stacklok_toolhive_pkg_authserver.DelegateClientRunConfig": { + "authserver.DelegateClientRunConfig": { "properties": { "audiences": { "description": "Audiences are the RFC 8707 resource values this client may request a\ntoken for. Required, and must be a subset of RunConfig.AllowedAudiences:\na declared client must not receive every allowed audience just because\nthis was left empty.", @@ -389,7 +201,7 @@ const docTemplate = `{ }, "type": "object" }, - "github_com_stacklok_toolhive_pkg_authserver.IdentityFromTokenRunConfig": { + "authserver.IdentityFromTokenRunConfig": { "description": "IdentityFromToken extracts user identity (subject, name, email) directly from the\nOAuth2 token-endpoint response body using gjson dot-notation paths. When set, the\nembedded auth server skips the userinfo HTTP call entirely. Mirrors the CRD type\n(cmd/thv-operator/api/v1beta1.IdentityFromTokenConfig) — the authoritative\ntrust-model and uniqueness documentation lives there.", "properties": { "email_path": { @@ -407,7 +219,7 @@ const docTemplate = `{ }, "type": "object" }, - "github_com_stacklok_toolhive_pkg_authserver.OAuth2UpstreamRunConfig": { + "authserver.OAuth2UpstreamRunConfig": { "description": "OAuth2Config contains OAuth 2.0-specific configuration.\nRequired when Type is \"oauth2\", must be nil when Type is \"oidc\".", "properties": { "additional_authorization_params": { @@ -425,6 +237,10 @@ const docTemplate = `{ "description": "AuthorizationEndpoint is the URL for the OAuth authorization endpoint.", "type": "string" }, + "ca_file_path": { + "description": "CAFilePath is the path to a PEM CA bundle added to the system roots.", + "type": "string" + }, "client_id": { "description": "ClientID is the OAuth 2.0 client identifier registered with the upstream IDP.\nMutually exclusive with DCRConfig: when DCRConfig is set, ClientID is obtained\nat runtime via RFC 7591 Dynamic Client Registration and must be left empty.", "type": "string" @@ -438,10 +254,10 @@ const docTemplate = `{ "type": "string" }, "dcr_config": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.DCRUpstreamConfig" + "$ref": "#/components/schemas/authserver.DCRUpstreamConfig" }, "identity_from_token": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.IdentityFromTokenRunConfig" + "$ref": "#/components/schemas/authserver.IdentityFromTokenRunConfig" }, "insecure_allow_http": { "description": "InsecureAllowHTTP permits plain-HTTP authorization and token endpoint URLs\nfor this upstream. Only for in-cluster development environments (e.g. an\nOAuth2 provider served over HTTP in a kind cluster) where TLS is not\navailable. Never set this in production.", @@ -464,15 +280,15 @@ const docTemplate = `{ "type": "string" }, "token_response_mapping": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.TokenResponseMappingRunConfig" + "$ref": "#/components/schemas/authserver.TokenResponseMappingRunConfig" }, "userinfo": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.UserInfoRunConfig" + "$ref": "#/components/schemas/authserver.UserInfoRunConfig" } }, "type": "object" }, - "github_com_stacklok_toolhive_pkg_authserver.OIDCUpstreamRunConfig": { + "authserver.OIDCUpstreamRunConfig": { "description": "OIDCConfig contains OIDC-specific configuration.\nRequired when Type is \"oidc\", must be nil when Type is \"oauth2\".", "properties": { "additional_authorization_params": { @@ -486,6 +302,10 @@ const docTemplate = `{ "description": "AllowPrivateIPs permits the OIDC discovery and token HTTP clients to\nconnect to private IP ranges (RFC-1918, link-local). Use only when the\nupstream is hosted inside the same cluster and has no public endpoint.\nHTTP-scheme restrictions are unchanged — HTTPS is still required for\nnon-localhost hosts. Defaults to false.", "type": "boolean" }, + "ca_file_path": { + "description": "CAFilePath is the path to a PEM CA bundle added to the system roots.", + "type": "string" + }, "client_id": { "description": "ClientID is the OAuth 2.0 client identifier registered with the upstream IDP.", "type": "string" @@ -523,12 +343,12 @@ const docTemplate = `{ "type": "string" }, "userinfo_override": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.UserInfoRunConfig" + "$ref": "#/components/schemas/authserver.UserInfoRunConfig" } }, "type": "object" }, - "github_com_stacklok_toolhive_pkg_authserver.RunConfig": { + "authserver.RunConfig": { "description": "EmbeddedAuthServerConfig contains configuration for the embedded OAuth2/OIDC authorization server.\nWhen set, the proxy runner will start an embedded auth server that delegates to upstream IDPs.\nThis is the serializable RunConfig; secrets are referenced by file paths or env var names.", "properties": { "allow_confidential_client_registration": { @@ -560,12 +380,12 @@ const docTemplate = `{ "uniqueItems": false }, "cimd": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.CIMDRunConfig" + "$ref": "#/components/schemas/authserver.CIMDRunConfig" }, "delegate_clients": { "description": "DelegateClients declares confidential OAuth clients to register at\nauthorization-server startup, including clients intended for RFC 8693\ntoken exchange.\n\nIndependent of AllowConfidentialClientRegistration: declaring a client\nhere does not require or enable self-service confidential DCR, and\nsetting that flag does not declare or enable any client here. They\ngovern different endpoints — this field is static configuration the\noperator controls directly, while the flag is admission policy for the\nunauthenticated /oauth/register endpoint.\n\nSee DelegateClientRunConfig for the per-client field reference.", "items": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.DelegateClientRunConfig" + "$ref": "#/components/schemas/authserver.DelegateClientRunConfig" }, "type": "array", "uniqueItems": false @@ -619,18 +439,18 @@ const docTemplate = `{ "uniqueItems": false }, "signing_key_config": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.SigningKeyRunConfig" + "$ref": "#/components/schemas/authserver.SigningKeyRunConfig" }, "storage": { "$ref": "#/components/schemas/storage.RunConfig" }, "token_lifespans": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.TokenLifespanRunConfig" + "$ref": "#/components/schemas/authserver.TokenLifespanRunConfig" }, "trusted_issuers": { "description": "TrustedIssuers lists external OIDC issuers whose tokens are accepted as\nRFC 8693 subject tokens or RFC 7523 JWT-bearer assertions. Issuers with\njwtBearerGrant enabled may be used for the JWT-bearer grant without an\nRFC 8693 delegation policy. Empty (the default) means only self-issued\nsubject tokens are accepted.\n\nSee tokenexchange.TrustedIssuer for the per-issuer field reference, and\ndocs/arch/17-token-exchange-delegation.md for the trust model, consent\nsignals, and operator-facing constraints (audience/scope bounding,\nsubject namespace qualification, required client binding) that aren't\nvisible from the config shape alone.", "items": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver_server_tokenexchange.TrustedIssuer" + "$ref": "#/components/schemas/tokenexchange.TrustedIssuer" }, "type": "array", "uniqueItems": false @@ -638,7 +458,7 @@ const docTemplate = `{ "upstreams": { "description": "Upstreams configures connections to upstream Identity Providers.\nAt least one upstream is required - the server delegates authentication to these providers.\nMultiple upstreams are supported for sequential authorization chains.", "items": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.UpstreamRunConfig" + "$ref": "#/components/schemas/authserver.UpstreamRunConfig" }, "type": "array", "uniqueItems": false @@ -646,7 +466,7 @@ const docTemplate = `{ }, "type": "object" }, - "github_com_stacklok_toolhive_pkg_authserver.SigningKeyRunConfig": { + "authserver.SigningKeyRunConfig": { "description": "SigningKeyConfig configures the signing key provider for JWT operations.\nIf nil or empty, an ephemeral signing key will be auto-generated (development only).", "properties": { "fallback_key_files": { @@ -668,7 +488,7 @@ const docTemplate = `{ }, "type": "object" }, - "github_com_stacklok_toolhive_pkg_authserver.TokenLifespanRunConfig": { + "authserver.TokenLifespanRunConfig": { "description": "TokenLifespans configures the duration that various tokens are valid.\nIf nil, defaults are applied (access: 1h, refresh: 7d, authCode: 10m).", "properties": { "access_token_lifespan": { @@ -686,7 +506,7 @@ const docTemplate = `{ }, "type": "object" }, - "github_com_stacklok_toolhive_pkg_authserver.TokenResponseMappingRunConfig": { + "authserver.TokenResponseMappingRunConfig": { "description": "TokenResponseMapping configures custom field extraction from non-standard token responses.\nWhen set, the token exchange bypasses golang.org/x/oauth2 and extracts fields using\nthe configured dot-notation paths.", "properties": { "access_token_path": { @@ -708,37 +528,26 @@ const docTemplate = `{ }, "type": "object" }, - "github_com_stacklok_toolhive_pkg_authserver.UpstreamProviderType": { - "description": "Type specifies the provider type: \"oidc\" or \"oauth2\".", - "enum": [ - "oidc", - "oauth2" - ], - "type": "string", - "x-enum-varnames": [ - "UpstreamProviderTypeOIDC", - "UpstreamProviderTypeOAuth2" - ] - }, - "github_com_stacklok_toolhive_pkg_authserver.UpstreamRunConfig": { + "authserver.UpstreamRunConfig": { "properties": { "name": { "description": "Name uniquely identifies this upstream.\nUsed for routing decisions and session binding in multi-upstream scenarios.\nIf empty when only one upstream is configured, defaults to \"default\".", "type": "string" }, "oauth2_config": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.OAuth2UpstreamRunConfig" + "$ref": "#/components/schemas/authserver.OAuth2UpstreamRunConfig" }, "oidc_config": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.OIDCUpstreamRunConfig" + "$ref": "#/components/schemas/authserver.OIDCUpstreamRunConfig" }, "type": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.UpstreamProviderType" + "description": "Type specifies the provider type: \"oidc\" or \"oauth2\".", + "type": "string" } }, "type": "object" }, - "github_com_stacklok_toolhive_pkg_authserver.UserInfoFieldMappingRunConfig": { + "authserver.UserInfoFieldMappingRunConfig": { "description": "FieldMapping contains custom field mapping configuration for non-standard providers.\nIf nil, standard OIDC field names are used (\"sub\", \"name\", \"email\").", "properties": { "email_fields": { @@ -768,7 +577,7 @@ const docTemplate = `{ }, "type": "object" }, - "github_com_stacklok_toolhive_pkg_authserver.UserInfoRunConfig": { + "authserver.UserInfoRunConfig": { "description": "UserInfo contains configuration for fetching user information.\nOptional: when nil, the upstream OAuth2 provider derives a deterministic\nsubject by SHA-256-hashing the access token (with a \"tk-\" prefix) instead\nof calling a userinfo endpoint. OIDC providers always derive Subject from\nthe ID token and are unaffected.", "properties": { "additional_headers": { @@ -783,7 +592,7 @@ const docTemplate = `{ "type": "string" }, "field_mapping": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.UserInfoFieldMappingRunConfig" + "$ref": "#/components/schemas/authserver.UserInfoFieldMappingRunConfig" }, "http_method": { "description": "HTTPMethod is the HTTP method to use for the userinfo request.\nIf not specified, defaults to GET.", @@ -792,97 +601,170 @@ const docTemplate = `{ }, "type": "object" }, - "github_com_stacklok_toolhive_pkg_authserver_server_tokenexchange.JWTBearerGrantPolicy": { - "description": "JWTBearerGrant optionally enables the plain RFC 7523 JWT-bearer grant.\nIt accepts assertions from this issuer without client authentication and\nlimits their maximum age, subjects, and RFC 8707 resources. It is\nindependent from RFC 8693 delegation policy.", + "core.Workload": { "properties": { - "accepted_audiences": { - "description": "AcceptedAudiences is the set of \"this AS\" identity strings an\nassertion's \"aud\" claim must intersect — e.g. to support migrating\nthis server's issuer/token-endpoint URL, or exposing it under more\nthan one valid name. Each value uniquely identifies this\nauthorization server for this grant; it is NOT a resource/API\nidentifier — a bare resource audience is deliberately not accepted\nhere, that would let any RFC 8707 resource-scoped token satisfy the\ngrant instead of only tokens minted for this AS. Defaults to\n[tokenEndpoint] when empty, preserving prior exact-match behavior.", - "items": { + "created_at": { + "description": "CreatedAt is the timestamp when the workload was created.", + "type": "string" + }, + "group": { + "description": "Group is the name of the group this workload belongs to, if any.", + "type": "string" + }, + "labels": { + "additionalProperties": { "type": "string" }, - "type": "array", - "uniqueItems": false + "description": "Labels are the container labels (excluding standard ToolHive labels)", + "type": "object" }, - "max_assertion_age": { + "name": { + "description": "Name is the name of the workload.\nIt is used as a unique identifier.", "type": "string" }, - "subject_bindings": { - "items": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver_server_tokenexchange.JWTBearerSubjectBinding" - }, - "type": "array", - "uniqueItems": false - } - }, - "type": "object" - }, - "github_com_stacklok_toolhive_pkg_authserver_server_tokenexchange.JWTBearerSubjectBinding": { - "properties": { - "allowed_resources": { + "package": { + "description": "Package specifies the Workload Package used to create this Workload.", + "type": "string" + }, + "port": { + "description": "Port is the port on which the workload is exposed.\nThis is embedded in the URL.", + "type": "integer" + }, + "proxy_mode": { + "description": "ProxyMode is the proxy mode that clients should use to connect.\nFor stdio transports, this will be the proxy mode (sse or streamable-http).\nFor direct transports (sse/streamable-http), this will be the same as TransportType.", + "type": "string" + }, + "remote": { + "description": "Remote indicates whether this is a remote workload (true) or a container workload (false).", + "type": "boolean" + }, + "started_at": { + "description": "StartedAt is when the container was last started (changes on restart)", + "type": "string" + }, + "status": { + "description": "Status is the current status of the workload.", + "enum": [ + "running", + "stopped", + "error", + "starting", + "stopping", + "unhealthy", + "removing", + "unknown", + "unauthenticated", + "auth_retrying", + "policy_stopped" + ], + "type": "string" + }, + "status_context": { + "description": "StatusContext provides additional context about the workload's status.\nThe exact meaning is determined by the status and the underlying runtime.", + "type": "string" + }, + "tools": { + "description": "ToolsFilter is the filter on tools applied to the workload.", "items": { "type": "string" }, "type": "array", "uniqueItems": false }, - "subject": { + "transport_type": { + "description": "TransportType is the type of transport used for this workload.", + "enum": [ + "stdio", + "sse", + "streamable-http", + "inspector" + ], + "type": "string" + }, + "url": { + "description": "URL is the URL of the workload exposed by the ToolHive proxy.", "type": "string" } }, "type": "object" }, - "github_com_stacklok_toolhive_pkg_authserver_server_tokenexchange.TrustedIssuer": { + "github_com_stacklok_toolhive_pkg_auth_awssts.Config": { + "description": "AWSStsConfig contains AWS STS token exchange configuration for accessing AWS services", "properties": { - "actor_claim": { - "description": "ActorClaim names the claim identifying the client that requested the\nsubject token from THIS EXTERNAL ISSUER (used by AllowedActors below).\nValues are in the external issuer's namespace, NOT ToolHive client\nIDs. Defaults to \"azp\"; use \"appid\" for Microsoft Entra v1, \"cid\" for\nOkta. The special value \"client_id\" reads ValidatedClaims.ClientID\ninstead of Extra (assignClaim routes it to that field) — it is still\nthe external token's client_id claim, not a ToolHive one.", + "fallback_role_arn": { + "description": "FallbackRoleArn is the IAM role ARN to assume when no role mapping matches.", "type": "string" }, - "actor_matcher": { - "description": "ActorMatcher is an admin-authored CEL expression evaluated against the\ncomplete signature-verified JWT claims map as \"claims\". A true result\nauthorizes delegation alongside AllowedActors; a syntax or type error\nfails configuration validation. An expression that compiles but does\nnot return bool is NOT caught at that point, though — it compiles\nsuccessfully and is only rejected the first time it is evaluated\nagainst a real token, denying that token (and every one after it, since\nthe expression will never return bool). Any other runtime evaluation\nerror denies the token the same way.", + "region": { + "description": "Region is the AWS region for STS and SigV4 signing.", "type": "string" }, - "allow_may_act": { - "description": "AllowMayAct permits this external issuer's may_act claim to authorize\ndelegation. It defaults to false; external issuers must be opted in\nexplicitly because may_act bypasses AllowedActors and ActorMatcher. It\ndoes not affect self-issued subject tokens. When enabled,\nAllowedDelegateClients must name specific ToolHive clients rather than\nuse the wildcard.", - "type": "boolean" - }, - "allow_private_ips": { - "description": "AllowPrivateIPs permits OIDC discovery and JWKS fetches for THIS\nissuer to resolve to a private or loopback address. Use only when the\nissuer is hosted inside the same cluster and has no public endpoint.", - "type": "boolean" + "role_claim": { + "description": "RoleClaim is the JWT claim to use for role mapping (default: \"groups\").", + "type": "string" }, - "allowed_actors": { - "description": "AllowedActors is the allowlist of ActorClaim values authorized to\nexchange a subject token from this issuer when it carries no\n\"may_act\" claim. ActorMatcher can additionally authorize a token by\nmatching its complete verified claims map; either signal is sufficient.\nWhen both are empty, only may_act-bearing tokens are accepted, and only\nif AllowMayAct is also true for this issuer. By itself names no\nToolHive client — see AllowedDelegateClients and\ndocs/arch/17-token-exchange-delegation.md (\"Accepted limitations\" #1).", + "role_mappings": { + "description": "RoleMappings maps JWT claim values to IAM roles with priority.", "items": { - "type": "string" + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_auth_awssts.RoleMapping" }, "type": "array", "uniqueItems": false }, - "allowed_delegate_clients": { - "description": "AllowedDelegateClients restricts which ToolHive client IDs may\nexchange a subject token from this issuer, for BOTH consent paths.\nRequired (validateTrustedIssuer rejects empty/absent); \"*\" permits\nany confidential client holding the grant. See\ndocs/arch/17-token-exchange-delegation.md (\"Accepted limitations\" #1).", - "items": { - "type": "string" - }, - "type": "array", - "uniqueItems": false + "service": { + "description": "Service is the AWS service name for SigV4 signing (default: \"aws-mcp\").", + "type": "string" }, - "expected_audience": { - "description": "ExpectedAudience is the expected \"aud\" claim value that must appear\nin an RFC 8693 subject token's audience list (a resource/API identifier,\nnot a client ID — required for delegation unless JWTBearerGrant is\nconfigured; see looksLikeResourceIdentifier). RFC 7523 assertions use\nthe token endpoint as their audience instead.\nSee docs/arch/17-token-exchange-delegation.md (\"ID/access-token\ndiscrimination\") for why and its limits.", + "session_duration": { + "description": "SessionDuration is the duration in seconds for assumed role credentials (default: 3600).", + "type": "integer" + }, + "session_name_claim": { + "description": "SessionNameClaim is the JWT claim to use for role session name (default: \"sub\").", "type": "string" }, - "insecure_allow_http": { - "description": "InsecureAllowHTTP permits plain-HTTP OIDC discovery and JWKS fetches\nfor THIS issuer only. Development and testing only — never set in\nproduction. Does not relax the private-IP guard; see AllowPrivateIPs.\nDeliberately per-issuer: this server's own InsecureAllowHTTP must not\nsilently permit plaintext discovery for every trusted external issuer\ntoo — a network attacker who can intercept that traffic could\nsubstitute a JWKS and forge subject tokens for that issuer's\nnamespace.", - "type": "boolean" + "subject_provider_name": { + "description": "SubjectProviderName identifies which upstream provider's access token to use\nfor STS AssumeRoleWithWebIdentity. Used by vMCP only. When empty, the bearer\ntoken from the incoming HTTP request is used.", + "type": "string" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_auth_awssts.RoleMapping": { + "properties": { + "claim": { + "description": "Claim is the simple claim value to match (e.g., group name).\nInternally compiles to a CEL expression: \"\u003cclaim_value\u003e\" in claims[\"\u003crole_claim\u003e\"]\nMutually exclusive with Matcher.", + "type": "string" }, - "issuer_url": { - "description": "IssuerURL is the expected \"iss\" claim value (exact match).", + "matcher": { + "description": "Matcher is a CEL expression for complex matching against JWT claims.\nThe expression has access to a \"claims\" variable containing all JWT claims.\nExamples:\n - \"admins\" in claims[\"groups\"]\n - claims[\"sub\"] == \"user123\" \u0026\u0026 !(\"act\" in claims)\nMutually exclusive with Claim.", "type": "string" }, - "jwks_url": { - "description": "JWKSURL is the URL to fetch the issuer's JSON Web Key Set from.\nIf empty, it is resolved via OIDC discovery at {IssuerURL}/.well-known/openid-configuration.", + "priority": { + "description": "Priority determines selection order (lower number = higher priority).\nWhen multiple mappings match, the one with the lowest priority is selected.\nWhen nil (omitted), the mapping has the lowest possible priority, and\nconfiguration order acts as tie-breaker via stable sort.", + "type": "integer" + }, + "role_arn": { + "description": "RoleArn is the IAM role ARN to assume when this mapping matches.", + "type": "string" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_auth_upstreamswap.Config": { + "description": "UpstreamSwapConfig contains configuration for upstream token swap middleware.\nWhen set along with EmbeddedAuthServerConfig, this middleware exchanges ToolHive JWTs\nfor upstream IdP tokens before forwarding requests to the MCP server.", + "properties": { + "custom_header_name": { + "description": "CustomHeaderName is the header name when HeaderStrategy is \"custom\".", "type": "string" }, - "jwt_bearer_grant": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver_server_tokenexchange.JWTBearerGrantPolicy" + "header_strategy": { + "description": "HeaderStrategy determines how to inject the token: \"replace\" (default) or \"custom\".", + "type": "string" + }, + "provider_name": { + "description": "ProviderName identifies which upstream provider's tokens to retrieve for injection.\nThis is required and must match a configured upstream provider name.", + "type": "string" } }, "type": "object" @@ -1408,34 +1290,6 @@ const docTemplate = `{ }, "type": "object" }, - "github_com_stacklok_toolhive_pkg_ratelimit_types.RateLimitBucket": { - "description": "PerUser token bucket configuration for this tool.\n+optional", - "properties": { - "maxTokens": { - "description": "MaxTokens is the maximum number of tokens (bucket capacity).\nThis is also the burst size: the maximum number of requests that can be served\ninstantaneously before the bucket is depleted.\n+kubebuilder:validation:Required\n+kubebuilder:validation:Minimum=1", - "type": "integer" - }, - "refillPeriod": { - "$ref": "#/components/schemas/v1.Duration" - } - }, - "type": "object" - }, - "github_com_stacklok_toolhive_pkg_ratelimit_types.ToolRateLimitConfig": { - "properties": { - "name": { - "description": "Name is the MCP tool name this limit applies to.\n+kubebuilder:validation:Required\n+kubebuilder:validation:MinLength=1", - "type": "string" - }, - "perUser": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_ratelimit_types.RateLimitBucket" - }, - "shared": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_ratelimit_types.RateLimitBucket" - } - }, - "type": "object" - }, "github_com_stacklok_toolhive_pkg_registry.OAuthPublicConfig": { "description": "AuthConfig contains the non-secret OAuth configuration when auth is configured.\nNil when auth_status is \"none\".", "properties": { @@ -1501,7 +1355,7 @@ const docTemplate = `{ "uniqueItems": false }, "audit_config": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_audit.Config" + "$ref": "#/components/schemas/audit.Config" }, "audit_config_path": { "description": "DEPRECATED: Middleware configuration.\nAuditConfigPath is the path to the audit configuration file", @@ -1545,7 +1399,7 @@ const docTemplate = `{ "type": "boolean" }, "embedded_auth_server_config": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.RunConfig" + "$ref": "#/components/schemas/authserver.RunConfig" }, "endpoint_prefix": { "description": "EndpointPrefix is an explicit prefix to prepend to SSE endpoint URLs.\nThis is used to handle path-based ingress routing scenarios.", @@ -1644,7 +1498,7 @@ const docTemplate = `{ "uniqueItems": false }, "rate_limit_config": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.RateLimitConfig" + "$ref": "#/components/schemas/v1beta1.RateLimitConfig" }, "rate_limit_namespace": { "description": "RateLimitNamespace is the Kubernetes namespace for Redis key derivation.", @@ -5341,6 +5195,101 @@ const docTemplate = `{ }, "type": "object" }, + "tokenexchange.JWTBearerGrantPolicy": { + "description": "JWTBearerGrant optionally enables the plain RFC 7523 JWT-bearer grant.\nIt accepts assertions from this issuer without client authentication and\nlimits their maximum age, subjects, and RFC 8707 resources. It is\nindependent from RFC 8693 delegation policy.", + "properties": { + "accepted_audiences": { + "description": "AcceptedAudiences is the set of \"this AS\" identity strings an\nassertion's \"aud\" claim must intersect — e.g. to support migrating\nthis server's issuer/token-endpoint URL, or exposing it under more\nthan one valid name. Each value uniquely identifies this\nauthorization server for this grant; it is NOT a resource/API\nidentifier — a bare resource audience is deliberately not accepted\nhere, that would let any RFC 8707 resource-scoped token satisfy the\ngrant instead of only tokens minted for this AS. Defaults to\n[tokenEndpoint] when empty, preserving prior exact-match behavior.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "max_assertion_age": { + "type": "string" + }, + "subject_bindings": { + "items": { + "$ref": "#/components/schemas/tokenexchange.JWTBearerSubjectBinding" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, + "tokenexchange.JWTBearerSubjectBinding": { + "properties": { + "allowed_resources": { + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "subject": { + "type": "string" + } + }, + "type": "object" + }, + "tokenexchange.TrustedIssuer": { + "properties": { + "actor_claim": { + "description": "ActorClaim names the claim identifying the client that requested the\nsubject token from THIS EXTERNAL ISSUER (used by AllowedActors below).\nValues are in the external issuer's namespace, NOT ToolHive client\nIDs. Defaults to \"azp\"; use \"appid\" for Microsoft Entra v1, \"cid\" for\nOkta. The special value \"client_id\" reads ValidatedClaims.ClientID\ninstead of Extra (assignClaim routes it to that field) — it is still\nthe external token's client_id claim, not a ToolHive one.", + "type": "string" + }, + "actor_matcher": { + "description": "ActorMatcher is an admin-authored CEL expression evaluated against the\ncomplete signature-verified JWT claims map as \"claims\". A true result\nauthorizes delegation alongside AllowedActors; a syntax or type error\nfails configuration validation. An expression that compiles but does\nnot return bool is NOT caught at that point, though — it compiles\nsuccessfully and is only rejected the first time it is evaluated\nagainst a real token, denying that token (and every one after it, since\nthe expression will never return bool). Any other runtime evaluation\nerror denies the token the same way.", + "type": "string" + }, + "allow_may_act": { + "description": "AllowMayAct permits this external issuer's may_act claim to authorize\ndelegation. It defaults to false; external issuers must be opted in\nexplicitly because may_act bypasses AllowedActors and ActorMatcher. It\ndoes not affect self-issued subject tokens. When enabled,\nAllowedDelegateClients must name specific ToolHive clients rather than\nuse the wildcard.", + "type": "boolean" + }, + "allow_private_ips": { + "description": "AllowPrivateIPs permits OIDC discovery and JWKS fetches for THIS\nissuer to resolve to a private or loopback address. Use only when the\nissuer is hosted inside the same cluster and has no public endpoint.", + "type": "boolean" + }, + "allowed_actors": { + "description": "AllowedActors is the allowlist of ActorClaim values authorized to\nexchange a subject token from this issuer when it carries no\n\"may_act\" claim. ActorMatcher can additionally authorize a token by\nmatching its complete verified claims map; either signal is sufficient.\nWhen both are empty, only may_act-bearing tokens are accepted, and only\nif AllowMayAct is also true for this issuer. By itself names no\nToolHive client — see AllowedDelegateClients and\ndocs/arch/17-token-exchange-delegation.md (\"Accepted limitations\" #1).", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "allowed_delegate_clients": { + "description": "AllowedDelegateClients restricts which ToolHive client IDs may\nexchange a subject token from this issuer, for BOTH consent paths.\nRequired (validateTrustedIssuer rejects empty/absent); \"*\" permits\nany confidential client holding the grant. See\ndocs/arch/17-token-exchange-delegation.md (\"Accepted limitations\" #1).", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "expected_audience": { + "description": "ExpectedAudience is the expected \"aud\" claim value that must appear\nin an RFC 8693 subject token's audience list (a resource/API identifier,\nnot a client ID — required for delegation unless JWTBearerGrant is\nconfigured; see looksLikeResourceIdentifier). RFC 7523 assertions use\nthe token endpoint as their audience instead.\nSee docs/arch/17-token-exchange-delegation.md (\"ID/access-token\ndiscrimination\") for why and its limits.", + "type": "string" + }, + "insecure_allow_http": { + "description": "InsecureAllowHTTP permits plain-HTTP OIDC discovery and JWKS fetches\nfor THIS issuer only. Development and testing only — never set in\nproduction. Does not relax the private-IP guard; see AllowPrivateIPs.\nDeliberately per-issuer: this server's own InsecureAllowHTTP must not\nsilently permit plaintext discovery for every trusted external issuer\ntoo — a network attacker who can intercept that traffic could\nsubstitute a JWKS and forge subject tokens for that issuer's\nnamespace.", + "type": "boolean" + }, + "issuer_url": { + "description": "IssuerURL is the expected \"iss\" claim value (exact match).", + "type": "string" + }, + "jwks_url": { + "description": "JWKSURL is the URL to fetch the issuer's JSON Web Key Set from.\nIf empty, it is resolved via OIDC discovery at {IssuerURL}/.well-known/openid-configuration.", + "type": "string" + }, + "jwt_bearer_grant": { + "$ref": "#/components/schemas/tokenexchange.JWTBearerGrantPolicy" + } + }, + "type": "object" + }, "types.MiddlewareConfig": { "properties": { "parameters": { @@ -5354,6 +5303,34 @@ const docTemplate = `{ }, "type": "object" }, + "types.RateLimitBucket": { + "description": "PerUser token bucket configuration for this tool.\n+optional", + "properties": { + "maxTokens": { + "description": "MaxTokens is the maximum number of tokens (bucket capacity).\nThis is also the burst size: the maximum number of requests that can be served\ninstantaneously before the bucket is depleted.\n+kubebuilder:validation:Required\n+kubebuilder:validation:Minimum=1", + "type": "integer" + }, + "refillPeriod": { + "$ref": "#/components/schemas/v1.Duration" + } + }, + "type": "object" + }, + "types.ToolRateLimitConfig": { + "properties": { + "name": { + "description": "Name is the MCP tool name this limit applies to.\n+kubebuilder:validation:Required\n+kubebuilder:validation:MinLength=1", + "type": "string" + }, + "perUser": { + "$ref": "#/components/schemas/types.RateLimitBucket" + }, + "shared": { + "$ref": "#/components/schemas/types.RateLimitBucket" + } + }, + "type": "object" + }, "v0.ServerJSON": { "properties": { "$schema": { @@ -5434,6 +5411,26 @@ const docTemplate = `{ "v1.Duration": { "description": "RefillPeriod is the duration to fully refill the bucket from zero to maxTokens.\nThe effective refill rate is maxTokens / refillPeriod tokens per second.\nFormat: Go duration string (e.g., \"1m0s\", \"30s\", \"1h0m0s\").\n+kubebuilder:validation:Required", "type": "object" + }, + "v1beta1.RateLimitConfig": { + "description": "RateLimitConfig contains the CRD rate limiting configuration.\nWhen set, rate limiting middleware is added to the proxy middleware chain.", + "properties": { + "perUser": { + "$ref": "#/components/schemas/types.RateLimitBucket" + }, + "shared": { + "$ref": "#/components/schemas/types.RateLimitBucket" + }, + "tools": { + "description": "Tools defines per-tool rate limit overrides.\nEach entry applies additional rate limits to calls targeting a specific tool name.\nA request must pass both the server-level limit and the per-tool limit.\n+listType=map\n+listMapKey=name\n+optional", + "items": { + "$ref": "#/components/schemas/types.ToolRateLimitConfig" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" } } }, diff --git a/docs/server/swagger.json b/docs/server/swagger.json index c360864541..82b0600b30 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -1,171 +1,7 @@ { "components": { "schemas": { - "auth.TokenValidatorConfig": { - "description": "DEPRECATED: Middleware configuration.\nOIDCConfig contains OIDC configuration", - "properties": { - "allowPrivateIP": { - "description": "AllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses", - "type": "boolean" - }, - "audience": { - "description": "Audience is the expected audience for the token", - "type": "string" - }, - "authTokenFile": { - "description": "AuthTokenFile is the path to file containing bearer token for authentication", - "type": "string" - }, - "cacertPath": { - "description": "CACertPath is the path to the CA certificate bundle for HTTPS requests", - "type": "string" - }, - "clientID": { - "description": "ClientID is the OIDC client ID", - "type": "string" - }, - "clientSecret": { - "description": "ClientSecret is the optional OIDC client secret for introspection", - "type": "string" - }, - "insecureAllowHTTP": { - "description": "InsecureAllowHTTP allows HTTP (non-HTTPS) OIDC issuers for development/testing\nWARNING: This is insecure and should NEVER be used in production", - "type": "boolean" - }, - "introspectionURL": { - "description": "IntrospectionURL is the optional introspection endpoint for validating tokens", - "type": "string" - }, - "issuer": { - "description": "Issuer is the OIDC issuer URL (e.g., https://accounts.google.com)", - "type": "string" - }, - "jwksurl": { - "description": "JWKSURL is the URL to fetch the JWKS from", - "type": "string" - }, - "resourceURL": { - "description": "ResourceURL is the explicit resource URL for OAuth discovery (RFC 9728)", - "type": "string" - }, - "scopes": { - "description": "Scopes is the list of OAuth scopes to advertise in the well-known endpoint (RFC 9728)\nIf empty, defaults to [\"openid\"]", - "items": { - "type": "string" - }, - "type": "array" - } - }, - "type": "object" - }, - "core.Workload": { - "properties": { - "created_at": { - "description": "CreatedAt is the timestamp when the workload was created.", - "type": "string" - }, - "group": { - "description": "Group is the name of the group this workload belongs to, if any.", - "type": "string" - }, - "labels": { - "additionalProperties": { - "type": "string" - }, - "description": "Labels are the container labels (excluding standard ToolHive labels)", - "type": "object" - }, - "name": { - "description": "Name is the name of the workload.\nIt is used as a unique identifier.", - "type": "string" - }, - "package": { - "description": "Package specifies the Workload Package used to create this Workload.", - "type": "string" - }, - "port": { - "description": "Port is the port on which the workload is exposed.\nThis is embedded in the URL.", - "type": "integer" - }, - "proxy_mode": { - "description": "ProxyMode is the proxy mode that clients should use to connect.\nFor stdio transports, this will be the proxy mode (sse or streamable-http).\nFor direct transports (sse/streamable-http), this will be the same as TransportType.", - "type": "string" - }, - "remote": { - "description": "Remote indicates whether this is a remote workload (true) or a container workload (false).", - "type": "boolean" - }, - "started_at": { - "description": "StartedAt is when the container was last started (changes on restart)", - "type": "string" - }, - "status": { - "description": "Status is the current status of the workload.", - "enum": [ - "running", - "stopped", - "error", - "starting", - "stopping", - "unhealthy", - "removing", - "unknown", - "unauthenticated", - "auth_retrying", - "policy_stopped" - ], - "type": "string" - }, - "status_context": { - "description": "StatusContext provides additional context about the workload's status.\nThe exact meaning is determined by the status and the underlying runtime.", - "type": "string" - }, - "tools": { - "description": "ToolsFilter is the filter on tools applied to the workload.", - "items": { - "type": "string" - }, - "type": "array", - "uniqueItems": false - }, - "transport_type": { - "description": "TransportType is the type of transport used for this workload.", - "enum": [ - "stdio", - "sse", - "streamable-http", - "inspector" - ], - "type": "string" - }, - "url": { - "description": "URL is the URL of the workload exposed by the ToolHive proxy.", - "type": "string" - } - }, - "type": "object" - }, - "github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.RateLimitConfig": { - "description": "RateLimitConfig contains the CRD rate limiting configuration.\nWhen set, rate limiting middleware is added to the proxy middleware chain.", - "properties": { - "perUser": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_ratelimit_types.RateLimitBucket" - }, - "shared": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_ratelimit_types.RateLimitBucket" - }, - "tools": { - "description": "Tools defines per-tool rate limit overrides.\nEach entry applies additional rate limits to calls targeting a specific tool name.\nA request must pass both the server-level limit and the per-tool limit.\n+listType=map\n+listMapKey=name\n+optional", - "items": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_ratelimit_types.ToolRateLimitConfig" - }, - "type": "array", - "uniqueItems": false - } - }, - "type": "object" - }, - "github_com_stacklok_toolhive_pkg_audit.Config": { + "audit.Config": { "description": "DEPRECATED: Middleware configuration.\nAuditConfig contains the audit logging configuration", "properties": { "component": { @@ -219,88 +55,64 @@ }, "type": "object" }, - "github_com_stacklok_toolhive_pkg_auth_awssts.Config": { - "description": "AWSStsConfig contains AWS STS token exchange configuration for accessing AWS services", + "auth.TokenValidatorConfig": { + "description": "DEPRECATED: Middleware configuration.\nOIDCConfig contains OIDC configuration", "properties": { - "fallback_role_arn": { - "description": "FallbackRoleArn is the IAM role ARN to assume when no role mapping matches.", - "type": "string" - }, - "region": { - "description": "Region is the AWS region for STS and SigV4 signing.", - "type": "string" + "allowPrivateIP": { + "description": "AllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses", + "type": "boolean" }, - "role_claim": { - "description": "RoleClaim is the JWT claim to use for role mapping (default: \"groups\").", + "audience": { + "description": "Audience is the expected audience for the token", "type": "string" }, - "role_mappings": { - "description": "RoleMappings maps JWT claim values to IAM roles with priority.", - "items": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_auth_awssts.RoleMapping" - }, - "type": "array", - "uniqueItems": false - }, - "service": { - "description": "Service is the AWS service name for SigV4 signing (default: \"aws-mcp\").", + "authTokenFile": { + "description": "AuthTokenFile is the path to file containing bearer token for authentication", "type": "string" }, - "session_duration": { - "description": "SessionDuration is the duration in seconds for assumed role credentials (default: 3600).", - "type": "integer" - }, - "session_name_claim": { - "description": "SessionNameClaim is the JWT claim to use for role session name (default: \"sub\").", + "cacertPath": { + "description": "CACertPath is the path to the CA certificate bundle for HTTPS requests", "type": "string" }, - "subject_provider_name": { - "description": "SubjectProviderName identifies which upstream provider's access token to use\nfor STS AssumeRoleWithWebIdentity. Used by vMCP only. When empty, the bearer\ntoken from the incoming HTTP request is used.", - "type": "string" - } - }, - "type": "object" - }, - "github_com_stacklok_toolhive_pkg_auth_awssts.RoleMapping": { - "properties": { - "claim": { - "description": "Claim is the simple claim value to match (e.g., group name).\nInternally compiles to a CEL expression: \"\u003cclaim_value\u003e\" in claims[\"\u003crole_claim\u003e\"]\nMutually exclusive with Matcher.", + "clientID": { + "description": "ClientID is the OIDC client ID", "type": "string" }, - "matcher": { - "description": "Matcher is a CEL expression for complex matching against JWT claims.\nThe expression has access to a \"claims\" variable containing all JWT claims.\nExamples:\n - \"admins\" in claims[\"groups\"]\n - claims[\"sub\"] == \"user123\" \u0026\u0026 !(\"act\" in claims)\nMutually exclusive with Claim.", + "clientSecret": { + "description": "ClientSecret is the optional OIDC client secret for introspection", "type": "string" }, - "priority": { - "description": "Priority determines selection order (lower number = higher priority).\nWhen multiple mappings match, the one with the lowest priority is selected.\nWhen nil (omitted), the mapping has the lowest possible priority, and\nconfiguration order acts as tie-breaker via stable sort.", - "type": "integer" + "insecureAllowHTTP": { + "description": "InsecureAllowHTTP allows HTTP (non-HTTPS) OIDC issuers for development/testing\nWARNING: This is insecure and should NEVER be used in production", + "type": "boolean" }, - "role_arn": { - "description": "RoleArn is the IAM role ARN to assume when this mapping matches.", + "introspectionURL": { + "description": "IntrospectionURL is the optional introspection endpoint for validating tokens", "type": "string" - } - }, - "type": "object" - }, - "github_com_stacklok_toolhive_pkg_auth_upstreamswap.Config": { - "description": "UpstreamSwapConfig contains configuration for upstream token swap middleware.\nWhen set along with EmbeddedAuthServerConfig, this middleware exchanges ToolHive JWTs\nfor upstream IdP tokens before forwarding requests to the MCP server.", - "properties": { - "custom_header_name": { - "description": "CustomHeaderName is the header name when HeaderStrategy is \"custom\".", + }, + "issuer": { + "description": "Issuer is the OIDC issuer URL (e.g., https://accounts.google.com)", "type": "string" }, - "header_strategy": { - "description": "HeaderStrategy determines how to inject the token: \"replace\" (default) or \"custom\".", + "jwksurl": { + "description": "JWKSURL is the URL to fetch the JWKS from", "type": "string" }, - "provider_name": { - "description": "ProviderName identifies which upstream provider's tokens to retrieve for injection.\nThis is required and must match a configured upstream provider name.", + "resourceURL": { + "description": "ResourceURL is the explicit resource URL for OAuth discovery (RFC 9728)", "type": "string" + }, + "scopes": { + "description": "Scopes is the list of OAuth scopes to advertise in the well-known endpoint (RFC 9728)\nIf empty, defaults to [\"openid\"]", + "items": { + "type": "string" + }, + "type": "array" } }, "type": "object" }, - "github_com_stacklok_toolhive_pkg_authserver.CIMDRunConfig": { + "authserver.CIMDRunConfig": { "description": "CIMD controls client_id metadata document support. When enabled, the\nembedded authorization server accepts HTTPS URLs as client_id values\nand resolves them via the CIMD protocol instead of requiring DCR.", "properties": { "cache_fallback_ttl": { @@ -319,7 +131,7 @@ }, "type": "object" }, - "github_com_stacklok_toolhive_pkg_authserver.DCRUpstreamConfig": { + "authserver.DCRUpstreamConfig": { "description": "DCRConfig enables RFC 7591 Dynamic Client Registration against the\nupstream authorization server. When set, the client credentials are\nobtained at runtime rather than being pre-provisioned via ClientID /\nClientSecretFile / ClientSecretEnvVar, and ClientID must be left empty.\nMutually exclusive with ClientID.", "properties": { "discovery_url": { @@ -349,7 +161,7 @@ }, "type": "object" }, - "github_com_stacklok_toolhive_pkg_authserver.DelegateClientRunConfig": { + "authserver.DelegateClientRunConfig": { "properties": { "audiences": { "description": "Audiences are the RFC 8707 resource values this client may request a\ntoken for. Required, and must be a subset of RunConfig.AllowedAudiences:\na declared client must not receive every allowed audience just because\nthis was left empty.", @@ -382,7 +194,7 @@ }, "type": "object" }, - "github_com_stacklok_toolhive_pkg_authserver.IdentityFromTokenRunConfig": { + "authserver.IdentityFromTokenRunConfig": { "description": "IdentityFromToken extracts user identity (subject, name, email) directly from the\nOAuth2 token-endpoint response body using gjson dot-notation paths. When set, the\nembedded auth server skips the userinfo HTTP call entirely. Mirrors the CRD type\n(cmd/thv-operator/api/v1beta1.IdentityFromTokenConfig) — the authoritative\ntrust-model and uniqueness documentation lives there.", "properties": { "email_path": { @@ -400,7 +212,7 @@ }, "type": "object" }, - "github_com_stacklok_toolhive_pkg_authserver.OAuth2UpstreamRunConfig": { + "authserver.OAuth2UpstreamRunConfig": { "description": "OAuth2Config contains OAuth 2.0-specific configuration.\nRequired when Type is \"oauth2\", must be nil when Type is \"oidc\".", "properties": { "additional_authorization_params": { @@ -418,6 +230,10 @@ "description": "AuthorizationEndpoint is the URL for the OAuth authorization endpoint.", "type": "string" }, + "ca_file_path": { + "description": "CAFilePath is the path to a PEM CA bundle added to the system roots.", + "type": "string" + }, "client_id": { "description": "ClientID is the OAuth 2.0 client identifier registered with the upstream IDP.\nMutually exclusive with DCRConfig: when DCRConfig is set, ClientID is obtained\nat runtime via RFC 7591 Dynamic Client Registration and must be left empty.", "type": "string" @@ -431,10 +247,10 @@ "type": "string" }, "dcr_config": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.DCRUpstreamConfig" + "$ref": "#/components/schemas/authserver.DCRUpstreamConfig" }, "identity_from_token": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.IdentityFromTokenRunConfig" + "$ref": "#/components/schemas/authserver.IdentityFromTokenRunConfig" }, "insecure_allow_http": { "description": "InsecureAllowHTTP permits plain-HTTP authorization and token endpoint URLs\nfor this upstream. Only for in-cluster development environments (e.g. an\nOAuth2 provider served over HTTP in a kind cluster) where TLS is not\navailable. Never set this in production.", @@ -457,15 +273,15 @@ "type": "string" }, "token_response_mapping": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.TokenResponseMappingRunConfig" + "$ref": "#/components/schemas/authserver.TokenResponseMappingRunConfig" }, "userinfo": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.UserInfoRunConfig" + "$ref": "#/components/schemas/authserver.UserInfoRunConfig" } }, "type": "object" }, - "github_com_stacklok_toolhive_pkg_authserver.OIDCUpstreamRunConfig": { + "authserver.OIDCUpstreamRunConfig": { "description": "OIDCConfig contains OIDC-specific configuration.\nRequired when Type is \"oidc\", must be nil when Type is \"oauth2\".", "properties": { "additional_authorization_params": { @@ -479,6 +295,10 @@ "description": "AllowPrivateIPs permits the OIDC discovery and token HTTP clients to\nconnect to private IP ranges (RFC-1918, link-local). Use only when the\nupstream is hosted inside the same cluster and has no public endpoint.\nHTTP-scheme restrictions are unchanged — HTTPS is still required for\nnon-localhost hosts. Defaults to false.", "type": "boolean" }, + "ca_file_path": { + "description": "CAFilePath is the path to a PEM CA bundle added to the system roots.", + "type": "string" + }, "client_id": { "description": "ClientID is the OAuth 2.0 client identifier registered with the upstream IDP.", "type": "string" @@ -516,12 +336,12 @@ "type": "string" }, "userinfo_override": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.UserInfoRunConfig" + "$ref": "#/components/schemas/authserver.UserInfoRunConfig" } }, "type": "object" }, - "github_com_stacklok_toolhive_pkg_authserver.RunConfig": { + "authserver.RunConfig": { "description": "EmbeddedAuthServerConfig contains configuration for the embedded OAuth2/OIDC authorization server.\nWhen set, the proxy runner will start an embedded auth server that delegates to upstream IDPs.\nThis is the serializable RunConfig; secrets are referenced by file paths or env var names.", "properties": { "allow_confidential_client_registration": { @@ -553,12 +373,12 @@ "uniqueItems": false }, "cimd": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.CIMDRunConfig" + "$ref": "#/components/schemas/authserver.CIMDRunConfig" }, "delegate_clients": { "description": "DelegateClients declares confidential OAuth clients to register at\nauthorization-server startup, including clients intended for RFC 8693\ntoken exchange.\n\nIndependent of AllowConfidentialClientRegistration: declaring a client\nhere does not require or enable self-service confidential DCR, and\nsetting that flag does not declare or enable any client here. They\ngovern different endpoints — this field is static configuration the\noperator controls directly, while the flag is admission policy for the\nunauthenticated /oauth/register endpoint.\n\nSee DelegateClientRunConfig for the per-client field reference.", "items": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.DelegateClientRunConfig" + "$ref": "#/components/schemas/authserver.DelegateClientRunConfig" }, "type": "array", "uniqueItems": false @@ -612,18 +432,18 @@ "uniqueItems": false }, "signing_key_config": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.SigningKeyRunConfig" + "$ref": "#/components/schemas/authserver.SigningKeyRunConfig" }, "storage": { "$ref": "#/components/schemas/storage.RunConfig" }, "token_lifespans": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.TokenLifespanRunConfig" + "$ref": "#/components/schemas/authserver.TokenLifespanRunConfig" }, "trusted_issuers": { "description": "TrustedIssuers lists external OIDC issuers whose tokens are accepted as\nRFC 8693 subject tokens or RFC 7523 JWT-bearer assertions. Issuers with\njwtBearerGrant enabled may be used for the JWT-bearer grant without an\nRFC 8693 delegation policy. Empty (the default) means only self-issued\nsubject tokens are accepted.\n\nSee tokenexchange.TrustedIssuer for the per-issuer field reference, and\ndocs/arch/17-token-exchange-delegation.md for the trust model, consent\nsignals, and operator-facing constraints (audience/scope bounding,\nsubject namespace qualification, required client binding) that aren't\nvisible from the config shape alone.", "items": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver_server_tokenexchange.TrustedIssuer" + "$ref": "#/components/schemas/tokenexchange.TrustedIssuer" }, "type": "array", "uniqueItems": false @@ -631,7 +451,7 @@ "upstreams": { "description": "Upstreams configures connections to upstream Identity Providers.\nAt least one upstream is required - the server delegates authentication to these providers.\nMultiple upstreams are supported for sequential authorization chains.", "items": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.UpstreamRunConfig" + "$ref": "#/components/schemas/authserver.UpstreamRunConfig" }, "type": "array", "uniqueItems": false @@ -639,7 +459,7 @@ }, "type": "object" }, - "github_com_stacklok_toolhive_pkg_authserver.SigningKeyRunConfig": { + "authserver.SigningKeyRunConfig": { "description": "SigningKeyConfig configures the signing key provider for JWT operations.\nIf nil or empty, an ephemeral signing key will be auto-generated (development only).", "properties": { "fallback_key_files": { @@ -661,7 +481,7 @@ }, "type": "object" }, - "github_com_stacklok_toolhive_pkg_authserver.TokenLifespanRunConfig": { + "authserver.TokenLifespanRunConfig": { "description": "TokenLifespans configures the duration that various tokens are valid.\nIf nil, defaults are applied (access: 1h, refresh: 7d, authCode: 10m).", "properties": { "access_token_lifespan": { @@ -679,7 +499,7 @@ }, "type": "object" }, - "github_com_stacklok_toolhive_pkg_authserver.TokenResponseMappingRunConfig": { + "authserver.TokenResponseMappingRunConfig": { "description": "TokenResponseMapping configures custom field extraction from non-standard token responses.\nWhen set, the token exchange bypasses golang.org/x/oauth2 and extracts fields using\nthe configured dot-notation paths.", "properties": { "access_token_path": { @@ -701,37 +521,26 @@ }, "type": "object" }, - "github_com_stacklok_toolhive_pkg_authserver.UpstreamProviderType": { - "description": "Type specifies the provider type: \"oidc\" or \"oauth2\".", - "enum": [ - "oidc", - "oauth2" - ], - "type": "string", - "x-enum-varnames": [ - "UpstreamProviderTypeOIDC", - "UpstreamProviderTypeOAuth2" - ] - }, - "github_com_stacklok_toolhive_pkg_authserver.UpstreamRunConfig": { + "authserver.UpstreamRunConfig": { "properties": { "name": { "description": "Name uniquely identifies this upstream.\nUsed for routing decisions and session binding in multi-upstream scenarios.\nIf empty when only one upstream is configured, defaults to \"default\".", "type": "string" }, "oauth2_config": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.OAuth2UpstreamRunConfig" + "$ref": "#/components/schemas/authserver.OAuth2UpstreamRunConfig" }, "oidc_config": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.OIDCUpstreamRunConfig" + "$ref": "#/components/schemas/authserver.OIDCUpstreamRunConfig" }, "type": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.UpstreamProviderType" + "description": "Type specifies the provider type: \"oidc\" or \"oauth2\".", + "type": "string" } }, "type": "object" }, - "github_com_stacklok_toolhive_pkg_authserver.UserInfoFieldMappingRunConfig": { + "authserver.UserInfoFieldMappingRunConfig": { "description": "FieldMapping contains custom field mapping configuration for non-standard providers.\nIf nil, standard OIDC field names are used (\"sub\", \"name\", \"email\").", "properties": { "email_fields": { @@ -761,7 +570,7 @@ }, "type": "object" }, - "github_com_stacklok_toolhive_pkg_authserver.UserInfoRunConfig": { + "authserver.UserInfoRunConfig": { "description": "UserInfo contains configuration for fetching user information.\nOptional: when nil, the upstream OAuth2 provider derives a deterministic\nsubject by SHA-256-hashing the access token (with a \"tk-\" prefix) instead\nof calling a userinfo endpoint. OIDC providers always derive Subject from\nthe ID token and are unaffected.", "properties": { "additional_headers": { @@ -776,7 +585,7 @@ "type": "string" }, "field_mapping": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.UserInfoFieldMappingRunConfig" + "$ref": "#/components/schemas/authserver.UserInfoFieldMappingRunConfig" }, "http_method": { "description": "HTTPMethod is the HTTP method to use for the userinfo request.\nIf not specified, defaults to GET.", @@ -785,97 +594,170 @@ }, "type": "object" }, - "github_com_stacklok_toolhive_pkg_authserver_server_tokenexchange.JWTBearerGrantPolicy": { - "description": "JWTBearerGrant optionally enables the plain RFC 7523 JWT-bearer grant.\nIt accepts assertions from this issuer without client authentication and\nlimits their maximum age, subjects, and RFC 8707 resources. It is\nindependent from RFC 8693 delegation policy.", + "core.Workload": { "properties": { - "accepted_audiences": { - "description": "AcceptedAudiences is the set of \"this AS\" identity strings an\nassertion's \"aud\" claim must intersect — e.g. to support migrating\nthis server's issuer/token-endpoint URL, or exposing it under more\nthan one valid name. Each value uniquely identifies this\nauthorization server for this grant; it is NOT a resource/API\nidentifier — a bare resource audience is deliberately not accepted\nhere, that would let any RFC 8707 resource-scoped token satisfy the\ngrant instead of only tokens minted for this AS. Defaults to\n[tokenEndpoint] when empty, preserving prior exact-match behavior.", - "items": { + "created_at": { + "description": "CreatedAt is the timestamp when the workload was created.", + "type": "string" + }, + "group": { + "description": "Group is the name of the group this workload belongs to, if any.", + "type": "string" + }, + "labels": { + "additionalProperties": { "type": "string" }, - "type": "array", - "uniqueItems": false + "description": "Labels are the container labels (excluding standard ToolHive labels)", + "type": "object" }, - "max_assertion_age": { + "name": { + "description": "Name is the name of the workload.\nIt is used as a unique identifier.", "type": "string" }, - "subject_bindings": { - "items": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver_server_tokenexchange.JWTBearerSubjectBinding" - }, - "type": "array", - "uniqueItems": false - } - }, - "type": "object" - }, - "github_com_stacklok_toolhive_pkg_authserver_server_tokenexchange.JWTBearerSubjectBinding": { - "properties": { - "allowed_resources": { + "package": { + "description": "Package specifies the Workload Package used to create this Workload.", + "type": "string" + }, + "port": { + "description": "Port is the port on which the workload is exposed.\nThis is embedded in the URL.", + "type": "integer" + }, + "proxy_mode": { + "description": "ProxyMode is the proxy mode that clients should use to connect.\nFor stdio transports, this will be the proxy mode (sse or streamable-http).\nFor direct transports (sse/streamable-http), this will be the same as TransportType.", + "type": "string" + }, + "remote": { + "description": "Remote indicates whether this is a remote workload (true) or a container workload (false).", + "type": "boolean" + }, + "started_at": { + "description": "StartedAt is when the container was last started (changes on restart)", + "type": "string" + }, + "status": { + "description": "Status is the current status of the workload.", + "enum": [ + "running", + "stopped", + "error", + "starting", + "stopping", + "unhealthy", + "removing", + "unknown", + "unauthenticated", + "auth_retrying", + "policy_stopped" + ], + "type": "string" + }, + "status_context": { + "description": "StatusContext provides additional context about the workload's status.\nThe exact meaning is determined by the status and the underlying runtime.", + "type": "string" + }, + "tools": { + "description": "ToolsFilter is the filter on tools applied to the workload.", "items": { "type": "string" }, "type": "array", "uniqueItems": false }, - "subject": { + "transport_type": { + "description": "TransportType is the type of transport used for this workload.", + "enum": [ + "stdio", + "sse", + "streamable-http", + "inspector" + ], + "type": "string" + }, + "url": { + "description": "URL is the URL of the workload exposed by the ToolHive proxy.", "type": "string" } }, "type": "object" }, - "github_com_stacklok_toolhive_pkg_authserver_server_tokenexchange.TrustedIssuer": { + "github_com_stacklok_toolhive_pkg_auth_awssts.Config": { + "description": "AWSStsConfig contains AWS STS token exchange configuration for accessing AWS services", "properties": { - "actor_claim": { - "description": "ActorClaim names the claim identifying the client that requested the\nsubject token from THIS EXTERNAL ISSUER (used by AllowedActors below).\nValues are in the external issuer's namespace, NOT ToolHive client\nIDs. Defaults to \"azp\"; use \"appid\" for Microsoft Entra v1, \"cid\" for\nOkta. The special value \"client_id\" reads ValidatedClaims.ClientID\ninstead of Extra (assignClaim routes it to that field) — it is still\nthe external token's client_id claim, not a ToolHive one.", + "fallback_role_arn": { + "description": "FallbackRoleArn is the IAM role ARN to assume when no role mapping matches.", "type": "string" }, - "actor_matcher": { - "description": "ActorMatcher is an admin-authored CEL expression evaluated against the\ncomplete signature-verified JWT claims map as \"claims\". A true result\nauthorizes delegation alongside AllowedActors; a syntax or type error\nfails configuration validation. An expression that compiles but does\nnot return bool is NOT caught at that point, though — it compiles\nsuccessfully and is only rejected the first time it is evaluated\nagainst a real token, denying that token (and every one after it, since\nthe expression will never return bool). Any other runtime evaluation\nerror denies the token the same way.", + "region": { + "description": "Region is the AWS region for STS and SigV4 signing.", "type": "string" }, - "allow_may_act": { - "description": "AllowMayAct permits this external issuer's may_act claim to authorize\ndelegation. It defaults to false; external issuers must be opted in\nexplicitly because may_act bypasses AllowedActors and ActorMatcher. It\ndoes not affect self-issued subject tokens. When enabled,\nAllowedDelegateClients must name specific ToolHive clients rather than\nuse the wildcard.", - "type": "boolean" - }, - "allow_private_ips": { - "description": "AllowPrivateIPs permits OIDC discovery and JWKS fetches for THIS\nissuer to resolve to a private or loopback address. Use only when the\nissuer is hosted inside the same cluster and has no public endpoint.", - "type": "boolean" + "role_claim": { + "description": "RoleClaim is the JWT claim to use for role mapping (default: \"groups\").", + "type": "string" }, - "allowed_actors": { - "description": "AllowedActors is the allowlist of ActorClaim values authorized to\nexchange a subject token from this issuer when it carries no\n\"may_act\" claim. ActorMatcher can additionally authorize a token by\nmatching its complete verified claims map; either signal is sufficient.\nWhen both are empty, only may_act-bearing tokens are accepted, and only\nif AllowMayAct is also true for this issuer. By itself names no\nToolHive client — see AllowedDelegateClients and\ndocs/arch/17-token-exchange-delegation.md (\"Accepted limitations\" #1).", + "role_mappings": { + "description": "RoleMappings maps JWT claim values to IAM roles with priority.", "items": { - "type": "string" + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_auth_awssts.RoleMapping" }, "type": "array", "uniqueItems": false }, - "allowed_delegate_clients": { - "description": "AllowedDelegateClients restricts which ToolHive client IDs may\nexchange a subject token from this issuer, for BOTH consent paths.\nRequired (validateTrustedIssuer rejects empty/absent); \"*\" permits\nany confidential client holding the grant. See\ndocs/arch/17-token-exchange-delegation.md (\"Accepted limitations\" #1).", - "items": { - "type": "string" - }, - "type": "array", - "uniqueItems": false + "service": { + "description": "Service is the AWS service name for SigV4 signing (default: \"aws-mcp\").", + "type": "string" }, - "expected_audience": { - "description": "ExpectedAudience is the expected \"aud\" claim value that must appear\nin an RFC 8693 subject token's audience list (a resource/API identifier,\nnot a client ID — required for delegation unless JWTBearerGrant is\nconfigured; see looksLikeResourceIdentifier). RFC 7523 assertions use\nthe token endpoint as their audience instead.\nSee docs/arch/17-token-exchange-delegation.md (\"ID/access-token\ndiscrimination\") for why and its limits.", + "session_duration": { + "description": "SessionDuration is the duration in seconds for assumed role credentials (default: 3600).", + "type": "integer" + }, + "session_name_claim": { + "description": "SessionNameClaim is the JWT claim to use for role session name (default: \"sub\").", "type": "string" }, - "insecure_allow_http": { - "description": "InsecureAllowHTTP permits plain-HTTP OIDC discovery and JWKS fetches\nfor THIS issuer only. Development and testing only — never set in\nproduction. Does not relax the private-IP guard; see AllowPrivateIPs.\nDeliberately per-issuer: this server's own InsecureAllowHTTP must not\nsilently permit plaintext discovery for every trusted external issuer\ntoo — a network attacker who can intercept that traffic could\nsubstitute a JWKS and forge subject tokens for that issuer's\nnamespace.", - "type": "boolean" + "subject_provider_name": { + "description": "SubjectProviderName identifies which upstream provider's access token to use\nfor STS AssumeRoleWithWebIdentity. Used by vMCP only. When empty, the bearer\ntoken from the incoming HTTP request is used.", + "type": "string" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_auth_awssts.RoleMapping": { + "properties": { + "claim": { + "description": "Claim is the simple claim value to match (e.g., group name).\nInternally compiles to a CEL expression: \"\u003cclaim_value\u003e\" in claims[\"\u003crole_claim\u003e\"]\nMutually exclusive with Matcher.", + "type": "string" }, - "issuer_url": { - "description": "IssuerURL is the expected \"iss\" claim value (exact match).", + "matcher": { + "description": "Matcher is a CEL expression for complex matching against JWT claims.\nThe expression has access to a \"claims\" variable containing all JWT claims.\nExamples:\n - \"admins\" in claims[\"groups\"]\n - claims[\"sub\"] == \"user123\" \u0026\u0026 !(\"act\" in claims)\nMutually exclusive with Claim.", "type": "string" }, - "jwks_url": { - "description": "JWKSURL is the URL to fetch the issuer's JSON Web Key Set from.\nIf empty, it is resolved via OIDC discovery at {IssuerURL}/.well-known/openid-configuration.", + "priority": { + "description": "Priority determines selection order (lower number = higher priority).\nWhen multiple mappings match, the one with the lowest priority is selected.\nWhen nil (omitted), the mapping has the lowest possible priority, and\nconfiguration order acts as tie-breaker via stable sort.", + "type": "integer" + }, + "role_arn": { + "description": "RoleArn is the IAM role ARN to assume when this mapping matches.", + "type": "string" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_auth_upstreamswap.Config": { + "description": "UpstreamSwapConfig contains configuration for upstream token swap middleware.\nWhen set along with EmbeddedAuthServerConfig, this middleware exchanges ToolHive JWTs\nfor upstream IdP tokens before forwarding requests to the MCP server.", + "properties": { + "custom_header_name": { + "description": "CustomHeaderName is the header name when HeaderStrategy is \"custom\".", "type": "string" }, - "jwt_bearer_grant": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver_server_tokenexchange.JWTBearerGrantPolicy" + "header_strategy": { + "description": "HeaderStrategy determines how to inject the token: \"replace\" (default) or \"custom\".", + "type": "string" + }, + "provider_name": { + "description": "ProviderName identifies which upstream provider's tokens to retrieve for injection.\nThis is required and must match a configured upstream provider name.", + "type": "string" } }, "type": "object" @@ -1401,34 +1283,6 @@ }, "type": "object" }, - "github_com_stacklok_toolhive_pkg_ratelimit_types.RateLimitBucket": { - "description": "PerUser token bucket configuration for this tool.\n+optional", - "properties": { - "maxTokens": { - "description": "MaxTokens is the maximum number of tokens (bucket capacity).\nThis is also the burst size: the maximum number of requests that can be served\ninstantaneously before the bucket is depleted.\n+kubebuilder:validation:Required\n+kubebuilder:validation:Minimum=1", - "type": "integer" - }, - "refillPeriod": { - "$ref": "#/components/schemas/v1.Duration" - } - }, - "type": "object" - }, - "github_com_stacklok_toolhive_pkg_ratelimit_types.ToolRateLimitConfig": { - "properties": { - "name": { - "description": "Name is the MCP tool name this limit applies to.\n+kubebuilder:validation:Required\n+kubebuilder:validation:MinLength=1", - "type": "string" - }, - "perUser": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_ratelimit_types.RateLimitBucket" - }, - "shared": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_ratelimit_types.RateLimitBucket" - } - }, - "type": "object" - }, "github_com_stacklok_toolhive_pkg_registry.OAuthPublicConfig": { "description": "AuthConfig contains the non-secret OAuth configuration when auth is configured.\nNil when auth_status is \"none\".", "properties": { @@ -1494,7 +1348,7 @@ "uniqueItems": false }, "audit_config": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_audit.Config" + "$ref": "#/components/schemas/audit.Config" }, "audit_config_path": { "description": "DEPRECATED: Middleware configuration.\nAuditConfigPath is the path to the audit configuration file", @@ -1538,7 +1392,7 @@ "type": "boolean" }, "embedded_auth_server_config": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.RunConfig" + "$ref": "#/components/schemas/authserver.RunConfig" }, "endpoint_prefix": { "description": "EndpointPrefix is an explicit prefix to prepend to SSE endpoint URLs.\nThis is used to handle path-based ingress routing scenarios.", @@ -1637,7 +1491,7 @@ "uniqueItems": false }, "rate_limit_config": { - "$ref": "#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.RateLimitConfig" + "$ref": "#/components/schemas/v1beta1.RateLimitConfig" }, "rate_limit_namespace": { "description": "RateLimitNamespace is the Kubernetes namespace for Redis key derivation.", @@ -5334,6 +5188,101 @@ }, "type": "object" }, + "tokenexchange.JWTBearerGrantPolicy": { + "description": "JWTBearerGrant optionally enables the plain RFC 7523 JWT-bearer grant.\nIt accepts assertions from this issuer without client authentication and\nlimits their maximum age, subjects, and RFC 8707 resources. It is\nindependent from RFC 8693 delegation policy.", + "properties": { + "accepted_audiences": { + "description": "AcceptedAudiences is the set of \"this AS\" identity strings an\nassertion's \"aud\" claim must intersect — e.g. to support migrating\nthis server's issuer/token-endpoint URL, or exposing it under more\nthan one valid name. Each value uniquely identifies this\nauthorization server for this grant; it is NOT a resource/API\nidentifier — a bare resource audience is deliberately not accepted\nhere, that would let any RFC 8707 resource-scoped token satisfy the\ngrant instead of only tokens minted for this AS. Defaults to\n[tokenEndpoint] when empty, preserving prior exact-match behavior.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "max_assertion_age": { + "type": "string" + }, + "subject_bindings": { + "items": { + "$ref": "#/components/schemas/tokenexchange.JWTBearerSubjectBinding" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, + "tokenexchange.JWTBearerSubjectBinding": { + "properties": { + "allowed_resources": { + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "subject": { + "type": "string" + } + }, + "type": "object" + }, + "tokenexchange.TrustedIssuer": { + "properties": { + "actor_claim": { + "description": "ActorClaim names the claim identifying the client that requested the\nsubject token from THIS EXTERNAL ISSUER (used by AllowedActors below).\nValues are in the external issuer's namespace, NOT ToolHive client\nIDs. Defaults to \"azp\"; use \"appid\" for Microsoft Entra v1, \"cid\" for\nOkta. The special value \"client_id\" reads ValidatedClaims.ClientID\ninstead of Extra (assignClaim routes it to that field) — it is still\nthe external token's client_id claim, not a ToolHive one.", + "type": "string" + }, + "actor_matcher": { + "description": "ActorMatcher is an admin-authored CEL expression evaluated against the\ncomplete signature-verified JWT claims map as \"claims\". A true result\nauthorizes delegation alongside AllowedActors; a syntax or type error\nfails configuration validation. An expression that compiles but does\nnot return bool is NOT caught at that point, though — it compiles\nsuccessfully and is only rejected the first time it is evaluated\nagainst a real token, denying that token (and every one after it, since\nthe expression will never return bool). Any other runtime evaluation\nerror denies the token the same way.", + "type": "string" + }, + "allow_may_act": { + "description": "AllowMayAct permits this external issuer's may_act claim to authorize\ndelegation. It defaults to false; external issuers must be opted in\nexplicitly because may_act bypasses AllowedActors and ActorMatcher. It\ndoes not affect self-issued subject tokens. When enabled,\nAllowedDelegateClients must name specific ToolHive clients rather than\nuse the wildcard.", + "type": "boolean" + }, + "allow_private_ips": { + "description": "AllowPrivateIPs permits OIDC discovery and JWKS fetches for THIS\nissuer to resolve to a private or loopback address. Use only when the\nissuer is hosted inside the same cluster and has no public endpoint.", + "type": "boolean" + }, + "allowed_actors": { + "description": "AllowedActors is the allowlist of ActorClaim values authorized to\nexchange a subject token from this issuer when it carries no\n\"may_act\" claim. ActorMatcher can additionally authorize a token by\nmatching its complete verified claims map; either signal is sufficient.\nWhen both are empty, only may_act-bearing tokens are accepted, and only\nif AllowMayAct is also true for this issuer. By itself names no\nToolHive client — see AllowedDelegateClients and\ndocs/arch/17-token-exchange-delegation.md (\"Accepted limitations\" #1).", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "allowed_delegate_clients": { + "description": "AllowedDelegateClients restricts which ToolHive client IDs may\nexchange a subject token from this issuer, for BOTH consent paths.\nRequired (validateTrustedIssuer rejects empty/absent); \"*\" permits\nany confidential client holding the grant. See\ndocs/arch/17-token-exchange-delegation.md (\"Accepted limitations\" #1).", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "expected_audience": { + "description": "ExpectedAudience is the expected \"aud\" claim value that must appear\nin an RFC 8693 subject token's audience list (a resource/API identifier,\nnot a client ID — required for delegation unless JWTBearerGrant is\nconfigured; see looksLikeResourceIdentifier). RFC 7523 assertions use\nthe token endpoint as their audience instead.\nSee docs/arch/17-token-exchange-delegation.md (\"ID/access-token\ndiscrimination\") for why and its limits.", + "type": "string" + }, + "insecure_allow_http": { + "description": "InsecureAllowHTTP permits plain-HTTP OIDC discovery and JWKS fetches\nfor THIS issuer only. Development and testing only — never set in\nproduction. Does not relax the private-IP guard; see AllowPrivateIPs.\nDeliberately per-issuer: this server's own InsecureAllowHTTP must not\nsilently permit plaintext discovery for every trusted external issuer\ntoo — a network attacker who can intercept that traffic could\nsubstitute a JWKS and forge subject tokens for that issuer's\nnamespace.", + "type": "boolean" + }, + "issuer_url": { + "description": "IssuerURL is the expected \"iss\" claim value (exact match).", + "type": "string" + }, + "jwks_url": { + "description": "JWKSURL is the URL to fetch the issuer's JSON Web Key Set from.\nIf empty, it is resolved via OIDC discovery at {IssuerURL}/.well-known/openid-configuration.", + "type": "string" + }, + "jwt_bearer_grant": { + "$ref": "#/components/schemas/tokenexchange.JWTBearerGrantPolicy" + } + }, + "type": "object" + }, "types.MiddlewareConfig": { "properties": { "parameters": { @@ -5347,6 +5296,34 @@ }, "type": "object" }, + "types.RateLimitBucket": { + "description": "PerUser token bucket configuration for this tool.\n+optional", + "properties": { + "maxTokens": { + "description": "MaxTokens is the maximum number of tokens (bucket capacity).\nThis is also the burst size: the maximum number of requests that can be served\ninstantaneously before the bucket is depleted.\n+kubebuilder:validation:Required\n+kubebuilder:validation:Minimum=1", + "type": "integer" + }, + "refillPeriod": { + "$ref": "#/components/schemas/v1.Duration" + } + }, + "type": "object" + }, + "types.ToolRateLimitConfig": { + "properties": { + "name": { + "description": "Name is the MCP tool name this limit applies to.\n+kubebuilder:validation:Required\n+kubebuilder:validation:MinLength=1", + "type": "string" + }, + "perUser": { + "$ref": "#/components/schemas/types.RateLimitBucket" + }, + "shared": { + "$ref": "#/components/schemas/types.RateLimitBucket" + } + }, + "type": "object" + }, "v0.ServerJSON": { "properties": { "$schema": { @@ -5427,6 +5404,26 @@ "v1.Duration": { "description": "RefillPeriod is the duration to fully refill the bucket from zero to maxTokens.\nThe effective refill rate is maxTokens / refillPeriod tokens per second.\nFormat: Go duration string (e.g., \"1m0s\", \"30s\", \"1h0m0s\").\n+kubebuilder:validation:Required", "type": "object" + }, + "v1beta1.RateLimitConfig": { + "description": "RateLimitConfig contains the CRD rate limiting configuration.\nWhen set, rate limiting middleware is added to the proxy middleware chain.", + "properties": { + "perUser": { + "$ref": "#/components/schemas/types.RateLimitBucket" + }, + "shared": { + "$ref": "#/components/schemas/types.RateLimitBucket" + }, + "tools": { + "description": "Tools defines per-tool rate limit overrides.\nEach entry applies additional rate limits to calls targeting a specific tool name.\nA request must pass both the server-level limit and the per-tool limit.\n+listType=map\n+listMapKey=name\n+optional", + "items": { + "$ref": "#/components/schemas/types.ToolRateLimitConfig" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" } } }, diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index 06bd9b2c47..d6c3fe294b 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -1,161 +1,6 @@ components: schemas: - auth.TokenValidatorConfig: - description: |- - DEPRECATED: Middleware configuration. - OIDCConfig contains OIDC configuration - properties: - allowPrivateIP: - description: AllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses - type: boolean - audience: - description: Audience is the expected audience for the token - type: string - authTokenFile: - description: AuthTokenFile is the path to file containing bearer token for - authentication - type: string - cacertPath: - description: CACertPath is the path to the CA certificate bundle for HTTPS - requests - type: string - clientID: - description: ClientID is the OIDC client ID - type: string - clientSecret: - description: ClientSecret is the optional OIDC client secret for introspection - type: string - insecureAllowHTTP: - description: |- - InsecureAllowHTTP allows HTTP (non-HTTPS) OIDC issuers for development/testing - WARNING: This is insecure and should NEVER be used in production - type: boolean - introspectionURL: - description: IntrospectionURL is the optional introspection endpoint for - validating tokens - type: string - issuer: - description: Issuer is the OIDC issuer URL (e.g., https://accounts.google.com) - type: string - jwksurl: - description: JWKSURL is the URL to fetch the JWKS from - type: string - resourceURL: - description: ResourceURL is the explicit resource URL for OAuth discovery - (RFC 9728) - type: string - scopes: - description: |- - Scopes is the list of OAuth scopes to advertise in the well-known endpoint (RFC 9728) - If empty, defaults to ["openid"] - items: - type: string - type: array - type: object - core.Workload: - properties: - created_at: - description: CreatedAt is the timestamp when the workload was created. - type: string - group: - description: Group is the name of the group this workload belongs to, if - any. - type: string - labels: - additionalProperties: - type: string - description: Labels are the container labels (excluding standard ToolHive - labels) - type: object - name: - description: |- - Name is the name of the workload. - It is used as a unique identifier. - type: string - package: - description: Package specifies the Workload Package used to create this - Workload. - type: string - port: - description: |- - Port is the port on which the workload is exposed. - This is embedded in the URL. - type: integer - proxy_mode: - description: |- - ProxyMode is the proxy mode that clients should use to connect. - For stdio transports, this will be the proxy mode (sse or streamable-http). - For direct transports (sse/streamable-http), this will be the same as TransportType. - type: string - remote: - description: Remote indicates whether this is a remote workload (true) or - a container workload (false). - type: boolean - started_at: - description: StartedAt is when the container was last started (changes on - restart) - type: string - status: - description: Status is the current status of the workload. - enum: - - running - - stopped - - error - - starting - - stopping - - unhealthy - - removing - - unknown - - unauthenticated - - auth_retrying - - policy_stopped - type: string - status_context: - description: |- - StatusContext provides additional context about the workload's status. - The exact meaning is determined by the status and the underlying runtime. - type: string - tools: - description: ToolsFilter is the filter on tools applied to the workload. - items: - type: string - type: array - uniqueItems: false - transport_type: - description: TransportType is the type of transport used for this workload. - enum: - - stdio - - sse - - streamable-http - - inspector - type: string - url: - description: URL is the URL of the workload exposed by the ToolHive proxy. - type: string - type: object - github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.RateLimitConfig: - description: |- - RateLimitConfig contains the CRD rate limiting configuration. - When set, rate limiting middleware is added to the proxy middleware chain. - properties: - perUser: - $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_ratelimit_types.RateLimitBucket' - shared: - $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_ratelimit_types.RateLimitBucket' - tools: - description: |- - Tools defines per-tool rate limit overrides. - Each entry applies additional rate limits to calls targeting a specific tool name. - A request must pass both the server-level limit and the per-tool limit. - +listType=map - +listMapKey=name - +optional - items: - $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_ratelimit_types.ToolRateLimitConfig' - type: array - uniqueItems: false - type: object - github_com_stacklok_toolhive_pkg_audit.Config: + audit.Config: description: |- DEPRECATED: Middleware configuration. AuditConfig contains the audit logging configuration @@ -232,95 +77,59 @@ components: +optional type: integer type: object - github_com_stacklok_toolhive_pkg_auth_awssts.Config: - description: AWSStsConfig contains AWS STS token exchange configuration for - accessing AWS services + auth.TokenValidatorConfig: + description: |- + DEPRECATED: Middleware configuration. + OIDCConfig contains OIDC configuration properties: - fallback_role_arn: - description: FallbackRoleArn is the IAM role ARN to assume when no role - mapping matches. - type: string - region: - description: Region is the AWS region for STS and SigV4 signing. - type: string - role_claim: - description: 'RoleClaim is the JWT claim to use for role mapping (default: - "groups").' + allowPrivateIP: + description: AllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses + type: boolean + audience: + description: Audience is the expected audience for the token type: string - role_mappings: - description: RoleMappings maps JWT claim values to IAM roles with priority. - items: - $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_auth_awssts.RoleMapping' - type: array - uniqueItems: false - service: - description: 'Service is the AWS service name for SigV4 signing (default: - "aws-mcp").' + authTokenFile: + description: AuthTokenFile is the path to file containing bearer token for + authentication type: string - session_duration: - description: 'SessionDuration is the duration in seconds for assumed role - credentials (default: 3600).' - type: integer - session_name_claim: - description: 'SessionNameClaim is the JWT claim to use for role session - name (default: "sub").' + cacertPath: + description: CACertPath is the path to the CA certificate bundle for HTTPS + requests type: string - subject_provider_name: - description: |- - SubjectProviderName identifies which upstream provider's access token to use - for STS AssumeRoleWithWebIdentity. Used by vMCP only. When empty, the bearer - token from the incoming HTTP request is used. + clientID: + description: ClientID is the OIDC client ID type: string - type: object - github_com_stacklok_toolhive_pkg_auth_awssts.RoleMapping: - properties: - claim: - description: |- - Claim is the simple claim value to match (e.g., group name). - Internally compiles to a CEL expression: "" in claims[""] - Mutually exclusive with Matcher. + clientSecret: + description: ClientSecret is the optional OIDC client secret for introspection type: string - matcher: + insecureAllowHTTP: description: |- - Matcher is a CEL expression for complex matching against JWT claims. - The expression has access to a "claims" variable containing all JWT claims. - Examples: - - "admins" in claims["groups"] - - claims["sub"] == "user123" && !("act" in claims) - Mutually exclusive with Claim. + InsecureAllowHTTP allows HTTP (non-HTTPS) OIDC issuers for development/testing + WARNING: This is insecure and should NEVER be used in production + type: boolean + introspectionURL: + description: IntrospectionURL is the optional introspection endpoint for + validating tokens type: string - priority: - description: |- - Priority determines selection order (lower number = higher priority). - When multiple mappings match, the one with the lowest priority is selected. - When nil (omitted), the mapping has the lowest possible priority, and - configuration order acts as tie-breaker via stable sort. - type: integer - role_arn: - description: RoleArn is the IAM role ARN to assume when this mapping matches. + issuer: + description: Issuer is the OIDC issuer URL (e.g., https://accounts.google.com) type: string - type: object - github_com_stacklok_toolhive_pkg_auth_upstreamswap.Config: - description: |- - UpstreamSwapConfig contains configuration for upstream token swap middleware. - When set along with EmbeddedAuthServerConfig, this middleware exchanges ToolHive JWTs - for upstream IdP tokens before forwarding requests to the MCP server. - properties: - custom_header_name: - description: CustomHeaderName is the header name when HeaderStrategy is - "custom". + jwksurl: + description: JWKSURL is the URL to fetch the JWKS from type: string - header_strategy: - description: 'HeaderStrategy determines how to inject the token: "replace" - (default) or "custom".' + resourceURL: + description: ResourceURL is the explicit resource URL for OAuth discovery + (RFC 9728) type: string - provider_name: + scopes: description: |- - ProviderName identifies which upstream provider's tokens to retrieve for injection. - This is required and must match a configured upstream provider name. - type: string + Scopes is the list of OAuth scopes to advertise in the well-known endpoint (RFC 9728) + If empty, defaults to ["openid"] + items: + type: string + type: array type: object - github_com_stacklok_toolhive_pkg_authserver.CIMDRunConfig: + authserver.CIMDRunConfig: description: |- CIMD controls client_id metadata document support. When enabled, the embedded authorization server accepts HTTPS URLs as client_id values @@ -343,7 +152,7 @@ components: description: Enabled activates CIMD client lookup when true. type: boolean type: object - github_com_stacklok_toolhive_pkg_authserver.DCRUpstreamConfig: + authserver.DCRUpstreamConfig: description: |- DCRConfig enables RFC 7591 Dynamic Client Registration against the upstream authorization server. When set, the client credentials are @@ -407,7 +216,7 @@ components: server trusts. type: string type: object - github_com_stacklok_toolhive_pkg_authserver.DelegateClientRunConfig: + authserver.DelegateClientRunConfig: properties: audiences: description: |- @@ -444,7 +253,7 @@ components: type: array uniqueItems: false type: object - github_com_stacklok_toolhive_pkg_authserver.IdentityFromTokenRunConfig: + authserver.IdentityFromTokenRunConfig: description: |- IdentityFromToken extracts user identity (subject, name, email) directly from the OAuth2 token-endpoint response body using gjson dot-notation paths. When set, the @@ -464,7 +273,7 @@ components: Required when IdentityFromToken is set. type: string type: object - github_com_stacklok_toolhive_pkg_authserver.OAuth2UpstreamRunConfig: + authserver.OAuth2UpstreamRunConfig: description: |- OAuth2Config contains OAuth 2.0-specific configuration. Required when Type is "oauth2", must be nil when Type is "oidc". @@ -493,6 +302,10 @@ components: description: AuthorizationEndpoint is the URL for the OAuth authorization endpoint. type: string + ca_file_path: + description: CAFilePath is the path to a PEM CA bundle added to the system + roots. + type: string client_id: description: |- ClientID is the OAuth 2.0 client identifier registered with the upstream IDP. @@ -510,9 +323,9 @@ components: Mutually exclusive with ClientSecretEnvVar. Optional for public clients using PKCE. type: string dcr_config: - $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.DCRUpstreamConfig' + $ref: '#/components/schemas/authserver.DCRUpstreamConfig' identity_from_token: - $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.IdentityFromTokenRunConfig' + $ref: '#/components/schemas/authserver.IdentityFromTokenRunConfig' insecure_allow_http: description: |- InsecureAllowHTTP permits plain-HTTP authorization and token endpoint URLs @@ -535,11 +348,11 @@ components: description: TokenEndpoint is the URL for the OAuth token endpoint. type: string token_response_mapping: - $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.TokenResponseMappingRunConfig' + $ref: '#/components/schemas/authserver.TokenResponseMappingRunConfig' userinfo: - $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.UserInfoRunConfig' + $ref: '#/components/schemas/authserver.UserInfoRunConfig' type: object - github_com_stacklok_toolhive_pkg_authserver.OIDCUpstreamRunConfig: + authserver.OIDCUpstreamRunConfig: description: |- OIDCConfig contains OIDC-specific configuration. Required when Type is "oidc", must be nil when Type is "oauth2". @@ -560,6 +373,10 @@ components: HTTP-scheme restrictions are unchanged — HTTPS is still required for non-localhost hosts. Defaults to false. type: boolean + ca_file_path: + description: CAFilePath is the path to a PEM CA bundle added to the system + roots. + type: string client_id: description: ClientID is the OAuth 2.0 client identifier registered with the upstream IDP. @@ -609,9 +426,9 @@ components: stable per user (e.g. Entra/Azure AD's "oid"). See upstream.OIDCConfig. type: string userinfo_override: - $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.UserInfoRunConfig' + $ref: '#/components/schemas/authserver.UserInfoRunConfig' type: object - github_com_stacklok_toolhive_pkg_authserver.RunConfig: + authserver.RunConfig: description: |- EmbeddedAuthServerConfig contains configuration for the embedded OAuth2/OIDC authorization server. When set, the proxy runner will start an embedded auth server that delegates to upstream IDPs. @@ -679,7 +496,7 @@ components: type: array uniqueItems: false cimd: - $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.CIMDRunConfig' + $ref: '#/components/schemas/authserver.CIMDRunConfig' delegate_clients: description: |- DelegateClients declares confidential OAuth clients to register at @@ -695,7 +512,7 @@ components: See DelegateClientRunConfig for the per-client field reference. items: - $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.DelegateClientRunConfig' + $ref: '#/components/schemas/authserver.DelegateClientRunConfig' type: array uniqueItems: false delegation_token_lifespan: @@ -800,11 +617,11 @@ components: type: array uniqueItems: false signing_key_config: - $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.SigningKeyRunConfig' + $ref: '#/components/schemas/authserver.SigningKeyRunConfig' storage: $ref: '#/components/schemas/storage.RunConfig' token_lifespans: - $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.TokenLifespanRunConfig' + $ref: '#/components/schemas/authserver.TokenLifespanRunConfig' trusted_issuers: description: |- TrustedIssuers lists external OIDC issuers whose tokens are accepted as @@ -819,7 +636,7 @@ components: subject namespace qualification, required client binding) that aren't visible from the config shape alone. items: - $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_authserver_server_tokenexchange.TrustedIssuer' + $ref: '#/components/schemas/tokenexchange.TrustedIssuer' type: array uniqueItems: false upstreams: @@ -828,11 +645,11 @@ components: At least one upstream is required - the server delegates authentication to these providers. Multiple upstreams are supported for sequential authorization chains. items: - $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.UpstreamRunConfig' + $ref: '#/components/schemas/authserver.UpstreamRunConfig' type: array uniqueItems: false type: object - github_com_stacklok_toolhive_pkg_authserver.SigningKeyRunConfig: + authserver.SigningKeyRunConfig: description: |- SigningKeyConfig configures the signing key provider for JWT operations. If nil or empty, an ephemeral signing key will be auto-generated (development only). @@ -858,7 +675,7 @@ components: This key is used for signing new tokens. type: string type: object - github_com_stacklok_toolhive_pkg_authserver.TokenLifespanRunConfig: + authserver.TokenLifespanRunConfig: description: |- TokenLifespans configures the duration that various tokens are valid. If nil, defaults are applied (access: 1h, refresh: 7d, authCode: 10m). @@ -879,7 +696,7 @@ components: If empty, defaults to 7 days (168h). type: string type: object - github_com_stacklok_toolhive_pkg_authserver.TokenResponseMappingRunConfig: + authserver.TokenResponseMappingRunConfig: description: |- TokenResponseMapping configures custom field extraction from non-standard token responses. When set, the token exchange bypasses golang.org/x/oauth2 and extracts fields using @@ -902,16 +719,7 @@ components: "scope". type: string type: object - github_com_stacklok_toolhive_pkg_authserver.UpstreamProviderType: - description: 'Type specifies the provider type: "oidc" or "oauth2".' - enum: - - oidc - - oauth2 - type: string - x-enum-varnames: - - UpstreamProviderTypeOIDC - - UpstreamProviderTypeOAuth2 - github_com_stacklok_toolhive_pkg_authserver.UpstreamRunConfig: + authserver.UpstreamRunConfig: properties: name: description: |- @@ -920,13 +728,14 @@ components: If empty when only one upstream is configured, defaults to "default". type: string oauth2_config: - $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.OAuth2UpstreamRunConfig' + $ref: '#/components/schemas/authserver.OAuth2UpstreamRunConfig' oidc_config: - $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.OIDCUpstreamRunConfig' + $ref: '#/components/schemas/authserver.OIDCUpstreamRunConfig' type: - $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.UpstreamProviderType' + description: 'Type specifies the provider type: "oidc" or "oauth2".' + type: string type: object - github_com_stacklok_toolhive_pkg_authserver.UserInfoFieldMappingRunConfig: + authserver.UserInfoFieldMappingRunConfig: description: |- FieldMapping contains custom field mapping configuration for non-standard providers. If nil, standard OIDC field names are used ("sub", "name", "email"). @@ -959,7 +768,7 @@ components: type: array uniqueItems: false type: object - github_com_stacklok_toolhive_pkg_authserver.UserInfoRunConfig: + authserver.UserInfoRunConfig: description: |- UserInfo contains configuration for fetching user information. Optional: when nil, the upstream OAuth2 provider derives a deterministic @@ -978,148 +787,181 @@ components: description: EndpointURL is the URL of the userinfo endpoint. type: string field_mapping: - $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.UserInfoFieldMappingRunConfig' + $ref: '#/components/schemas/authserver.UserInfoFieldMappingRunConfig' http_method: description: |- HTTPMethod is the HTTP method to use for the userinfo request. If not specified, defaults to GET. type: string type: object - github_com_stacklok_toolhive_pkg_authserver_server_tokenexchange.JWTBearerGrantPolicy: - description: |- - JWTBearerGrant optionally enables the plain RFC 7523 JWT-bearer grant. - It accepts assertions from this issuer without client authentication and - limits their maximum age, subjects, and RFC 8707 resources. It is - independent from RFC 8693 delegation policy. + core.Workload: properties: - accepted_audiences: - description: |- - AcceptedAudiences is the set of "this AS" identity strings an - assertion's "aud" claim must intersect — e.g. to support migrating - this server's issuer/token-endpoint URL, or exposing it under more - than one valid name. Each value uniquely identifies this - authorization server for this grant; it is NOT a resource/API - identifier — a bare resource audience is deliberately not accepted - here, that would let any RFC 8707 resource-scoped token satisfy the - grant instead of only tokens minted for this AS. Defaults to - [tokenEndpoint] when empty, preserving prior exact-match behavior. - items: - type: string - type: array - uniqueItems: false - max_assertion_age: + created_at: + description: CreatedAt is the timestamp when the workload was created. type: string - subject_bindings: - items: - $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_authserver_server_tokenexchange.JWTBearerSubjectBinding' - type: array - uniqueItems: false - type: object - github_com_stacklok_toolhive_pkg_authserver_server_tokenexchange.JWTBearerSubjectBinding: - properties: - allowed_resources: - items: - type: string - type: array - uniqueItems: false - subject: + group: + description: Group is the name of the group this workload belongs to, if + any. type: string - type: object - github_com_stacklok_toolhive_pkg_authserver_server_tokenexchange.TrustedIssuer: - properties: - actor_claim: + labels: + additionalProperties: + type: string + description: Labels are the container labels (excluding standard ToolHive + labels) + type: object + name: description: |- - ActorClaim names the claim identifying the client that requested the - subject token from THIS EXTERNAL ISSUER (used by AllowedActors below). - Values are in the external issuer's namespace, NOT ToolHive client - IDs. Defaults to "azp"; use "appid" for Microsoft Entra v1, "cid" for - Okta. The special value "client_id" reads ValidatedClaims.ClientID - instead of Extra (assignClaim routes it to that field) — it is still - the external token's client_id claim, not a ToolHive one. + Name is the name of the workload. + It is used as a unique identifier. type: string - actor_matcher: - description: |- - ActorMatcher is an admin-authored CEL expression evaluated against the - complete signature-verified JWT claims map as "claims". A true result - authorizes delegation alongside AllowedActors; a syntax or type error - fails configuration validation. An expression that compiles but does - not return bool is NOT caught at that point, though — it compiles - successfully and is only rejected the first time it is evaluated - against a real token, denying that token (and every one after it, since - the expression will never return bool). Any other runtime evaluation - error denies the token the same way. + package: + description: Package specifies the Workload Package used to create this + Workload. type: string - allow_may_act: + port: description: |- - AllowMayAct permits this external issuer's may_act claim to authorize - delegation. It defaults to false; external issuers must be opted in - explicitly because may_act bypasses AllowedActors and ActorMatcher. It - does not affect self-issued subject tokens. When enabled, - AllowedDelegateClients must name specific ToolHive clients rather than - use the wildcard. - type: boolean - allow_private_ips: + Port is the port on which the workload is exposed. + This is embedded in the URL. + type: integer + proxy_mode: description: |- - AllowPrivateIPs permits OIDC discovery and JWKS fetches for THIS - issuer to resolve to a private or loopback address. Use only when the - issuer is hosted inside the same cluster and has no public endpoint. + ProxyMode is the proxy mode that clients should use to connect. + For stdio transports, this will be the proxy mode (sse or streamable-http). + For direct transports (sse/streamable-http), this will be the same as TransportType. + type: string + remote: + description: Remote indicates whether this is a remote workload (true) or + a container workload (false). type: boolean - allowed_actors: + started_at: + description: StartedAt is when the container was last started (changes on + restart) + type: string + status: + description: Status is the current status of the workload. + enum: + - running + - stopped + - error + - starting + - stopping + - unhealthy + - removing + - unknown + - unauthenticated + - auth_retrying + - policy_stopped + type: string + status_context: description: |- - AllowedActors is the allowlist of ActorClaim values authorized to - exchange a subject token from this issuer when it carries no - "may_act" claim. ActorMatcher can additionally authorize a token by - matching its complete verified claims map; either signal is sufficient. - When both are empty, only may_act-bearing tokens are accepted, and only - if AllowMayAct is also true for this issuer. By itself names no - ToolHive client — see AllowedDelegateClients and - docs/arch/17-token-exchange-delegation.md ("Accepted limitations" #1). + StatusContext provides additional context about the workload's status. + The exact meaning is determined by the status and the underlying runtime. + type: string + tools: + description: ToolsFilter is the filter on tools applied to the workload. items: type: string type: array uniqueItems: false - allowed_delegate_clients: - description: |- - AllowedDelegateClients restricts which ToolHive client IDs may - exchange a subject token from this issuer, for BOTH consent paths. - Required (validateTrustedIssuer rejects empty/absent); "*" permits - any confidential client holding the grant. See - docs/arch/17-token-exchange-delegation.md ("Accepted limitations" #1). + transport_type: + description: TransportType is the type of transport used for this workload. + enum: + - stdio + - sse + - streamable-http + - inspector + type: string + url: + description: URL is the URL of the workload exposed by the ToolHive proxy. + type: string + type: object + github_com_stacklok_toolhive_pkg_auth_awssts.Config: + description: AWSStsConfig contains AWS STS token exchange configuration for + accessing AWS services + properties: + fallback_role_arn: + description: FallbackRoleArn is the IAM role ARN to assume when no role + mapping matches. + type: string + region: + description: Region is the AWS region for STS and SigV4 signing. + type: string + role_claim: + description: 'RoleClaim is the JWT claim to use for role mapping (default: + "groups").' + type: string + role_mappings: + description: RoleMappings maps JWT claim values to IAM roles with priority. items: - type: string + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_auth_awssts.RoleMapping' type: array uniqueItems: false - expected_audience: + service: + description: 'Service is the AWS service name for SigV4 signing (default: + "aws-mcp").' + type: string + session_duration: + description: 'SessionDuration is the duration in seconds for assumed role + credentials (default: 3600).' + type: integer + session_name_claim: + description: 'SessionNameClaim is the JWT claim to use for role session + name (default: "sub").' + type: string + subject_provider_name: + description: |- + SubjectProviderName identifies which upstream provider's access token to use + for STS AssumeRoleWithWebIdentity. Used by vMCP only. When empty, the bearer + token from the incoming HTTP request is used. + type: string + type: object + github_com_stacklok_toolhive_pkg_auth_awssts.RoleMapping: + properties: + claim: + description: |- + Claim is the simple claim value to match (e.g., group name). + Internally compiles to a CEL expression: "" in claims[""] + Mutually exclusive with Matcher. + type: string + matcher: description: |- - ExpectedAudience is the expected "aud" claim value that must appear - in an RFC 8693 subject token's audience list (a resource/API identifier, - not a client ID — required for delegation unless JWTBearerGrant is - configured; see looksLikeResourceIdentifier). RFC 7523 assertions use - the token endpoint as their audience instead. - See docs/arch/17-token-exchange-delegation.md ("ID/access-token - discrimination") for why and its limits. + Matcher is a CEL expression for complex matching against JWT claims. + The expression has access to a "claims" variable containing all JWT claims. + Examples: + - "admins" in claims["groups"] + - claims["sub"] == "user123" && !("act" in claims) + Mutually exclusive with Claim. type: string - insecure_allow_http: + priority: description: |- - InsecureAllowHTTP permits plain-HTTP OIDC discovery and JWKS fetches - for THIS issuer only. Development and testing only — never set in - production. Does not relax the private-IP guard; see AllowPrivateIPs. - Deliberately per-issuer: this server's own InsecureAllowHTTP must not - silently permit plaintext discovery for every trusted external issuer - too — a network attacker who can intercept that traffic could - substitute a JWKS and forge subject tokens for that issuer's - namespace. - type: boolean - issuer_url: - description: IssuerURL is the expected "iss" claim value (exact match). + Priority determines selection order (lower number = higher priority). + When multiple mappings match, the one with the lowest priority is selected. + When nil (omitted), the mapping has the lowest possible priority, and + configuration order acts as tie-breaker via stable sort. + type: integer + role_arn: + description: RoleArn is the IAM role ARN to assume when this mapping matches. type: string - jwks_url: + type: object + github_com_stacklok_toolhive_pkg_auth_upstreamswap.Config: + description: |- + UpstreamSwapConfig contains configuration for upstream token swap middleware. + When set along with EmbeddedAuthServerConfig, this middleware exchanges ToolHive JWTs + for upstream IdP tokens before forwarding requests to the MCP server. + properties: + custom_header_name: + description: CustomHeaderName is the header name when HeaderStrategy is + "custom". + type: string + header_strategy: + description: 'HeaderStrategy determines how to inject the token: "replace" + (default) or "custom".' + type: string + provider_name: description: |- - JWKSURL is the URL to fetch the issuer's JSON Web Key Set from. - If empty, it is resolved via OIDC discovery at {IssuerURL}/.well-known/openid-configuration. + ProviderName identifies which upstream provider's tokens to retrieve for injection. + This is required and must match a configured upstream provider name. type: string - jwt_bearer_grant: - $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_authserver_server_tokenexchange.JWTBearerGrantPolicy' type: object github_com_stacklok_toolhive_pkg_authz.Config: description: |- @@ -1555,35 +1397,6 @@ components: type: array uniqueItems: false type: object - github_com_stacklok_toolhive_pkg_ratelimit_types.RateLimitBucket: - description: |- - PerUser token bucket configuration for this tool. - +optional - properties: - maxTokens: - description: |- - MaxTokens is the maximum number of tokens (bucket capacity). - This is also the burst size: the maximum number of requests that can be served - instantaneously before the bucket is depleted. - +kubebuilder:validation:Required - +kubebuilder:validation:Minimum=1 - type: integer - refillPeriod: - $ref: '#/components/schemas/v1.Duration' - type: object - github_com_stacklok_toolhive_pkg_ratelimit_types.ToolRateLimitConfig: - properties: - name: - description: |- - Name is the MCP tool name this limit applies to. - +kubebuilder:validation:Required - +kubebuilder:validation:MinLength=1 - type: string - perUser: - $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_ratelimit_types.RateLimitBucket' - shared: - $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_ratelimit_types.RateLimitBucket' - type: object github_com_stacklok_toolhive_pkg_registry.OAuthPublicConfig: description: |- AuthConfig contains the non-secret OAuth configuration when auth is configured. @@ -1667,7 +1480,7 @@ components: type: array uniqueItems: false audit_config: - $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_audit.Config' + $ref: '#/components/schemas/audit.Config' audit_config_path: description: |- DEPRECATED: Middleware configuration. @@ -1703,7 +1516,7 @@ components: description: Debug indicates whether debug mode is enabled type: boolean embedded_auth_server_config: - $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_authserver.RunConfig' + $ref: '#/components/schemas/authserver.RunConfig' endpoint_prefix: description: |- EndpointPrefix is an explicit prefix to prepend to SSE endpoint URLs. @@ -1799,7 +1612,7 @@ components: type: array uniqueItems: false rate_limit_config: - $ref: '#/components/schemas/github_com_stacklok_toolhive_cmd_thv-operator_api_v1beta1.RateLimitConfig' + $ref: '#/components/schemas/v1beta1.RateLimitConfig' rate_limit_namespace: description: RateLimitNamespace is the Kubernetes namespace for Redis key derivation. @@ -5011,6 +4824,142 @@ components: description: TokenURL is the OAuth 2.0 token endpoint URL type: string type: object + tokenexchange.JWTBearerGrantPolicy: + description: |- + JWTBearerGrant optionally enables the plain RFC 7523 JWT-bearer grant. + It accepts assertions from this issuer without client authentication and + limits their maximum age, subjects, and RFC 8707 resources. It is + independent from RFC 8693 delegation policy. + properties: + accepted_audiences: + description: |- + AcceptedAudiences is the set of "this AS" identity strings an + assertion's "aud" claim must intersect — e.g. to support migrating + this server's issuer/token-endpoint URL, or exposing it under more + than one valid name. Each value uniquely identifies this + authorization server for this grant; it is NOT a resource/API + identifier — a bare resource audience is deliberately not accepted + here, that would let any RFC 8707 resource-scoped token satisfy the + grant instead of only tokens minted for this AS. Defaults to + [tokenEndpoint] when empty, preserving prior exact-match behavior. + items: + type: string + type: array + uniqueItems: false + max_assertion_age: + type: string + subject_bindings: + items: + $ref: '#/components/schemas/tokenexchange.JWTBearerSubjectBinding' + type: array + uniqueItems: false + type: object + tokenexchange.JWTBearerSubjectBinding: + properties: + allowed_resources: + items: + type: string + type: array + uniqueItems: false + subject: + type: string + type: object + tokenexchange.TrustedIssuer: + properties: + actor_claim: + description: |- + ActorClaim names the claim identifying the client that requested the + subject token from THIS EXTERNAL ISSUER (used by AllowedActors below). + Values are in the external issuer's namespace, NOT ToolHive client + IDs. Defaults to "azp"; use "appid" for Microsoft Entra v1, "cid" for + Okta. The special value "client_id" reads ValidatedClaims.ClientID + instead of Extra (assignClaim routes it to that field) — it is still + the external token's client_id claim, not a ToolHive one. + type: string + actor_matcher: + description: |- + ActorMatcher is an admin-authored CEL expression evaluated against the + complete signature-verified JWT claims map as "claims". A true result + authorizes delegation alongside AllowedActors; a syntax or type error + fails configuration validation. An expression that compiles but does + not return bool is NOT caught at that point, though — it compiles + successfully and is only rejected the first time it is evaluated + against a real token, denying that token (and every one after it, since + the expression will never return bool). Any other runtime evaluation + error denies the token the same way. + type: string + allow_may_act: + description: |- + AllowMayAct permits this external issuer's may_act claim to authorize + delegation. It defaults to false; external issuers must be opted in + explicitly because may_act bypasses AllowedActors and ActorMatcher. It + does not affect self-issued subject tokens. When enabled, + AllowedDelegateClients must name specific ToolHive clients rather than + use the wildcard. + type: boolean + allow_private_ips: + description: |- + AllowPrivateIPs permits OIDC discovery and JWKS fetches for THIS + issuer to resolve to a private or loopback address. Use only when the + issuer is hosted inside the same cluster and has no public endpoint. + type: boolean + allowed_actors: + description: |- + AllowedActors is the allowlist of ActorClaim values authorized to + exchange a subject token from this issuer when it carries no + "may_act" claim. ActorMatcher can additionally authorize a token by + matching its complete verified claims map; either signal is sufficient. + When both are empty, only may_act-bearing tokens are accepted, and only + if AllowMayAct is also true for this issuer. By itself names no + ToolHive client — see AllowedDelegateClients and + docs/arch/17-token-exchange-delegation.md ("Accepted limitations" #1). + items: + type: string + type: array + uniqueItems: false + allowed_delegate_clients: + description: |- + AllowedDelegateClients restricts which ToolHive client IDs may + exchange a subject token from this issuer, for BOTH consent paths. + Required (validateTrustedIssuer rejects empty/absent); "*" permits + any confidential client holding the grant. See + docs/arch/17-token-exchange-delegation.md ("Accepted limitations" #1). + items: + type: string + type: array + uniqueItems: false + expected_audience: + description: |- + ExpectedAudience is the expected "aud" claim value that must appear + in an RFC 8693 subject token's audience list (a resource/API identifier, + not a client ID — required for delegation unless JWTBearerGrant is + configured; see looksLikeResourceIdentifier). RFC 7523 assertions use + the token endpoint as their audience instead. + See docs/arch/17-token-exchange-delegation.md ("ID/access-token + discrimination") for why and its limits. + type: string + insecure_allow_http: + description: |- + InsecureAllowHTTP permits plain-HTTP OIDC discovery and JWKS fetches + for THIS issuer only. Development and testing only — never set in + production. Does not relax the private-IP guard; see AllowPrivateIPs. + Deliberately per-issuer: this server's own InsecureAllowHTTP must not + silently permit plaintext discovery for every trusted external issuer + too — a network attacker who can intercept that traffic could + substitute a JWKS and forge subject tokens for that issuer's + namespace. + type: boolean + issuer_url: + description: IssuerURL is the expected "iss" claim value (exact match). + type: string + jwks_url: + description: |- + JWKSURL is the URL to fetch the issuer's JSON Web Key Set from. + If empty, it is resolved via OIDC discovery at {IssuerURL}/.well-known/openid-configuration. + type: string + jwt_bearer_grant: + $ref: '#/components/schemas/tokenexchange.JWTBearerGrantPolicy' + type: object types.MiddlewareConfig: properties: parameters: @@ -5022,6 +4971,35 @@ components: description: Type is a string representing the middleware type. type: string type: object + types.RateLimitBucket: + description: |- + PerUser token bucket configuration for this tool. + +optional + properties: + maxTokens: + description: |- + MaxTokens is the maximum number of tokens (bucket capacity). + This is also the burst size: the maximum number of requests that can be served + instantaneously before the bucket is depleted. + +kubebuilder:validation:Required + +kubebuilder:validation:Minimum=1 + type: integer + refillPeriod: + $ref: '#/components/schemas/v1.Duration' + type: object + types.ToolRateLimitConfig: + properties: + name: + description: |- + Name is the MCP tool name this limit applies to. + +kubebuilder:validation:Required + +kubebuilder:validation:MinLength=1 + type: string + perUser: + $ref: '#/components/schemas/types.RateLimitBucket' + shared: + $ref: '#/components/schemas/types.RateLimitBucket' + type: object v0.ServerJSON: properties: $schema: @@ -5088,6 +5066,28 @@ components: Format: Go duration string (e.g., "1m0s", "30s", "1h0m0s"). +kubebuilder:validation:Required type: object + v1beta1.RateLimitConfig: + description: |- + RateLimitConfig contains the CRD rate limiting configuration. + When set, rate limiting middleware is added to the proxy middleware chain. + properties: + perUser: + $ref: '#/components/schemas/types.RateLimitBucket' + shared: + $ref: '#/components/schemas/types.RateLimitBucket' + tools: + description: |- + Tools defines per-tool rate limit overrides. + Each entry applies additional rate limits to calls targeting a specific tool name. + A request must pass both the server-level limit and the per-tool limit. + +listType=map + +listMapKey=name + +optional + items: + $ref: '#/components/schemas/types.ToolRateLimitConfig' + type: array + uniqueItems: false + type: object externalDocs: description: "" url: "" diff --git a/examples/operator/external-auth/mcpexternalauthconfig_private_ca.yaml b/examples/operator/external-auth/mcpexternalauthconfig_private_ca.yaml new file mode 100644 index 0000000000..e3f8b5d8d2 --- /dev/null +++ b/examples/operator/external-auth/mcpexternalauthconfig_private_ca.yaml @@ -0,0 +1,34 @@ +# Embedded auth server using a private CA for its OIDC upstream. +# Replace the placeholder certificate with the PEM-encoded CA certificate. +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: upstream-private-ca + namespace: default +data: + ca.crt: | + -----BEGIN CERTIFICATE----- + replace-with-your-private-ca-certificate + -----END CERTIFICATE----- + +--- +apiVersion: toolhive.stacklok.dev/v1beta1 +kind: MCPExternalAuthConfig +metadata: + name: private-ca-auth-server + namespace: default +spec: + type: embeddedAuthServer + embeddedAuthServer: + issuer: https://auth.example.com + upstreamProviders: + - name: corporate-idp + type: oidc + oidcConfig: + issuerUrl: https://idp.internal.example.com + clientId: toolhive-client + caBundleRef: + configMapRef: + name: upstream-private-ca + key: ca.crt diff --git a/pkg/auth/dcr/request.go b/pkg/auth/dcr/request.go index bc5cb1dc25..63094d9818 100644 --- a/pkg/auth/dcr/request.go +++ b/pkg/auth/dcr/request.go @@ -142,6 +142,9 @@ type Request struct { // so the resolver refuses to register as a public client. PublicClient bool + // CAFilePath is the path to a PEM CA bundle added to the system roots. + CAFilePath string + // AllowPrivateIPs permits both of the resolver's outbound calls — the // discovery fetch to DiscoveryURL and the registration POST to the // resolved registration endpoint — to connect to private IP ranges. For diff --git a/pkg/auth/dcr/resolver.go b/pkg/auth/dcr/resolver.go index 423f47394b..94992d76e0 100644 --- a/pkg/auth/dcr/resolver.go +++ b/pkg/auth/dcr/resolver.go @@ -458,7 +458,8 @@ func registerAndCache( registrationScopes := chooseRegistrationScopes(scopes, endpoints.scopesSupported, req.Issuer) response, err := performRegistration(ctx, req, endpoints.registrationEndpoint, - redirectURI, authMethod, registrationScopes, endpoints.registrationEndpointServerSupplied) + redirectURI, authMethod, registrationScopes, endpoints.registrationEndpointServerSupplied, + req.CAFilePath) if err != nil { return nil, newDCRStepError(dcrStepRegister, req.Issuer, redirectURI, err) } @@ -786,12 +787,14 @@ func performRegistration( registrationEndpoint, redirectURI, authMethod string, scopes []string, registrationEndpointServerSupplied bool, + caFilePath string, ) (*oauthproto.DynamicClientRegistrationResponse, error) { httpClient, err := newDCRHTTPClient( req.InitialAccessToken, registrationEndpoint, req.AllowPrivateIPs, req.ServerSuppliedEndpoints || registrationEndpointServerSupplied, + caFilePath, ) if err != nil { return nil, fmt.Errorf("dcr: build registration http client: %w", err) @@ -971,6 +974,7 @@ func resolveDCREndpoints( discoveryHost, req.AllowPrivateIPs, req.ServerSuppliedEndpoints, + req.CAFilePath, ) if err != nil { return nil, fmt.Errorf("dcr: build discovery http client: %w", err) @@ -1429,12 +1433,13 @@ var errDCRRedirectRefused = errors.New( func newDCRHTTPClient( initialAccessToken, registrationEndpoint string, allowPrivateIPs, serverSuppliedEndpoints bool, + caFilePath string, ) (*http.Client, error) { host, err := hostFromURL(registrationEndpoint) if err != nil { return nil, err } - client, err := newGuardedDCRClient(host, allowPrivateIPs, serverSuppliedEndpoints) + client, err := newGuardedDCRClient(host, allowPrivateIPs, serverSuppliedEndpoints, caFilePath) if err != nil { return nil, err } @@ -1470,13 +1475,16 @@ func newDCRHTTPClient( // the discovery client restricts them to the same host). The dial guard alone // does not stop a redirect to a different public host, so the redirect policy // is a required complement, not an optional one. -func newGuardedDCRClient(host string, allowPrivateIPs, serverSuppliedEndpoints bool) (*http.Client, error) { +func newGuardedDCRClient(host string, allowPrivateIPs, serverSuppliedEndpoints bool, caFilePath string) (*http.Client, error) { builder := func() *networking.HttpClientBuilder { if serverSuppliedEndpoints { return networking.NewServerSuppliedHostClientBuilder(host, allowPrivateIPs, false) } return networking.NewHostScopedClientBuilder(host, allowPrivateIPs, false) }() + if caFilePath != "" { + builder.WithSystemRootsPlusCABundle(caFilePath) + } return builder.WithDisableKeepAlives(true).Build() } diff --git a/pkg/authserver/config.go b/pkg/authserver/config.go index da03ed8f7e..e0de1dd80a 100644 --- a/pkg/authserver/config.go +++ b/pkg/authserver/config.go @@ -585,6 +585,9 @@ type OIDCUpstreamRunConfig struct { // stable per user (e.g. Entra/Azure AD's "oid"). See upstream.OIDCConfig. SubjectClaim string `json:"subject_claim,omitempty" yaml:"subject_claim,omitempty"` + // CAFilePath is the path to a PEM CA bundle added to the system roots. + CAFilePath string `json:"ca_file_path,omitempty" yaml:"ca_file_path,omitempty"` + // AllowPrivateIPs permits the OIDC discovery and token HTTP clients to // connect to private IP ranges (RFC-1918, link-local). Use only when the // upstream is hosted inside the same cluster and has no public endpoint. @@ -663,6 +666,9 @@ type OAuth2UpstreamRunConfig struct { // Mutually exclusive with ClientID. DCRConfig *DCRUpstreamConfig `json:"dcr_config,omitempty" yaml:"dcr_config,omitempty"` + // CAFilePath is the path to a PEM CA bundle added to the system roots. + CAFilePath string `json:"ca_file_path,omitempty" yaml:"ca_file_path,omitempty"` + // AllowPrivateIPs permits the upstream provider's HTTP client to connect to // private IP ranges (RFC-1918, link-local). When DCRConfig is set, this // also gates the DCR discovery and registration calls made on this diff --git a/pkg/authserver/runner/dcr_adapter.go b/pkg/authserver/runner/dcr_adapter.go index 4682129d92..0625d378cc 100644 --- a/pkg/authserver/runner/dcr_adapter.go +++ b/pkg/authserver/runner/dcr_adapter.go @@ -79,6 +79,7 @@ func newDCRRequest(rc *authserver.OAuth2UpstreamRunConfig, localIssuer string) ( AuthorizationEndpoint: rc.AuthorizationEndpoint, TokenEndpoint: rc.TokenEndpoint, InitialAccessToken: initialAccessToken, + CAFilePath: rc.CAFilePath, // Reuse the upstream's private-IP policy so the DCR discovery and // registration calls share the same SSRF posture as its token and // userinfo calls (see upstream.OAuth2Config.AllowPrivateIPs). diff --git a/pkg/authserver/runner/dcr_adapter_test.go b/pkg/authserver/runner/dcr_adapter_test.go index aa3bc5dbca..5ae3fa6aea 100644 --- a/pkg/authserver/runner/dcr_adapter_test.go +++ b/pkg/authserver/runner/dcr_adapter_test.go @@ -253,6 +253,7 @@ func TestNewDCRRequest(t *testing.T) { wantDiscoveryURL string wantRegistration string wantAllowPrivateIPs bool + wantCAFilePath string }{ { name: "discovery_url branch resolves file-based initial access token", @@ -292,11 +293,13 @@ func TestNewDCRRequest(t *testing.T) { RegistrationEndpoint: "https://idp.example.com/register", }, AllowPrivateIPs: true, + CAFilePath: "/var/run/toolhive/upstream-ca/ca.crt", }, localIssuer: "https://thv.example.com", wantIssuer: "https://thv.example.com", wantRegistration: "https://idp.example.com/register", wantAllowPrivateIPs: true, + wantCAFilePath: "/var/run/toolhive/upstream-ca/ca.crt", }, { name: "nil run-config rejected", @@ -327,6 +330,7 @@ func TestNewDCRRequest(t *testing.T) { assert.Equal(t, tc.wantDiscoveryURL, req.DiscoveryURL) assert.Equal(t, tc.wantRegistration, req.RegistrationEndpoint) assert.Equal(t, tc.wantAllowPrivateIPs, req.AllowPrivateIPs) + assert.Equal(t, tc.wantCAFilePath, req.CAFilePath) }) } } diff --git a/pkg/authserver/runner/embeddedauthserver.go b/pkg/authserver/runner/embeddedauthserver.go index 06fbc68621..08a0a38fd8 100644 --- a/pkg/authserver/runner/embeddedauthserver.go +++ b/pkg/authserver/runner/embeddedauthserver.go @@ -632,6 +632,7 @@ func buildOIDCConfig(rc *authserver.UpstreamRunConfig, insecureAllowHTTP bool) ( Issuer: oidc.IssuerURL, SubjectClaim: oidc.SubjectClaim, AllowPrivateIPs: oidc.AllowPrivateIPs, + CAFilePath: oidc.CAFilePath, InsecureAllowHTTP: insecureAllowHTTP || oidc.InsecureAllowHTTP, }, nil } @@ -667,6 +668,7 @@ func buildPureOAuth2Config(rc *authserver.UpstreamRunConfig, insecureAllowHTTP b AuthorizationEndpoint: oauth2.AuthorizationEndpoint, TokenEndpoint: oauth2.TokenEndpoint, UserInfo: convertUserInfoConfig(oauth2.UserInfo), + CAFilePath: oauth2.CAFilePath, AllowPrivateIPs: oauth2.AllowPrivateIPs, InsecureAllowHTTP: insecureAllowHTTP || oauth2.InsecureAllowHTTP, } diff --git a/pkg/authserver/upstream/oauth2.go b/pkg/authserver/upstream/oauth2.go index bb44b31643..12badd60dd 100644 --- a/pkg/authserver/upstream/oauth2.go +++ b/pkg/authserver/upstream/oauth2.go @@ -178,6 +178,9 @@ type OAuth2Config struct { // authoritative trust-model and uniqueness documentation. IdentityFromToken *IdentityFromTokenConfig `json:"identity_from_token,omitempty" yaml:"identity_from_token,omitempty"` + // CAFilePath is the path to a PEM CA bundle added to the system roots. + CAFilePath string `json:"ca_file_path,omitempty" yaml:"ca_file_path,omitempty"` + // InsecureAllowHTTP permits http:// authorization_endpoint and token_endpoint // URLs for non-localhost hosts. Set only for trusted in-cluster deployments. InsecureAllowHTTP bool `json:"insecure_allow_http,omitempty" yaml:"insecure_allow_http,omitempty"` @@ -313,7 +316,7 @@ func newBaseOAuth2Provider(config *OAuth2Config, hostForClient string) (*BaseOAu return nil, fmt.Errorf("invalid config: %w", err) } - httpClient, err := newHTTPClientForHost(hostForClient, config.AllowPrivateIPs, config.InsecureAllowHTTP) + httpClient, err := newHTTPClientForHost(hostForClient, config.AllowPrivateIPs, config.InsecureAllowHTTP, config.CAFilePath) if err != nil { return nil, fmt.Errorf("failed to create HTTP client: %w", err) } @@ -903,6 +906,10 @@ func formatOAuth2Error(err error, prefix string) error { // resolver's per-request-host, low-frequency calls don't have the same // trade-off, hence the difference. If this provider's threat model changes // (e.g. it starts dialing caller-varying hosts), revisit this decision. -func newHTTPClientForHost(host string, allowPrivateIPs, insecureAllowHTTP bool) (*http.Client, error) { - return networking.NewHostScopedClientBuilder(host, allowPrivateIPs, insecureAllowHTTP).Build() +func newHTTPClientForHost(host string, allowPrivateIPs, insecureAllowHTTP bool, caFilePath string) (*http.Client, error) { + builder := networking.NewHostScopedClientBuilder(host, allowPrivateIPs, insecureAllowHTTP) + if caFilePath != "" { + builder.WithSystemRootsPlusCABundle(caFilePath) + } + return builder.Build() } diff --git a/pkg/authserver/upstream/oauth2_test.go b/pkg/authserver/upstream/oauth2_test.go index d00b63afa2..5a635cf3af 100644 --- a/pkg/authserver/upstream/oauth2_test.go +++ b/pkg/authserver/upstream/oauth2_test.go @@ -2793,7 +2793,7 @@ func TestNewHTTPClientForHost(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - client, err := newHTTPClientForHost(tt.host, tt.allowPrivateIPs, false) + client, err := newHTTPClientForHost(tt.host, tt.allowPrivateIPs, false, "") require.NoError(t, err) require.NotNil(t, client) diff --git a/pkg/authserver/upstream/oidc.go b/pkg/authserver/upstream/oidc.go index 35e95853ea..742a6d5454 100644 --- a/pkg/authserver/upstream/oidc.go +++ b/pkg/authserver/upstream/oidc.go @@ -48,6 +48,9 @@ type OIDCConfig struct { // resolves through the same path and fails closed if the IdP drops it. SubjectClaim string `json:"subject_claim,omitempty" yaml:"subject_claim,omitempty"` + // CAFilePath is the path to a PEM CA bundle added to the system roots. + CAFilePath string `json:"ca_file_path,omitempty" yaml:"ca_file_path,omitempty"` + // AllowPrivateIPs permits the OIDC discovery and token HTTP clients to // connect to private IP ranges (RFC-1918, link-local). Use only when the // upstream is hosted inside the same cluster and has no public endpoint. @@ -178,7 +181,7 @@ func NewOIDCProvider( // Create HTTP client for the issuer host issuerURL, _ := url.Parse(config.Issuer) // Error already checked in ValidateWithInsecure() - httpClient, err := newHTTPClientForHost(issuerURL.Host, config.AllowPrivateIPs, config.InsecureAllowHTTP) + httpClient, err := newHTTPClientForHost(issuerURL.Host, config.AllowPrivateIPs, config.InsecureAllowHTTP, config.CAFilePath) if err != nil { return nil, fmt.Errorf("failed to create HTTP client: %w", err) } diff --git a/pkg/networking/http_client.go b/pkg/networking/http_client.go index 38f2908ddd..11fc6c2cc6 100644 --- a/pkg/networking/http_client.go +++ b/pkg/networking/http_client.go @@ -161,6 +161,7 @@ type HttpClientBuilder struct { tlsHandshakeTimeout time.Duration responseHeaderTimeout time.Duration caCertPath string + caCertUsesSystemRoots bool authTokenFile string allowPrivate bool insecureAllowHTTP bool @@ -221,9 +222,21 @@ func NewServerSuppliedHostClientBuilder(host string, allowPrivateIPs, insecureAl WithPrivateIPs(allowPrivateIPs) } -// WithCABundle sets the CA certificate bundle path +// WithCABundle sets a pinned CA certificate bundle path. When a bundle is +// configured, only certificates from that bundle are trusted (system roots are +// not included). func (b *HttpClientBuilder) WithCABundle(path string) *HttpClientBuilder { b.caCertPath = path + b.caCertUsesSystemRoots = false + return b +} + +// WithSystemRootsPlusCABundle sets a CA certificate bundle path and preserves +// trust in the system root pool. Use this when an upstream may use either a +// publicly trusted certificate or a private CA. +func (b *HttpClientBuilder) WithSystemRootsPlusCABundle(path string) *HttpClientBuilder { + b.caCertPath = path + b.caCertUsesSystemRoots = true return b } @@ -281,6 +294,16 @@ func (b *HttpClientBuilder) Build() (*http.Client, error) { } caCertPool := x509.NewCertPool() + if b.caCertUsesSystemRoots { + caCertPool, err = x509.SystemCertPool() + if err != nil { + return nil, fmt.Errorf("failed to load system CA certificate pool: %w", err) + } + if caCertPool == nil { + return nil, fmt.Errorf("failed to load system CA certificate pool: pool is nil") + } + } + if !caCertPool.AppendCertsFromPEM(caCert) { return nil, fmt.Errorf("failed to parse CA certificate bundle") } diff --git a/pkg/networking/http_client_test.go b/pkg/networking/http_client_test.go index 2f3b714f06..1c1609a93e 100644 --- a/pkg/networking/http_client_test.go +++ b/pkg/networking/http_client_test.go @@ -4,8 +4,14 @@ package networking import ( + "crypto/rand" + "crypto/rsa" "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" "io" + "math/big" "net/http" "net/http/httptest" "net/url" @@ -193,6 +199,66 @@ func TestHttpClientBuilder_WithCABundle(t *testing.T) { assert.Equal(t, path, builder.caCertPath) } +func TestHttpClientBuilder_CABundleTrustSemantics(t *testing.T) { + t.Parallel() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + certDER, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "ToolHive test CA"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + IsCA: true, + BasicConstraintsValid: true, + }, &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "ToolHive test CA"}, + IsCA: true, + BasicConstraintsValid: true, + }, &key.PublicKey, key) + require.NoError(t, err) + caPath := filepath.Join(t.TempDir(), "ca.crt") + require.NoError(t, os.WriteFile(caPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}), 0600)) + + tests := []struct { + name string + configure func(*HttpClientBuilder) + additive bool + }{ + { + name: "pinned custom bundle", + configure: func(builder *HttpClientBuilder) { builder.WithCABundle(caPath) }, + }, + { + name: "system roots plus custom bundle", + configure: func(builder *HttpClientBuilder) { builder.WithSystemRootsPlusCABundle(caPath) }, + additive: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + builder := NewHttpClientBuilder().WithPrivateIPs(true) + tt.configure(builder) + client, err := builder.Build() + require.NoError(t, err) + transport := client.Transport.(*ValidatingTransport).Transport.(*http.Transport) + require.NotNil(t, transport.TLSClientConfig) + require.NotNil(t, transport.TLSClientConfig.RootCAs) + + pinnedPool := x509.NewCertPool() + require.True(t, pinnedPool.AppendCertsFromPEM( + pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}))) + if tt.additive { + assert.False(t, transport.TLSClientConfig.RootCAs.Equal(pinnedPool)) + } else { + assert.True(t, transport.TLSClientConfig.RootCAs.Equal(pinnedPool)) + } + }) + } +} func TestHttpClientBuilder_WithTokenFromFile(t *testing.T) { t.Parallel()