Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 42 additions & 29 deletions internal/controller/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ func StartControllers(ctx context.Context, mgr manager.Manager, config *rest.Con
}

// ReferenceGrant controller for cross-namespace access validation
referenceGrantC := NewReferenceGrantController(c, logger.WithName("reference-grant"), aiGatewayRouteEventChan)
referenceGrantC := NewReferenceGrantController(c, logger.WithName("reference-grant"), aiGatewayRouteEventChan, backendSecurityPolicyEventChan)
if err = TypedControllerBuilderForCRD(mgr, &gwapiv1b1.ReferenceGrant{}).
Complete(referenceGrantC); err != nil {
return fmt.Errorf("failed to create controller for ReferenceGrant: %w", err)
Expand Down Expand Up @@ -472,37 +472,57 @@ func aiGatewayRouteIndexFunc(o client.Object) []string {
func backendSecurityPolicyIndexFunc(o client.Object) []string {
backendSecurityPolicy := o.(*aigv1b1.BackendSecurityPolicy)
var key string
switch backendSecurityPolicy.Spec.Type {
if name, namespace, ok := backendSecurityPolicySecretRef(backendSecurityPolicy); ok {
key = fmt.Sprintf("%s.%s", name, namespace)
} else {
switch backendSecurityPolicy.Spec.Type {
case aigv1b1.BackendSecurityPolicyTypeAWSCredentials:
if backendSecurityPolicy.Spec.AWSCredentials.OIDCExchangeToken != nil {
key = backendSecurityPolicyKey(backendSecurityPolicy.Namespace, backendSecurityPolicy.Name)
}
case aigv1b1.BackendSecurityPolicyTypeAzureCredentials:
if backendSecurityPolicy.Spec.AzureCredentials.OIDCExchangeToken != nil {
key = backendSecurityPolicyKey(backendSecurityPolicy.Namespace, backendSecurityPolicy.Name)
}
}
}
return []string{key}
}

// backendSecurityPolicySecretRef returns the name and namespace of the Secret directly referenced by a
// BackendSecurityPolicy's static credential fields, and whether one is set. OIDC-based credential types
// don't have a fixed target Secret here (see backendSecurityPolicyIndexFunc, which keys those by the
// BackendSecurityPolicy itself instead) and are excluded.
func backendSecurityPolicySecretRef(bsp *aigv1b1.BackendSecurityPolicy) (name, namespace string, ok bool) {
var secretRef *gwapiv1.SecretObjectReference
switch bsp.Spec.Type {
case aigv1b1.BackendSecurityPolicyTypeAPIKey:
apiKey := backendSecurityPolicy.Spec.APIKey
key = getSecretNameAndNamespace(apiKey.SecretRef, backendSecurityPolicy.Namespace)
secretRef = bsp.Spec.APIKey.SecretRef
case aigv1b1.BackendSecurityPolicyTypeAWSCredentials:
awsCreds := backendSecurityPolicy.Spec.AWSCredentials
if awsCreds.CredentialsFile != nil {
key = getSecretNameAndNamespace(awsCreds.CredentialsFile.SecretRef, backendSecurityPolicy.Namespace)
} else if awsCreds.OIDCExchangeToken != nil {
key = backendSecurityPolicyKey(backendSecurityPolicy.Namespace, backendSecurityPolicy.Name)
if bsp.Spec.AWSCredentials.CredentialsFile != nil {
secretRef = bsp.Spec.AWSCredentials.CredentialsFile.SecretRef
}
case aigv1b1.BackendSecurityPolicyTypeGCPCredentials:
gcpCreds := backendSecurityPolicy.Spec.GCPCredentials
if gcpCreds.CredentialsFile != nil {
key = getSecretNameAndNamespace(gcpCreds.CredentialsFile.SecretRef, backendSecurityPolicy.Namespace)
if bsp.Spec.GCPCredentials.CredentialsFile != nil {
secretRef = bsp.Spec.GCPCredentials.CredentialsFile.SecretRef
}
case aigv1b1.BackendSecurityPolicyTypeAzureAPIKey:
apiKey := backendSecurityPolicy.Spec.AzureAPIKey
key = getSecretNameAndNamespace(apiKey.SecretRef, backendSecurityPolicy.Namespace)
secretRef = bsp.Spec.AzureAPIKey.SecretRef
case aigv1b1.BackendSecurityPolicyTypeAnthropicAPIKey:
apiKey := backendSecurityPolicy.Spec.AnthropicAPIKey
key = getSecretNameAndNamespace(apiKey.SecretRef, backendSecurityPolicy.Namespace)
secretRef = bsp.Spec.AnthropicAPIKey.SecretRef
case aigv1b1.BackendSecurityPolicyTypeAzureCredentials:
azureCreds := backendSecurityPolicy.Spec.AzureCredentials
if azureCreds.ClientSecretRef != nil {
key = getSecretNameAndNamespace(azureCreds.ClientSecretRef, backendSecurityPolicy.Namespace)
} else if azureCreds.OIDCExchangeToken != nil {
key = backendSecurityPolicyKey(backendSecurityPolicy.Namespace, backendSecurityPolicy.Name)
if bsp.Spec.AzureCredentials.ClientSecretRef != nil {
secretRef = bsp.Spec.AzureCredentials.ClientSecretRef
}
}
return []string{key}
if secretRef == nil {
return "", "", false
}
namespace = bsp.Namespace
if secretRef.Namespace != nil {
namespace = string(*secretRef.Namespace)
}
return string(secretRef.Name), namespace, true
}

func backendSecurityPolicyTargetRefsIndexFunc(o client.Object) []string {
Expand All @@ -523,13 +543,6 @@ func quotaPolicyTargetRefsIndexFunc(o client.Object) []string {
return ret
}

func getSecretNameAndNamespace(secretRef *gwapiv1.SecretObjectReference, namespace string) string {
if secretRef.Namespace != nil {
return fmt.Sprintf("%s.%s", secretRef.Name, *secretRef.Namespace)
}
return fmt.Sprintf("%s.%s", secretRef.Name, namespace)
}

func getReferenceGrantIndexKey(namespace, kind string) string {
return fmt.Sprintf("%s.%s", namespace, kind)
}
Expand Down
10 changes: 0 additions & 10 deletions internal/controller/controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -257,16 +257,6 @@ func Test_backendSecurityPolicyIndexFunc(t *testing.T) {
}
}

func Test_getSecretNameAndNamespace(t *testing.T) {
secretRef := &gwapiv1.SecretObjectReference{
Name: "mysecret",
Namespace: ptr.To[gwapiv1.Namespace]("default"),
}
require.Equal(t, "mysecret.default", getSecretNameAndNamespace(secretRef, "foo"))
secretRef.Namespace = nil
require.Equal(t, "mysecret.foo", getSecretNameAndNamespace(secretRef, "foo"))
}

func Test_referenceGrantToTargetKindIndexFunc(t *testing.T) {
tests := []struct {
name string
Expand Down
35 changes: 28 additions & 7 deletions internal/controller/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,14 @@ func NewGatewayController(
uf = uuid.NewString
}
return &GatewayController{
client: client,
kube: kube,
logger: logger,
envoyGatewayNamespace: envoyGatewayNamespace,
standAlone: standAlone,
uuidFn: uf,
extProcBuilder: newExtProcBuilder(options, extProcAsSideCar, logger),
client: client,
kube: kube,
logger: logger,
envoyGatewayNamespace: envoyGatewayNamespace,
standAlone: standAlone,
uuidFn: uf,
extProcBuilder: newExtProcBuilder(options, extProcAsSideCar, logger),
referenceGrantValidator: newReferenceGrantValidator(client),
}
}

Expand All @@ -83,6 +84,9 @@ type GatewayController struct {
// extProcBuilder is shared with the mutating webhook so the template hash
// computed here matches the extproc container injected by the webhook.
*extProcBuilder
// referenceGrantValidator authorizes cross-namespace AIServiceBackend/InferencePool
// references (and their BackendSecurityPolicy credentials) via Gateway API ReferenceGrant.
referenceGrantValidator *referenceGrantValidator
}

// Reconcile implements the reconcile.Reconciler for gwapiv1.Gateway.
Expand Down Expand Up @@ -483,6 +487,23 @@ func (c *GatewayController) reconcileFilterConfigSecret(
var bsp *aigv1b1.BackendSecurityPolicy
backendNamespace := backendRef.GetNamespace(aiGatewayRoute.Namespace)

if backendRef.IsCrossNamespace(aiGatewayRoute.Namespace) {
var rgErr error
if backendRef.IsInferencePool() {
rgErr = c.referenceGrantValidator.validateInferencePoolReference(
ctx, aiGatewayRoute.Namespace, backendNamespace, backendRef.Name)
} else {
rgErr = c.referenceGrantValidator.validateAIServiceBackendReference(
ctx, aiGatewayRoute.Namespace, backendNamespace, backendRef.Name)
}
if rgErr != nil {
c.logger.Error(rgErr, "cross-namespace backendRef rejected: no valid ReferenceGrant. Skipping this backend.",
"backend_name", backendRef.Name, "aigatewayroute", aiGatewayRoute.Name,
"namespace", backendNamespace)
continue
}
}

if backendRef.IsInferencePool() {
// We assume that InferencePools are all OpenAI schema.
b.Schema = filterapi.VersionedAPISchema{
Expand Down
150 changes: 146 additions & 4 deletions internal/controller/gateway_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import (
"sigs.k8s.io/controller-runtime/pkg/log/zap"
gwapiv1 "sigs.k8s.io/gateway-api/apis/v1"
gwapiv1a2 "sigs.k8s.io/gateway-api/apis/v1alpha2"
gwapiv1b1 "sigs.k8s.io/gateway-api/apis/v1beta1"

aigv1b1 "github.com/envoyproxy/ai-gateway/api/v1beta1"
"github.com/envoyproxy/ai-gateway/internal/controller/rotators"
Expand Down Expand Up @@ -500,6 +501,149 @@ func TestGatewayController_reconcileFilterConfigSecret(t *testing.T) {
}
}

// GatewayController must not include a cross-namespace AIServiceBackend/InferencePool
// (and its BackendSecurityPolicy credentials) in the extproc filter config unless a ReferenceGrant permits it.
func TestGatewayController_reconcileFilterConfigSecret_CrossNamespaceReferenceGrant(t *testing.T) {
const (
routeNamespace = "tenant-a"
backendNamespace = "tenant-b"
)

newRoute := func(kind, name string) []aigv1b1.AIGatewayRoute {
group := aiServiceBackendGroup
if kind == "InferencePool" {
group = inferencePoolGroup
}
return []aigv1b1.AIGatewayRoute{
{
ObjectMeta: metav1.ObjectMeta{Name: "route1", Namespace: routeNamespace},
Spec: aigv1b1.AIGatewayRouteSpec{
Rules: []aigv1b1.AIGatewayRouteRule{
{
BackendRefs: []aigv1b1.AIGatewayRouteRuleBackendRef{
{
Name: name,
Namespace: ptr.To(gwapiv1.Namespace(backendNamespace)),
Group: ptr.To(group),
Kind: ptr.To(kind),
},
},
},
},
},
},
}
}

referenceGrant := func(fromGroup, fromKind, toGroup, toKind string) *gwapiv1b1.ReferenceGrant {
return &gwapiv1b1.ReferenceGrant{
ObjectMeta: metav1.ObjectMeta{Name: "allow", Namespace: backendNamespace},
Spec: gwapiv1b1.ReferenceGrantSpec{
From: []gwapiv1b1.ReferenceGrantFrom{{Group: gwapiv1b1.Group(fromGroup), Kind: gwapiv1b1.Kind(fromKind), Namespace: routeNamespace}},
To: []gwapiv1b1.ReferenceGrantTo{{Group: gwapiv1b1.Group(toGroup), Kind: gwapiv1b1.Kind(toKind)}},
},
}
}

setup := func(t *testing.T, grant *gwapiv1b1.ReferenceGrant) (*GatewayController, kubernetes.Interface) {
t.Helper()
fakeClient := requireNewFakeClientWithIndexes(t)
kube := fake2.NewClientset()
c := newTestGatewayController(fakeClient, kube, ctrl.Log, "envoy-gateway-system",
"docker.io/envoyproxy/ai-gateway-extproc:latest", "info", false, nil, true)

require.NoError(t, fakeClient.Create(t.Context(), &aigv1b1.AIServiceBackend{
ObjectMeta: metav1.ObjectMeta{Name: "backend1", Namespace: backendNamespace},
Spec: aigv1b1.AIServiceBackendSpec{
BackendRef: gwapiv1.BackendObjectReference{Name: "some-backend", Namespace: ptr.To[gwapiv1.Namespace](backendNamespace)},
},
}))
require.NoError(t, fakeClient.Create(t.Context(), &aigv1b1.BackendSecurityPolicy{
ObjectMeta: metav1.ObjectMeta{Name: "bsp", Namespace: backendNamespace},
Spec: aigv1b1.BackendSecurityPolicySpec{
Type: aigv1b1.BackendSecurityPolicyTypeAPIKey,
APIKey: &aigv1b1.BackendSecurityPolicyAPIKey{
SecretRef: &gwapiv1.SecretObjectReference{Name: "api-key-secret"},
},
TargetRefs: []gwapiv1a2.LocalPolicyTargetReference{
{Kind: "AIServiceBackend", Group: "aigateway.envoyproxy.io", Name: "backend1"},
},
},
}))
_, err := kube.CoreV1().Secrets(backendNamespace).Create(t.Context(), &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "api-key-secret", Namespace: backendNamespace},
Data: map[string][]byte{apiKeyInSecret: []byte("supersecret")},
}, metav1.CreateOptions{})
require.NoError(t, err)

if grant != nil {
require.NoError(t, fakeClient.Create(t.Context(), grant))
}
return c, kube
}

t.Run("AIServiceBackend cross-namespace without ReferenceGrant is rejected", func(t *testing.T) {
c, kube := setup(t, nil)
const someNamespace = "some-namespace"
_, err := c.reconcileFilterConfigSecret(t.Context(), "gw", routeNamespace, someNamespace,
newRoute("AIServiceBackend", "backend1"), nil, "uuid", nil, nil)
require.NoError(t, err)

fc := requireFilterConfigFromBundle(t, kube, someNamespace, "gw", routeNamespace)
require.Empty(t, fc.Backends, "cross-namespace backend without a ReferenceGrant must not be wired into the filter config")
})

t.Run("AIServiceBackend cross-namespace with ReferenceGrant is allowed", func(t *testing.T) {
grant := referenceGrant(aiServiceBackendGroup, aiGatewayRouteKind, aiServiceBackendGroup, aiServiceBackendKind)
c, kube := setup(t, grant)
const someNamespace = "some-namespace"
_, err := c.reconcileFilterConfigSecret(t.Context(), "gw", routeNamespace, someNamespace,
newRoute("AIServiceBackend", "backend1"), nil, "uuid", nil, nil)
require.NoError(t, err)

fc := requireFilterConfigFromBundle(t, kube, someNamespace, "gw", routeNamespace)
require.Len(t, fc.Backends, 1)
require.NotNil(t, fc.Backends[0].Auth)
require.NotNil(t, fc.Backends[0].Auth.APIKey)
require.Equal(t, "supersecret", fc.Backends[0].Auth.APIKey.Key)
})

t.Run("InferencePool cross-namespace without ReferenceGrant is rejected", func(t *testing.T) {
c, kube := setup(t, nil)
const someNamespace = "some-namespace"
_, err := c.reconcileFilterConfigSecret(t.Context(), "gw", routeNamespace, someNamespace,
newRoute("InferencePool", "pool1"), nil, "uuid", nil, nil)
require.NoError(t, err)

fc := requireFilterConfigFromBundle(t, kube, someNamespace, "gw", routeNamespace)
require.Empty(t, fc.Backends, "cross-namespace inference pool without a ReferenceGrant must not be wired into the filter config")
})

t.Run("InferencePool cross-namespace with ReferenceGrant is allowed", func(t *testing.T) {
grant := referenceGrant(aiServiceBackendGroup, aiGatewayRouteKind, inferencePoolGroup, inferencePoolKind)
c, kube := setup(t, grant)

// Re-target the existing BSP at the InferencePool instead of the AIServiceBackend.
var bsp aigv1b1.BackendSecurityPolicy
require.NoError(t, c.client.Get(t.Context(), client.ObjectKey{Namespace: backendNamespace, Name: "bsp"}, &bsp))
bsp.Spec.TargetRefs = []gwapiv1a2.LocalPolicyTargetReference{
{Kind: "InferencePool", Group: "inference.networking.k8s.io", Name: "pool1"},
}
require.NoError(t, c.client.Update(t.Context(), &bsp))

const someNamespace = "some-namespace"
_, err := c.reconcileFilterConfigSecret(t.Context(), "gw", routeNamespace, someNamespace,
newRoute("InferencePool", "pool1"), nil, "uuid", nil, nil)
require.NoError(t, err)

fc := requireFilterConfigFromBundle(t, kube, someNamespace, "gw", routeNamespace)
require.Len(t, fc.Backends, 1)
require.NotNil(t, fc.Backends[0].Auth)
require.NotNil(t, fc.Backends[0].Auth.APIKey)
require.Equal(t, "supersecret", fc.Backends[0].Auth.APIKey.Key)
})
}

// TestGatewayController_reconcileFilterConfigSecret_HostnameScopedModels verifies that mixing routes
// with and without Spec.Hostnames produces a filter config where:
// - each per-host list contains the host's own models AND every unscoped model (so the unscoped
Expand Down Expand Up @@ -3332,10 +3476,8 @@ func Test_mcpConfig_ForwardHeaders(t *testing.T) {
require.Empty(t, backendB.ForwardHeaders)
}

// Test_mcpConfig_Authorization_VerifiedJWT is a regression test for GHSA-9mp9-4hr2-835f
// (MCPRoute CEL Authorization Bypass via Unverified JWT Claims). The CRD's XValidation rule
// only requires oauth when a rule's source.jwt is set; a CEL expression referencing
// request.auth.jwt.claims/scopes is accepted without oauth configured. mcpConfig must still
// The CRD's XValidation rule only requires oauth when a rule's source.jwt is set; a CEL expression
// referencing request.auth.jwt.claims/scopes is accepted without oauth configured. mcpConfig must still
// mark the resulting filterapi.MCPRouteAuthorization as VerifiedJWT: false in that case, so
// the mcpproxy never trusts an attacker-forged bearer JWT for such a rule.
func Test_mcpConfig_Authorization_VerifiedJWT(t *testing.T) {
Expand Down
Loading
Loading