From 2bdb2e540fb7c01ed9b2a058fd70c6c887018bec Mon Sep 17 00:00:00 2001 From: Matt Mencel Date: Thu, 11 Sep 2025 22:18:21 -0500 Subject: [PATCH 1/4] feat: add Azure Managed Identity token provider support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds comprehensive support for Azure Managed Identity authentication for AI Gateway backends through the BackendSecurityPolicy. Changes include: - New AzureManagedIdentityTokenProvider with system and user-assigned identity support - Support for OIDC token exchange and Kubernetes secret-based authentication - Comprehensive test coverage including integration tests - Example configurations and CRD validation test data - Updated API documentation and CRD schemas - Added *.test pattern to .gitignore to exclude Go test binaries 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude Signed-off-by: Matt Mencel merge from upstream --- .gitignore | 5 + api/v1alpha1/backendsecurity_policy.go | 24 ++- api/v1alpha1/zz_generated.deepcopy.go | 5 + .../basic/azure_openai_managed_identity.yaml | 117 ++++++++++++ .../controller/backend_security_policy.go | 11 +- .../backend_security_policy_test.go | 149 +++++++++++++++ .../azure_managed_identity_token_provider.go | 89 +++++++++ ...re_managed_identity_token_provider_test.go | 112 ++++++++++++ ...envoyproxy.io_backendsecuritypolicies.yaml | 31 +++- site/docs/api/api.mdx | 14 +- .../controller/azure_managed_identity_test.go | 172 ++++++++++++++++++ tests/crdcel/main_test.go | 17 +- ...zure_managed_identity_system_assigned.yaml | 16 ++ .../azure_managed_identity_user_assigned.yaml | 17 ++ .../azure_managed_identity_with_oidc.yaml | 24 +++ .../azure_managed_identity_with_secret.yaml | 19 ++ 16 files changed, 800 insertions(+), 22 deletions(-) create mode 100644 examples/basic/azure_openai_managed_identity.yaml create mode 100644 internal/controller/tokenprovider/azure_managed_identity_token_provider.go create mode 100644 internal/controller/tokenprovider/azure_managed_identity_token_provider_test.go create mode 100644 tests/controller/azure_managed_identity_test.go create mode 100644 tests/crdcel/testdata/backendsecuritypolicies/azure_managed_identity_system_assigned.yaml create mode 100644 tests/crdcel/testdata/backendsecuritypolicies/azure_managed_identity_user_assigned.yaml create mode 100644 tests/crdcel/testdata/backendsecuritypolicies/azure_managed_identity_with_oidc.yaml create mode 100644 tests/crdcel/testdata/backendsecuritypolicies/azure_managed_identity_with_secret.yaml diff --git a/.gitignore b/.gitignore index a9470ff28d..85438b5ab3 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,11 @@ inference-extension-conformance-test-report.yaml .claude/ .env .mcp.json +.serena/ +CLAUDE.md .goose /aigw + +# Go test binaries +*.test diff --git a/api/v1alpha1/backendsecurity_policy.go b/api/v1alpha1/backendsecurity_policy.go index 4abd60900a..a7b0455932 100644 --- a/api/v1alpha1/backendsecurity_policy.go +++ b/api/v1alpha1/backendsecurity_policy.go @@ -232,16 +232,20 @@ type BackendSecurityPolicyGCPCredentials struct { } // BackendSecurityPolicyAzureCredentials contains the supported authentication mechanisms to access Azure. -// Only one of ClientSecretRef or OIDCExchangeToken must be specified. Credentials will not be generated if -// neither are set. +// One of ClientSecretRef, OIDCExchangeToken, or UseManagedIdentity must be specified. +// When UseManagedIdentity is true, neither ClientSecretRef nor OIDCExchangeToken should be set. +// Otherwise, exactly one of ClientSecretRef or OIDCExchangeToken must be specified. // -// +kubebuilder:validation:XValidation:rule="(has(self.clientSecretRef) && !has(self.oidcExchangeToken)) || (!has(self.clientSecretRef) && has(self.oidcExchangeToken))",message="Exactly one of clientSecretRef or oidcExchangeToken must be specified" +// +kubebuilder:validation:XValidation:rule="has(self.useManagedIdentity) && self.useManagedIdentity ? (!has(self.clientSecretRef) && !has(self.oidcExchangeToken)) : ((has(self.clientSecretRef) && !has(self.oidcExchangeToken)) || (!has(self.clientSecretRef) && has(self.oidcExchangeToken)))",message="When useManagedIdentity is true, clientSecretRef and oidcExchangeToken must not be specified. Otherwise, exactly one of clientSecretRef or oidcExchangeToken must be specified" +// +kubebuilder:validation:XValidation:rule="has(self.useManagedIdentity) && self.useManagedIdentity && !has(self.clientID) ? true : has(self.clientID)",message="clientID is optional for system-assigned managed identity but required otherwise" type BackendSecurityPolicyAzureCredentials struct { // ClientID is a unique identifier for an application in Azure. + // This field is optional when using system-assigned managed identity, + // but required for user-assigned managed identity and other authentication methods. // - // +kubebuilder:validation:Required + // +optional // +kubebuilder:validation:MinLength=1 - ClientID string `json:"clientID"` + ClientID string `json:"clientID,omitempty"` // TenantId is a unique identifier for an Azure Active Directory instance. // @@ -261,6 +265,16 @@ type BackendSecurityPolicyAzureCredentials struct { // // +optional OIDCExchangeToken *AzureOIDCExchangeToken `json:"oidcExchangeToken,omitempty"` + + // UseManagedIdentity enables Azure Managed Identity authentication. + // When set to true, the gateway will use DefaultAzureCredential which supports: + // - Environment variables (AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_CLIENT_SECRET, AZURE_FEDERATED_TOKEN_FILE) + // - AKS Workload Identity (via service account annotations) + // - System-assigned managed identity (when clientID is not specified) + // - User-assigned managed identity (when clientID is specified) + // + // +optional + UseManagedIdentity *bool `json:"useManagedIdentity,omitempty"` } // AzureOIDCExchangeToken specifies credentials to obtain oidc token from a sso server. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index d2d42ae93a..fab7b142b2 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -580,6 +580,11 @@ func (in *BackendSecurityPolicyAzureCredentials) DeepCopyInto(out *BackendSecuri *out = new(AzureOIDCExchangeToken) (*in).DeepCopyInto(*out) } + if in.UseManagedIdentity != nil { + in, out := &in.UseManagedIdentity, &out.UseManagedIdentity + *out = new(bool) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BackendSecurityPolicyAzureCredentials. diff --git a/examples/basic/azure_openai_managed_identity.yaml b/examples/basic/azure_openai_managed_identity.yaml new file mode 100644 index 0000000000..fba52a6438 --- /dev/null +++ b/examples/basic/azure_openai_managed_identity.yaml @@ -0,0 +1,117 @@ +# Copyright Envoy AI Gateway Authors +# SPDX-License-Identifier: Apache-2.0 +# The full text of the Apache license is available in the LICENSE file at +# the root of the repo. + +# This example demonstrates Azure OpenAI integration using Managed Identity authentication. +# +# Prerequisites for AKS Workload Identity: +# 1. Enable OIDC Issuer and Workload Identity on your AKS cluster +# 2. Create a User-Assigned Managed Identity in Azure +# 3. Grant the Managed Identity "Cognitive Services OpenAI User" role on your Azure OpenAI resource +# 4. Create a federated identity credential associating the managed identity with your Kubernetes service account +# 5. Annotate your Kubernetes service account with the managed identity client ID +# +# Example service account: +# apiVersion: v1 +# kind: ServiceAccount +# metadata: +# name: ai-gateway-sa +# namespace: envoy-gateway-system +# annotations: +# azure.workload.identity/client-id: "YOUR_USER_ASSIGNED_IDENTITY_CLIENT_ID" +# +# Then configure your gateway deployment to use this service account. + +--- +apiVersion: aigateway.envoyproxy.io/v1alpha1 +kind: AIGatewayRoute +metadata: + name: envoy-ai-gateway-basic-azure-mi + namespace: default +spec: + parentRefs: + - name: envoy-ai-gateway-basic + kind: Gateway + group: gateway.networking.k8s.io + rules: + - matches: + - headers: + - type: Exact + name: x-ai-eg-model + value: gpt-4o-preview + backendRefs: + - name: envoy-ai-gateway-basic-azure-mi +--- +apiVersion: aigateway.envoyproxy.io/v1alpha1 +kind: AIServiceBackend +metadata: + name: envoy-ai-gateway-basic-azure-mi + namespace: default +spec: + schema: + name: AzureOpenAI + version: 2025-01-01-preview + backendRef: + name: envoy-ai-gateway-basic-azure-mi + kind: Backend + group: gateway.envoyproxy.io +--- +# User-Assigned Managed Identity Example +apiVersion: aigateway.envoyproxy.io/v1alpha1 +kind: BackendSecurityPolicy +metadata: + name: envoy-ai-gateway-basic-azure-mi-credentials + namespace: default +spec: + targetRefs: + - group: aigateway.envoyproxy.io + kind: AIServiceBackend + name: envoy-ai-gateway-basic-azure-mi + type: AzureCredentials + azureCredentials: + clientID: YOUR_USER_ASSIGNED_IDENTITY_CLIENT_ID # Replace with your User-Assigned Managed Identity Client ID + tenantID: YOUR_AZURE_TENANT_ID # Replace with your Azure Tenant ID + useManagedIdentity: true +--- +# Alternative: System-Assigned Managed Identity Example (uncomment and use instead of above) +# apiVersion: aigateway.envoyproxy.io/v1alpha1 +# kind: BackendSecurityPolicy +# metadata: +# name: envoy-ai-gateway-basic-azure-mi-credentials +# namespace: default +# spec: +# targetRefs: +# - group: aigateway.envoyproxy.io +# kind: AIServiceBackend +# name: envoy-ai-gateway-basic-azure-mi +# type: AzureCredentials +# azureCredentials: +# # No clientID specified for system-assigned managed identity +# tenantID: YOUR_AZURE_TENANT_ID # Replace with your Azure Tenant ID +# useManagedIdentity: true +--- +apiVersion: gateway.envoyproxy.io/v1alpha1 +kind: Backend +metadata: + name: envoy-ai-gateway-basic-azure-mi + namespace: default +spec: + endpoints: + - fqdn: + hostname: your-azure-openai-resource.openai.azure.com # Replace with your Azure OpenAI resource + port: 443 +--- +apiVersion: gateway.networking.k8s.io/v1alpha3 +kind: BackendTLSPolicy +metadata: + name: envoy-ai-gateway-basic-azure-mi-tls + namespace: default +spec: + targetRefs: + - group: 'gateway.envoyproxy.io' + kind: Backend + name: envoy-ai-gateway-basic-azure-mi + validation: + wellKnownCACertificates: "System" + hostname: your-azure-openai-resource.openai.azure.com # Replace with your Azure OpenAI resource diff --git a/internal/controller/backend_security_policy.go b/internal/controller/backend_security_policy.go index 1a472eea62..c3a77e541d 100644 --- a/internal/controller/backend_security_policy.go +++ b/internal/controller/backend_security_policy.go @@ -136,8 +136,13 @@ func (c *BackendSecurityPolicyController) rotateCredential(ctx context.Context, var provider tokenprovider.TokenProvider options := policy.TokenRequestOptions{Scopes: []string{azureScopeURL}} - oidc := getBackendSecurityPolicyAuthOIDC(bsp.Spec) - if oidc != nil { + if bsp.Spec.AzureCredentials.UseManagedIdentity != nil && *bsp.Spec.AzureCredentials.UseManagedIdentity { + // Use managed identity authentication (DefaultAzureCredential). + provider, err = tokenprovider.NewAzureManagedIdentityTokenProvider(ctx, clientID, options) + if err != nil { + return ctrl.Result{}, err + } + } else if oidc := getBackendSecurityPolicyAuthOIDC(bsp.Spec); oidc != nil { var oidcProvider tokenprovider.TokenProvider oidcProvider, err = tokenprovider.NewOidcTokenProvider(ctx, c.client, oidc) if err != nil { @@ -169,7 +174,7 @@ func (c *BackendSecurityPolicyController) rotateCredential(ctx context.Context, return ctrl.Result{}, err } } else { - return ctrl.Result{}, fmt.Errorf("one of secret ref or oidc must be defined, namespace %s name %s", bsp.Namespace, bsp.Name) + return ctrl.Result{}, fmt.Errorf("one of secret ref, oidc, or managed identity must be defined, namespace %s name %s", bsp.Namespace, bsp.Name) } rotator, err = rotators.NewAzureTokenRotator(c.client, c.kube, c.logger, bsp.Namespace, bsp.Name, preRotationWindow, provider) diff --git a/internal/controller/backend_security_policy_test.go b/internal/controller/backend_security_policy_test.go index 7e782b2d39..028cf1c171 100644 --- a/internal/controller/backend_security_policy_test.go +++ b/internal/controller/backend_security_policy_test.go @@ -1270,3 +1270,152 @@ func TestGetBSPGeneratedSecretName(t *testing.T) { }) } } + +func TestBackendSecurityPolicyController_ManagedIdentity(t *testing.T) { + t.Run("Azure managed identity authentication", func(t *testing.T) { + eventCh := internaltesting.NewControllerEventChan[*aigv1a1.AIServiceBackend]() + fakeClient := requireNewFakeClientWithIndexes(t) + c := NewBackendSecurityPolicyController(fakeClient, fake2.NewClientset(), ctrl.Log, eventCh.Ch) + + namespace := "default" + bspName := "azure-mi-bsp" + asbName := "azure-mi-asb" + + // Create AIServiceBackend. + asb := &aigv1a1.AIServiceBackend{ + ObjectMeta: metav1.ObjectMeta{ + Name: asbName, + Namespace: namespace, + }, + } + require.NoError(t, fakeClient.Create(context.Background(), asb)) + + // Create BackendSecurityPolicy with user-assigned managed identity. + bsp := &aigv1a1.BackendSecurityPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: bspName, + Namespace: namespace, + }, + Spec: aigv1a1.BackendSecurityPolicySpec{ + TargetRefs: []gwapiv1a2.LocalPolicyTargetReference{ + { + Group: "aigateway.envoyproxy.io", + Kind: "AIServiceBackend", + Name: gwapiv1.ObjectName(asbName), + }, + }, + Type: aigv1a1.BackendSecurityPolicyTypeAzureCredentials, + AzureCredentials: &aigv1a1.BackendSecurityPolicyAzureCredentials{ + ClientID: "test-user-assigned-mi-client-id", + TenantID: "test-tenant-id", + UseManagedIdentity: ptr.To(true), + }, + }, + } + require.NoError(t, fakeClient.Create(context.Background(), bsp)) + + // Reconcile - expect it to succeed (token provider creation will fail but that's expected in test). + res, err := c.Reconcile(context.Background(), reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: bspName, + Namespace: namespace, + }, + }) + require.NoError(t, err) + require.Positive(t, res.RequeueAfter) // Should requeue for token rotation. + + // Verify AIServiceBackend event was sent. + select { + case event := <-eventCh.Ch: + receivedASB := event.Object.(*aigv1a1.AIServiceBackend) + require.Equal(t, asbName, receivedASB.Name) + require.Equal(t, namespace, receivedASB.Namespace) + case <-time.After(time.Second): + t.Fatal("expected AIServiceBackend event") + } + }) + + t.Run("Azure system-assigned managed identity authentication", func(t *testing.T) { + eventCh := internaltesting.NewControllerEventChan[*aigv1a1.AIServiceBackend]() + fakeClient := requireNewFakeClientWithIndexes(t) + c := NewBackendSecurityPolicyController(fakeClient, fake2.NewClientset(), ctrl.Log, eventCh.Ch) + + namespace := "default" + bspName := "azure-system-mi-bsp" + asbName := "azure-system-mi-asb" + + // Create AIServiceBackend. + asb := &aigv1a1.AIServiceBackend{ + ObjectMeta: metav1.ObjectMeta{ + Name: asbName, + Namespace: namespace, + }, + } + require.NoError(t, fakeClient.Create(context.Background(), asb)) + + // Create BackendSecurityPolicy with system-assigned managed identity (no clientID). + bsp := &aigv1a1.BackendSecurityPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: bspName, + Namespace: namespace, + }, + Spec: aigv1a1.BackendSecurityPolicySpec{ + TargetRefs: []gwapiv1a2.LocalPolicyTargetReference{ + { + Group: "aigateway.envoyproxy.io", + Kind: "AIServiceBackend", + Name: gwapiv1.ObjectName(asbName), + }, + }, + Type: aigv1a1.BackendSecurityPolicyTypeAzureCredentials, + AzureCredentials: &aigv1a1.BackendSecurityPolicyAzureCredentials{ + // No ClientID for system-assigned managed identity. + TenantID: "test-tenant-id", + UseManagedIdentity: ptr.To(true), + }, + }, + } + require.NoError(t, fakeClient.Create(context.Background(), bsp)) + + // Reconcile - expect it to succeed. + res, err := c.Reconcile(context.Background(), reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: bspName, + Namespace: namespace, + }, + }) + require.NoError(t, err) + require.Positive(t, res.RequeueAfter) // Should requeue for token rotation. + + // Verify AIServiceBackend event was sent. + select { + case event := <-eventCh.Ch: + receivedASB := event.Object.(*aigv1a1.AIServiceBackend) + require.Equal(t, asbName, receivedASB.Name) + require.Equal(t, namespace, receivedASB.Namespace) + case <-time.After(time.Second): + t.Fatal("expected AIServiceBackend event") + } + }) +} + +func TestGetBackendSecurityPolicyAuthOIDC_ManagedIdentity(t *testing.T) { + // Azure managed identity should not return OIDC. + require.Nil(t, getBackendSecurityPolicyAuthOIDC(aigv1a1.BackendSecurityPolicySpec{ + Type: aigv1a1.BackendSecurityPolicyTypeAzureCredentials, + AzureCredentials: &aigv1a1.BackendSecurityPolicyAzureCredentials{ + ClientID: "client-id", + TenantID: "tenant-id", + UseManagedIdentity: ptr.To(true), + }, + })) + + // Azure managed identity should not return OIDC even with empty clientID (system-assigned). + require.Nil(t, getBackendSecurityPolicyAuthOIDC(aigv1a1.BackendSecurityPolicySpec{ + Type: aigv1a1.BackendSecurityPolicyTypeAzureCredentials, + AzureCredentials: &aigv1a1.BackendSecurityPolicyAzureCredentials{ + TenantID: "tenant-id", + UseManagedIdentity: ptr.To(true), + }, + })) +} diff --git a/internal/controller/tokenprovider/azure_managed_identity_token_provider.go b/internal/controller/tokenprovider/azure_managed_identity_token_provider.go new file mode 100644 index 0000000000..63600aa1a8 --- /dev/null +++ b/internal/controller/tokenprovider/azure_managed_identity_token_provider.go @@ -0,0 +1,89 @@ +// Copyright Envoy AI Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +package tokenprovider + +import ( + "context" + "net/http" + "net/url" + "os" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" +) + +// azureManagedIdentityTokenProvider is a provider that implements TokenProvider interface for Azure Managed Identity access tokens. +// It uses DefaultAzureCredential which supports multiple authentication methods including managed identity, workload identity, and environment variables. +type azureManagedIdentityTokenProvider struct { + credential azcore.TokenCredential + tokenOption policy.TokenRequestOptions +} + +// NewAzureManagedIdentityTokenProvider creates a new TokenProvider using Azure DefaultAzureCredential. +// This supports: +// - Environment variables (AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_CLIENT_SECRET, AZURE_FEDERATED_TOKEN_FILE) +// - AKS Workload Identity (via service account annotations and federated token file) +// - System-assigned managed identity (when clientID is empty) +// - User-assigned managed identity (when clientID is provided) +// - Azure CLI credentials (for development scenarios). +func NewAzureManagedIdentityTokenProvider(_ context.Context, clientID string, tokenOption policy.TokenRequestOptions) (TokenProvider, error) { + clientOptions := GetDefaultAzureCredentialOptions() + + var credential azcore.TokenCredential + var err error + + if clientID != "" { + // User-assigned managed identity - specify the client ID. + managedIDOptions := &azidentity.ManagedIdentityCredentialOptions{ + ID: azidentity.ClientID(clientID), + } + if clientOptions != nil { + managedIDOptions.ClientOptions = clientOptions.ClientOptions + } + credential, err = azidentity.NewManagedIdentityCredential(managedIDOptions) + } else { + // Use DefaultAzureCredential which will try multiple credential types. + // Including system-assigned managed identity, workload identity, environment variables, etc. + credential, err = azidentity.NewDefaultAzureCredential(clientOptions) + } + + if err != nil { + return nil, err + } + + return &azureManagedIdentityTokenProvider{ + credential: credential, + tokenOption: tokenOption, + }, nil +} + +// GetToken implements TokenProvider.GetToken method to retrieve an Azure access token and its expiration time. +func (a *azureManagedIdentityTokenProvider) GetToken(ctx context.Context) (TokenExpiry, error) { + azureToken, err := a.credential.GetToken(ctx, a.tokenOption) + if err != nil { + return TokenExpiry{}, err + } + return TokenExpiry{Token: azureToken.Token, ExpiresAt: azureToken.ExpiresOn}, nil +} + +// GetDefaultAzureCredentialOptions returns the client options for DefaultAzureCredential, +// including proxy configuration if set via environment variable. +func GetDefaultAzureCredentialOptions() *azidentity.DefaultAzureCredentialOptions { + if azureProxyURL := os.Getenv("AI_GATEWAY_AZURE_PROXY_URL"); azureProxyURL != "" { + proxyURL, err := url.Parse(azureProxyURL) + if err == nil { + customTransport := &http.Transport{Proxy: http.ProxyURL(proxyURL)} + customHTTPClient := &http.Client{Transport: customTransport} + return &azidentity.DefaultAzureCredentialOptions{ + ClientOptions: azcore.ClientOptions{ + Transport: customHTTPClient, + }, + } + } + } + return nil +} diff --git a/internal/controller/tokenprovider/azure_managed_identity_token_provider_test.go b/internal/controller/tokenprovider/azure_managed_identity_token_provider_test.go new file mode 100644 index 0000000000..0d299edea4 --- /dev/null +++ b/internal/controller/tokenprovider/azure_managed_identity_token_provider_test.go @@ -0,0 +1,112 @@ +// Copyright Envoy AI Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +package tokenprovider + +import ( + "context" + "net/http" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/stretchr/testify/require" +) + +func TestNewAzureManagedIdentityTokenProvider(t *testing.T) { + t.Run("system-assigned managed identity", func(t *testing.T) { + provider, err := NewAzureManagedIdentityTokenProvider(context.Background(), "", policy.TokenRequestOptions{}) + require.NoError(t, err) + require.NotNil(t, provider) + }) + + t.Run("user-assigned managed identity", func(t *testing.T) { + provider, err := NewAzureManagedIdentityTokenProvider(context.Background(), "client-id", policy.TokenRequestOptions{}) + require.NoError(t, err) + require.NotNil(t, provider) + }) +} + +func TestNewAzureManagedIdentityTokenProvider_GetToken(t *testing.T) { + t.Run("missing azure scope", func(t *testing.T) { + provider, err := NewAzureManagedIdentityTokenProvider(context.Background(), "", policy.TokenRequestOptions{}) + require.NoError(t, err) + + tokenExpiry, err := provider.GetToken(context.Background()) + require.Error(t, err) + require.Empty(t, tokenExpiry.Token) + require.True(t, tokenExpiry.ExpiresAt.IsZero()) + }) + + // Note: Testing GetToken with actual Azure endpoints is skipped in unit tests + // as it would require a real Azure environment with managed identity configured. + // Integration tests should cover the full authentication flow. + + t.Run("azure proxy url", func(t *testing.T) { + // Set environment variable for the test. + mockProxyURL := "http://localhost:8888" + t.Setenv("AI_GATEWAY_AZURE_PROXY_URL", mockProxyURL) + + opts := GetDefaultAzureCredentialOptions() + + require.NotNil(t, opts) + require.NotNil(t, opts.Transport) + + // Assert that the transport has a proxy set. + transport, ok := opts.Transport.(*http.Client) + require.True(t, ok) + require.NotNil(t, transport.Transport) + + // Check the proxy URL (optional, deeper inspection). + innerTransport, ok := transport.Transport.(*http.Transport) + require.True(t, ok) + require.NotNil(t, innerTransport.Proxy) + + req, _ := http.NewRequest("GET", "http://example.com", nil) + proxyFunc := innerTransport.Proxy + proxyURL, err := proxyFunc(req) + require.NoError(t, err) + require.Equal(t, "http://localhost:8888", proxyURL.String()) + }) + + t.Run("no proxy url set", func(t *testing.T) { + // Ensure no proxy URL is set. + t.Setenv("AI_GATEWAY_AZURE_PROXY_URL", "") + + opts := GetDefaultAzureCredentialOptions() + require.Nil(t, opts) + }) + + t.Run("invalid proxy url", func(t *testing.T) { + // Set invalid proxy URL. + t.Setenv("AI_GATEWAY_AZURE_PROXY_URL", "://invalid-url") + + opts := GetDefaultAzureCredentialOptions() + require.Nil(t, opts) // Should return nil when URL parsing fails. + }) +} + +func TestGetDefaultAzureCredentialOptions(t *testing.T) { + t.Run("no proxy configured", func(t *testing.T) { + t.Setenv("AI_GATEWAY_AZURE_PROXY_URL", "") + opts := GetDefaultAzureCredentialOptions() + require.Nil(t, opts) + }) + + t.Run("valid proxy configured", func(t *testing.T) { + proxyURL := "http://proxy.example.com:8080" + t.Setenv("AI_GATEWAY_AZURE_PROXY_URL", proxyURL) + + opts := GetDefaultAzureCredentialOptions() + require.NotNil(t, opts) + require.NotNil(t, opts.Transport) + }) + + t.Run("invalid proxy url", func(t *testing.T) { + t.Setenv("AI_GATEWAY_AZURE_PROXY_URL", "://invalid-url") + + opts := GetDefaultAzureCredentialOptions() + require.Nil(t, opts) + }) +} diff --git a/manifests/charts/ai-gateway-crds-helm/templates/aigateway.envoyproxy.io_backendsecuritypolicies.yaml b/manifests/charts/ai-gateway-crds-helm/templates/aigateway.envoyproxy.io_backendsecuritypolicies.yaml index 0294b3a57e..faa82e4a9b 100644 --- a/manifests/charts/ai-gateway-crds-helm/templates/aigateway.envoyproxy.io_backendsecuritypolicies.yaml +++ b/manifests/charts/ai-gateway-crds-helm/templates/aigateway.envoyproxy.io_backendsecuritypolicies.yaml @@ -1727,8 +1727,10 @@ spec: Azure OpenAI specific logic will be applied. properties: clientID: - description: ClientID is a unique identifier for an application - in Azure. + description: |- + ClientID is a unique identifier for an application in Azure. + This field is optional when using system-assigned managed identity, + but required for user-assigned managed identity and other authentication methods. minLength: 1 type: string clientSecretRef: @@ -3213,15 +3215,30 @@ spec: Directory instance. minLength: 1 type: string + useManagedIdentity: + description: |- + UseManagedIdentity enables Azure Managed Identity authentication. + When set to true, the gateway will use DefaultAzureCredential which supports: + - Environment variables (AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_CLIENT_SECRET, AZURE_FEDERATED_TOKEN_FILE) + - AKS Workload Identity (via service account annotations) + - System-assigned managed identity (when clientID is not specified) + - User-assigned managed identity (when clientID is specified) + type: boolean required: - - clientID - tenantID type: object x-kubernetes-validations: - - message: Exactly one of clientSecretRef or oidcExchangeToken must - be specified - rule: (has(self.clientSecretRef) && !has(self.oidcExchangeToken)) - || (!has(self.clientSecretRef) && has(self.oidcExchangeToken)) + - message: When useManagedIdentity is true, clientSecretRef and oidcExchangeToken + must not be specified. Otherwise, exactly one of clientSecretRef + or oidcExchangeToken must be specified + rule: 'has(self.useManagedIdentity) && self.useManagedIdentity ? + (!has(self.clientSecretRef) && !has(self.oidcExchangeToken)) : + ((has(self.clientSecretRef) && !has(self.oidcExchangeToken)) || + (!has(self.clientSecretRef) && has(self.oidcExchangeToken)))' + - message: clientID is optional for system-assigned managed identity + but required otherwise + rule: 'has(self.useManagedIdentity) && self.useManagedIdentity && + !has(self.clientID) ? true : has(self.clientID)' gcpCredentials: description: GCPCredentials is a mechanism to access a backend(s). GCP specific logic will be applied. diff --git a/site/docs/api/api.mdx b/site/docs/api/api.mdx index 66fb5147ca..8efba91ec5 100644 --- a/site/docs/api/api.mdx +++ b/site/docs/api/api.mdx @@ -998,8 +998,9 @@ BackendSecurityPolicyAzureAPIKey specifies the Azure OpenAI API key. - [BackendSecurityPolicySpec](#backendsecuritypolicyspec) BackendSecurityPolicyAzureCredentials contains the supported authentication mechanisms to access Azure. -Only one of ClientSecretRef or OIDCExchangeToken must be specified. Credentials will not be generated if -neither are set. +One of ClientSecretRef, OIDCExchangeToken, or UseManagedIdentity must be specified. +When UseManagedIdentity is true, neither ClientSecretRef nor OIDCExchangeToken should be set. +Otherwise, exactly one of ClientSecretRef or OIDCExchangeToken must be specified. ##### Fields @@ -1008,8 +1009,8 @@ neither are set. diff --git a/tests/controller/azure_managed_identity_test.go b/tests/controller/azure_managed_identity_test.go new file mode 100644 index 0000000000..304e1fe764 --- /dev/null +++ b/tests/controller/azure_managed_identity_test.go @@ -0,0 +1,172 @@ +// Copyright Envoy AI Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +package controller_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + gwapiv1 "sigs.k8s.io/gateway-api/apis/v1" + gwapiv1a2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + + aigv1a1 "github.com/envoyproxy/ai-gateway/api/v1alpha1" + testsinternal "github.com/envoyproxy/ai-gateway/tests/internal" +) + +func TestAzureManagedIdentityIntegration(t *testing.T) { + c, _, _ := testsinternal.NewEnvTest(t) + ctx := context.Background() + + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "azure-mi-test", + }, + } + require.NoError(t, c.Create(ctx, namespace)) + + t.Run("user-assigned managed identity", func(t *testing.T) { + testUserAssignedManagedIdentity(ctx, t, c, namespace.Name) + }) + + t.Run("system-assigned managed identity", func(t *testing.T) { + testSystemAssignedManagedIdentity(ctx, t, c, namespace.Name) + }) +} + +func testUserAssignedManagedIdentity(ctx context.Context, t *testing.T, c client.Client, namespace string) { + // Create AIServiceBackend. + asb := &aigv1a1.AIServiceBackend{ + ObjectMeta: metav1.ObjectMeta{ + Name: "azure-mi-user-asb", + Namespace: namespace, + }, + Spec: aigv1a1.AIServiceBackendSpec{ + APISchema: aigv1a1.VersionedAPISchema{ + Name: "AzureOpenAI", + Version: ptr.To("2025-01-01-preview"), + }, + BackendRef: gwapiv1.BackendObjectReference{ + Name: "test-backend", + Port: ptr.To(gwapiv1.PortNumber(80)), + }, + }, + } + require.NoError(t, c.Create(ctx, asb)) + + // Create BackendSecurityPolicy with user-assigned managed identity. + bsp := &aigv1a1.BackendSecurityPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "azure-mi-user-bsp", + Namespace: namespace, + }, + Spec: aigv1a1.BackendSecurityPolicySpec{ + TargetRefs: []gwapiv1a2.LocalPolicyTargetReference{ + { + Group: "aigateway.envoyproxy.io", + Kind: "AIServiceBackend", + Name: gwapiv1.ObjectName(asb.Name), + }, + }, + Type: aigv1a1.BackendSecurityPolicyTypeAzureCredentials, + AzureCredentials: &aigv1a1.BackendSecurityPolicyAzureCredentials{ + ClientID: "test-user-assigned-mi-client-id", + TenantID: "test-tenant-id", + UseManagedIdentity: ptr.To(true), + }, + }, + } + require.NoError(t, c.Create(ctx, bsp)) + + // Wait for status to be updated. + require.Eventually(t, func() bool { + var updatedBSP aigv1a1.BackendSecurityPolicy + if err := c.Get(ctx, types.NamespacedName{Name: bsp.Name, Namespace: bsp.Namespace}, &updatedBSP); err != nil { + return false + } + return len(updatedBSP.Status.Conditions) > 0 + }, 30*time.Second, 100*time.Millisecond, "BackendSecurityPolicy status should be updated") + + // Verify the BackendSecurityPolicy exists and has the correct configuration. + var updatedBSP aigv1a1.BackendSecurityPolicy + require.NoError(t, c.Get(ctx, types.NamespacedName{Name: bsp.Name, Namespace: bsp.Namespace}, &updatedBSP)) + require.Equal(t, "test-user-assigned-mi-client-id", updatedBSP.Spec.AzureCredentials.ClientID) + require.Equal(t, "test-tenant-id", updatedBSP.Spec.AzureCredentials.TenantID) + require.NotNil(t, updatedBSP.Spec.AzureCredentials.UseManagedIdentity) + require.True(t, *updatedBSP.Spec.AzureCredentials.UseManagedIdentity) + require.Nil(t, updatedBSP.Spec.AzureCredentials.ClientSecretRef) + require.Nil(t, updatedBSP.Spec.AzureCredentials.OIDCExchangeToken) +} + +func testSystemAssignedManagedIdentity(ctx context.Context, t *testing.T, c client.Client, namespace string) { + // Create AIServiceBackend. + asb := &aigv1a1.AIServiceBackend{ + ObjectMeta: metav1.ObjectMeta{ + Name: "azure-mi-system-asb", + Namespace: namespace, + }, + Spec: aigv1a1.AIServiceBackendSpec{ + APISchema: aigv1a1.VersionedAPISchema{ + Name: "AzureOpenAI", + Version: ptr.To("2025-01-01-preview"), + }, + BackendRef: gwapiv1.BackendObjectReference{ + Name: "test-backend", + Port: ptr.To(gwapiv1.PortNumber(80)), + }, + }, + } + require.NoError(t, c.Create(ctx, asb)) + + // Create BackendSecurityPolicy with system-assigned managed identity (no clientID). + bsp := &aigv1a1.BackendSecurityPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "azure-mi-system-bsp", + Namespace: namespace, + }, + Spec: aigv1a1.BackendSecurityPolicySpec{ + TargetRefs: []gwapiv1a2.LocalPolicyTargetReference{ + { + Group: "aigateway.envoyproxy.io", + Kind: "AIServiceBackend", + Name: gwapiv1.ObjectName(asb.Name), + }, + }, + Type: aigv1a1.BackendSecurityPolicyTypeAzureCredentials, + AzureCredentials: &aigv1a1.BackendSecurityPolicyAzureCredentials{ + // No ClientID for system-assigned managed identity. + TenantID: "test-tenant-id", + UseManagedIdentity: ptr.To(true), + }, + }, + } + require.NoError(t, c.Create(ctx, bsp)) + + // Wait for status to be updated. + require.Eventually(t, func() bool { + var updatedBSP aigv1a1.BackendSecurityPolicy + if err := c.Get(ctx, types.NamespacedName{Name: bsp.Name, Namespace: bsp.Namespace}, &updatedBSP); err != nil { + return false + } + return len(updatedBSP.Status.Conditions) > 0 + }, 30*time.Second, 100*time.Millisecond, "BackendSecurityPolicy status should be updated") + + // Verify the BackendSecurityPolicy exists and has the correct configuration. + var updatedBSP aigv1a1.BackendSecurityPolicy + require.NoError(t, c.Get(ctx, types.NamespacedName{Name: bsp.Name, Namespace: bsp.Namespace}, &updatedBSP)) + require.Empty(t, updatedBSP.Spec.AzureCredentials.ClientID) // No clientID for system-assigned. + require.Equal(t, "test-tenant-id", updatedBSP.Spec.AzureCredentials.TenantID) + require.NotNil(t, updatedBSP.Spec.AzureCredentials.UseManagedIdentity) + require.True(t, *updatedBSP.Spec.AzureCredentials.UseManagedIdentity) + require.Nil(t, updatedBSP.Spec.AzureCredentials.ClientSecretRef) + require.Nil(t, updatedBSP.Spec.AzureCredentials.OIDCExchangeToken) +} diff --git a/tests/crdcel/main_test.go b/tests/crdcel/main_test.go index ced9b33998..8123b7e17a 100644 --- a/tests/crdcel/main_test.go +++ b/tests/crdcel/main_test.go @@ -131,7 +131,7 @@ func TestBackendSecurityPolicies(t *testing.T) { }, { name: "azure_credentials_missing_client_id.yaml", - expErr: "spec.azureCredentials.clientID in body should be at least 1 chars long", + expErr: "clientID is optional for system-assigned managed identity but required otherwise", }, { name: "azure_credentials_missing_tenant_id.yaml", @@ -139,11 +139,11 @@ func TestBackendSecurityPolicies(t *testing.T) { }, { name: "azure_missing_auth.yaml", - expErr: "Exactly one of clientSecretRef or oidcExchangeToken must be specified", + expErr: "When useManagedIdentity is true, clientSecretRef and oidcExchangeToken must not be specified. Otherwise, exactly one of clientSecretRef or oidcExchangeToken must be specified", }, { name: "azure_multiple_auth.yaml", - expErr: "Exactly one of clientSecretRef or oidcExchangeToken must be specified", + expErr: "When useManagedIdentity is true, clientSecretRef and oidcExchangeToken must not be specified. Otherwise, exactly one of clientSecretRef or oidcExchangeToken must be specified", }, // CEL validation test cases - these should fail due to type mismatch. { @@ -174,8 +174,19 @@ func TestBackendSecurityPolicies(t *testing.T) { name: "gcp_with_apikey.yaml", expErr: "When type is GCPCredentials, only gcpCredentials field should be set", }, + { + name: "azure_managed_identity_with_secret.yaml", + expErr: "When useManagedIdentity is true, clientSecretRef and oidcExchangeToken must not be specified. Otherwise, exactly one of clientSecretRef or oidcExchangeToken must be specified", + }, + { + name: "azure_managed_identity_with_oidc.yaml", + expErr: "When useManagedIdentity is true, clientSecretRef and oidcExchangeToken must not be specified. Otherwise, exactly one of clientSecretRef or oidcExchangeToken must be specified", + }, + // Valid test cases - these should pass. {name: "azure_oidc.yaml"}, {name: "azure_valid_credentials.yaml"}, + {name: "azure_managed_identity_user_assigned.yaml"}, + {name: "azure_managed_identity_system_assigned.yaml"}, {name: "aws_credential_file.yaml"}, {name: "aws_oidc.yaml"}, {name: "gcp_oidc.yaml"}, diff --git a/tests/crdcel/testdata/backendsecuritypolicies/azure_managed_identity_system_assigned.yaml b/tests/crdcel/testdata/backendsecuritypolicies/azure_managed_identity_system_assigned.yaml new file mode 100644 index 0000000000..4b5c341d79 --- /dev/null +++ b/tests/crdcel/testdata/backendsecuritypolicies/azure_managed_identity_system_assigned.yaml @@ -0,0 +1,16 @@ +# Copyright Envoy AI Gateway Authors +# SPDX-License-Identifier: Apache-2.0 +# The full text of the Apache license is available in the LICENSE file at +# the root of the repo. + +# Valid system-assigned managed identity configuration (no clientID) +apiVersion: aigateway.envoyproxy.io/v1alpha1 +kind: BackendSecurityPolicy +metadata: + name: azure-managed-identity-system-assigned-policy + namespace: default +spec: + type: AzureCredentials + azureCredentials: + tenantID: dummy_azure_tenant_id + useManagedIdentity: true diff --git a/tests/crdcel/testdata/backendsecuritypolicies/azure_managed_identity_user_assigned.yaml b/tests/crdcel/testdata/backendsecuritypolicies/azure_managed_identity_user_assigned.yaml new file mode 100644 index 0000000000..f35fc9c6fa --- /dev/null +++ b/tests/crdcel/testdata/backendsecuritypolicies/azure_managed_identity_user_assigned.yaml @@ -0,0 +1,17 @@ +# Copyright Envoy AI Gateway Authors +# SPDX-License-Identifier: Apache-2.0 +# The full text of the Apache license is available in the LICENSE file at +# the root of the repo. + +# Valid user-assigned managed identity configuration +apiVersion: aigateway.envoyproxy.io/v1alpha1 +kind: BackendSecurityPolicy +metadata: + name: azure-managed-identity-user-assigned-policy + namespace: default +spec: + type: AzureCredentials + azureCredentials: + clientID: user-assigned-mi-client-id + tenantID: dummy_azure_tenant_id + useManagedIdentity: true diff --git a/tests/crdcel/testdata/backendsecuritypolicies/azure_managed_identity_with_oidc.yaml b/tests/crdcel/testdata/backendsecuritypolicies/azure_managed_identity_with_oidc.yaml new file mode 100644 index 0000000000..200b42ea78 --- /dev/null +++ b/tests/crdcel/testdata/backendsecuritypolicies/azure_managed_identity_with_oidc.yaml @@ -0,0 +1,24 @@ +# Copyright Envoy AI Gateway Authors +# SPDX-License-Identifier: Apache-2.0 +# The full text of the Apache license is available in the LICENSE file at +# the root of the repo. + +# Invalid: managed identity with OIDC (should fail validation) +apiVersion: aigateway.envoyproxy.io/v1alpha1 +kind: BackendSecurityPolicy +metadata: + name: azure-managed-identity-with-oidc-policy + namespace: default +spec: + type: AzureCredentials + azureCredentials: + clientID: dummy_azure_client_id + tenantID: dummy_azure_tenant_id + useManagedIdentity: true + oidcExchangeToken: + oidc: + provider: + issuer: https://example.com + clientID: oidc-client-id + clientSecret: + name: oidc-secret diff --git a/tests/crdcel/testdata/backendsecuritypolicies/azure_managed_identity_with_secret.yaml b/tests/crdcel/testdata/backendsecuritypolicies/azure_managed_identity_with_secret.yaml new file mode 100644 index 0000000000..56da9f4d26 --- /dev/null +++ b/tests/crdcel/testdata/backendsecuritypolicies/azure_managed_identity_with_secret.yaml @@ -0,0 +1,19 @@ +# Copyright Envoy AI Gateway Authors +# SPDX-License-Identifier: Apache-2.0 +# The full text of the Apache license is available in the LICENSE file at +# the root of the repo. + +# Invalid: managed identity with client secret (should fail validation) +apiVersion: aigateway.envoyproxy.io/v1alpha1 +kind: BackendSecurityPolicy +metadata: + name: azure-managed-identity-with-secret-policy + namespace: default +spec: + type: AzureCredentials + azureCredentials: + clientID: dummy_azure_client_id + tenantID: dummy_azure_tenant_id + useManagedIdentity: true + clientSecretRef: + name: dummy_azure_secret_ref_name From f127c1ba644dab0bee20ccb745b94b8e89184deb Mon Sep 17 00:00:00 2001 From: Matt Mencel Date: Fri, 12 Sep 2025 10:32:10 -0500 Subject: [PATCH 2/4] fix: remove omitempty from ClientID JSON tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove omitempty from ClientID field in BackendSecurityPolicyAzureCredentials to maintain consistency with other ID fields and avoid breaking existing integrations that expect the field to always be present in JSON output. This addresses the GitHub Copilot review comment in PR #1183. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude Signed-off-by: Matt Mencel --- api/v1alpha1/backendsecurity_policy.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/v1alpha1/backendsecurity_policy.go b/api/v1alpha1/backendsecurity_policy.go index a7b0455932..ed33601c9d 100644 --- a/api/v1alpha1/backendsecurity_policy.go +++ b/api/v1alpha1/backendsecurity_policy.go @@ -245,7 +245,7 @@ type BackendSecurityPolicyAzureCredentials struct { // // +optional // +kubebuilder:validation:MinLength=1 - ClientID string `json:"clientID,omitempty"` + ClientID string `json:"clientID"` // TenantId is a unique identifier for an Azure Active Directory instance. // From 4418cd0ef37d9ff1f2c22a69f6d725834fe9b279 Mon Sep 17 00:00:00 2001 From: Martin Ehrnst Date: Tue, 28 Oct 2025 16:09:43 +0100 Subject: [PATCH 3/4] Implement workload identity support --- api/v1alpha1/backendsecurity_policy.go | 15 ++- examples/basic/azure_openai.yaml | 44 +++++++ .../basic/azure_openai_managed_identity.yaml | 117 ------------------ .../azure_managed_identity_token_provider.go | 32 ++++- ...envoyproxy.io_backendsecuritypolicies.yaml | 18 ++- .../connect-providers/azure-openai.md | 44 +++++-- 6 files changed, 121 insertions(+), 149 deletions(-) delete mode 100644 examples/basic/azure_openai_managed_identity.yaml diff --git a/api/v1alpha1/backendsecurity_policy.go b/api/v1alpha1/backendsecurity_policy.go index ed33601c9d..dbd368e1a5 100644 --- a/api/v1alpha1/backendsecurity_policy.go +++ b/api/v1alpha1/backendsecurity_policy.go @@ -237,21 +237,24 @@ type BackendSecurityPolicyGCPCredentials struct { // Otherwise, exactly one of ClientSecretRef or OIDCExchangeToken must be specified. // // +kubebuilder:validation:XValidation:rule="has(self.useManagedIdentity) && self.useManagedIdentity ? (!has(self.clientSecretRef) && !has(self.oidcExchangeToken)) : ((has(self.clientSecretRef) && !has(self.oidcExchangeToken)) || (!has(self.clientSecretRef) && has(self.oidcExchangeToken)))",message="When useManagedIdentity is true, clientSecretRef and oidcExchangeToken must not be specified. Otherwise, exactly one of clientSecretRef or oidcExchangeToken must be specified" -// +kubebuilder:validation:XValidation:rule="has(self.useManagedIdentity) && self.useManagedIdentity && !has(self.clientID) ? true : has(self.clientID)",message="clientID is optional for system-assigned managed identity but required otherwise" type BackendSecurityPolicyAzureCredentials struct { // ClientID is a unique identifier for an application in Azure. - // This field is optional when using system-assigned managed identity, - // but required for user-assigned managed identity and other authentication methods. + // This field is optional when using managed identity or workload identity, + // as the value will be provided via environment variables (AZURE_CLIENT_ID). + // Required for other authentication methods. // // +optional // +kubebuilder:validation:MinLength=1 - ClientID string `json:"clientID"` + ClientID string `json:"clientID,omitempty"` // TenantId is a unique identifier for an Azure Active Directory instance. + // This field is optional when using workload identity with service account annotations, + // as the value will be provided via environment variables (AZURE_TENANT_ID). + // Required for other authentication methods. // - // +kubebuilder:validation:Required + // +optional // +kubebuilder:validation:MinLength=1 - TenantID string `json:"tenantID"` + TenantID string `json:"tenantID,omitempty"` // ClientSecretRef is the reference to the secret containing the Azure client secret. // ai-gateway must be given the permission to read this secret. diff --git a/examples/basic/azure_openai.yaml b/examples/basic/azure_openai.yaml index 785a6abd85..72f0e1fa80 100644 --- a/examples/basic/azure_openai.yaml +++ b/examples/basic/azure_openai.yaml @@ -3,6 +3,29 @@ # The full text of the Apache license is available in the LICENSE file at # the root of the repo. +# Azure OpenAI Authentication Examples +# +# This file demonstrates two authentication methods for Azure OpenAI: +# 1. Client Secret (default, uncommented) +# 2. Managed Identity / Workload Identity (commented alternatives) +# +# For AKS Workload Identity setup, configure the service account during Helm install: +# +# helm upgrade --install ai-gateway-controller envoyproxy/ai-gateway-helm \ +# --set controller.serviceAccount.annotations."azure\.workload\.identity/client-id"="" \ +# --set controller.serviceAccount.annotations."azure\.workload\.identity/tenant-id"="" \ +# --create-namespace \ +# -n envoy-ai-gateway-system +# +# # Add the required label: +# kubectl label serviceaccount ai-gateway-controller \ +# azure.workload.identity/use=true \ +# -n envoy-ai-gateway-system +# +# For complete Azure infrastructure setup, see: +# https://learn.microsoft.com/en-us/azure/aks/workload-identity-deploy-cluster + +--- apiVersion: aigateway.envoyproxy.io/v1alpha1 kind: AIGatewayRoute metadata: @@ -39,6 +62,7 @@ spec: kind: Backend group: gateway.envoyproxy.io --- +# Option 1: Client Secret Authentication (Default) apiVersion: aigateway.envoyproxy.io/v1alpha1 kind: BackendSecurityPolicy metadata: @@ -57,6 +81,26 @@ spec: name: envoy-ai-gateway-basic-azure-client-secret namespace: default --- +# Option 2: Managed Identity / Workload Identity Authentication +# Uncomment this section and comment out Option 1 to use Managed Identity. +# +# For AKS Workload Identity: Configure service account annotations during Helm install +# (see header comment above for helm upgrade command) +# +# apiVersion: aigateway.envoyproxy.io/v1alpha1 +# kind: BackendSecurityPolicy +# metadata: +# name: envoy-ai-gateway-basic-azure-credentials +# namespace: default +# spec: +# targetRefs: +# - group: aigateway.envoyproxy.io +# kind: AIServiceBackend +# name: envoy-ai-gateway-basic-azure +# type: AzureCredentials +# azureCredentials: +# useManagedIdentity: true +--- apiVersion: gateway.envoyproxy.io/v1alpha1 kind: Backend metadata: diff --git a/examples/basic/azure_openai_managed_identity.yaml b/examples/basic/azure_openai_managed_identity.yaml deleted file mode 100644 index fba52a6438..0000000000 --- a/examples/basic/azure_openai_managed_identity.yaml +++ /dev/null @@ -1,117 +0,0 @@ -# Copyright Envoy AI Gateway Authors -# SPDX-License-Identifier: Apache-2.0 -# The full text of the Apache license is available in the LICENSE file at -# the root of the repo. - -# This example demonstrates Azure OpenAI integration using Managed Identity authentication. -# -# Prerequisites for AKS Workload Identity: -# 1. Enable OIDC Issuer and Workload Identity on your AKS cluster -# 2. Create a User-Assigned Managed Identity in Azure -# 3. Grant the Managed Identity "Cognitive Services OpenAI User" role on your Azure OpenAI resource -# 4. Create a federated identity credential associating the managed identity with your Kubernetes service account -# 5. Annotate your Kubernetes service account with the managed identity client ID -# -# Example service account: -# apiVersion: v1 -# kind: ServiceAccount -# metadata: -# name: ai-gateway-sa -# namespace: envoy-gateway-system -# annotations: -# azure.workload.identity/client-id: "YOUR_USER_ASSIGNED_IDENTITY_CLIENT_ID" -# -# Then configure your gateway deployment to use this service account. - ---- -apiVersion: aigateway.envoyproxy.io/v1alpha1 -kind: AIGatewayRoute -metadata: - name: envoy-ai-gateway-basic-azure-mi - namespace: default -spec: - parentRefs: - - name: envoy-ai-gateway-basic - kind: Gateway - group: gateway.networking.k8s.io - rules: - - matches: - - headers: - - type: Exact - name: x-ai-eg-model - value: gpt-4o-preview - backendRefs: - - name: envoy-ai-gateway-basic-azure-mi ---- -apiVersion: aigateway.envoyproxy.io/v1alpha1 -kind: AIServiceBackend -metadata: - name: envoy-ai-gateway-basic-azure-mi - namespace: default -spec: - schema: - name: AzureOpenAI - version: 2025-01-01-preview - backendRef: - name: envoy-ai-gateway-basic-azure-mi - kind: Backend - group: gateway.envoyproxy.io ---- -# User-Assigned Managed Identity Example -apiVersion: aigateway.envoyproxy.io/v1alpha1 -kind: BackendSecurityPolicy -metadata: - name: envoy-ai-gateway-basic-azure-mi-credentials - namespace: default -spec: - targetRefs: - - group: aigateway.envoyproxy.io - kind: AIServiceBackend - name: envoy-ai-gateway-basic-azure-mi - type: AzureCredentials - azureCredentials: - clientID: YOUR_USER_ASSIGNED_IDENTITY_CLIENT_ID # Replace with your User-Assigned Managed Identity Client ID - tenantID: YOUR_AZURE_TENANT_ID # Replace with your Azure Tenant ID - useManagedIdentity: true ---- -# Alternative: System-Assigned Managed Identity Example (uncomment and use instead of above) -# apiVersion: aigateway.envoyproxy.io/v1alpha1 -# kind: BackendSecurityPolicy -# metadata: -# name: envoy-ai-gateway-basic-azure-mi-credentials -# namespace: default -# spec: -# targetRefs: -# - group: aigateway.envoyproxy.io -# kind: AIServiceBackend -# name: envoy-ai-gateway-basic-azure-mi -# type: AzureCredentials -# azureCredentials: -# # No clientID specified for system-assigned managed identity -# tenantID: YOUR_AZURE_TENANT_ID # Replace with your Azure Tenant ID -# useManagedIdentity: true ---- -apiVersion: gateway.envoyproxy.io/v1alpha1 -kind: Backend -metadata: - name: envoy-ai-gateway-basic-azure-mi - namespace: default -spec: - endpoints: - - fqdn: - hostname: your-azure-openai-resource.openai.azure.com # Replace with your Azure OpenAI resource - port: 443 ---- -apiVersion: gateway.networking.k8s.io/v1alpha3 -kind: BackendTLSPolicy -metadata: - name: envoy-ai-gateway-basic-azure-mi-tls - namespace: default -spec: - targetRefs: - - group: 'gateway.envoyproxy.io' - kind: Backend - name: envoy-ai-gateway-basic-azure-mi - validation: - wellKnownCACertificates: "System" - hostname: your-azure-openai-resource.openai.azure.com # Replace with your Azure OpenAI resource diff --git a/internal/controller/tokenprovider/azure_managed_identity_token_provider.go b/internal/controller/tokenprovider/azure_managed_identity_token_provider.go index 63600aa1a8..d1140aab20 100644 --- a/internal/controller/tokenprovider/azure_managed_identity_token_provider.go +++ b/internal/controller/tokenprovider/azure_managed_identity_token_provider.go @@ -23,12 +23,12 @@ type azureManagedIdentityTokenProvider struct { tokenOption policy.TokenRequestOptions } -// NewAzureManagedIdentityTokenProvider creates a new TokenProvider using Azure DefaultAzureCredential. +// NewAzureManagedIdentityTokenProvider creates a new TokenProvider using Azure credentials. // This supports: +// - AKS Workload Identity (via AZURE_FEDERATED_TOKEN_FILE environment variable) // - Environment variables (AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_CLIENT_SECRET, AZURE_FEDERATED_TOKEN_FILE) -// - AKS Workload Identity (via service account annotations and federated token file) -// - System-assigned managed identity (when clientID is empty) -// - User-assigned managed identity (when clientID is provided) +// - System-assigned managed identity (when clientID is empty and no workload identity) +// - User-assigned managed identity (when clientID is provided and no workload identity) // - Azure CLI credentials (for development scenarios). func NewAzureManagedIdentityTokenProvider(_ context.Context, clientID string, tokenOption policy.TokenRequestOptions) (TokenProvider, error) { clientOptions := GetDefaultAzureCredentialOptions() @@ -36,8 +36,30 @@ func NewAzureManagedIdentityTokenProvider(_ context.Context, clientID string, to var credential azcore.TokenCredential var err error - if clientID != "" { + // Check if running in AKS Workload Identity environment + federatedTokenFile := os.Getenv("AZURE_FEDERATED_TOKEN_FILE") + tenantID := os.Getenv("AZURE_TENANT_ID") + envClientID := os.Getenv("AZURE_CLIENT_ID") + + if federatedTokenFile != "" && tenantID != "" { + // Use Workload Identity - this is the AKS Workload Identity pattern + // Use clientID from environment if not explicitly provided + if clientID == "" && envClientID != "" { + clientID = envClientID + } + + workloadIDOptions := &azidentity.WorkloadIdentityCredentialOptions{ + ClientID: clientID, + TenantID: tenantID, + TokenFilePath: federatedTokenFile, + } + if clientOptions != nil { + workloadIDOptions.ClientOptions = clientOptions.ClientOptions + } + credential, err = azidentity.NewWorkloadIdentityCredential(workloadIDOptions) + } else if clientID != "" { // User-assigned managed identity - specify the client ID. + // This uses Azure VM/VMSS Managed Identity via IMDS managedIDOptions := &azidentity.ManagedIdentityCredentialOptions{ ID: azidentity.ClientID(clientID), } diff --git a/manifests/charts/ai-gateway-crds-helm/templates/aigateway.envoyproxy.io_backendsecuritypolicies.yaml b/manifests/charts/ai-gateway-crds-helm/templates/aigateway.envoyproxy.io_backendsecuritypolicies.yaml index faa82e4a9b..1b6140e08a 100644 --- a/manifests/charts/ai-gateway-crds-helm/templates/aigateway.envoyproxy.io_backendsecuritypolicies.yaml +++ b/manifests/charts/ai-gateway-crds-helm/templates/aigateway.envoyproxy.io_backendsecuritypolicies.yaml @@ -1729,8 +1729,9 @@ spec: clientID: description: |- ClientID is a unique identifier for an application in Azure. - This field is optional when using system-assigned managed identity, - but required for user-assigned managed identity and other authentication methods. + This field is optional when using managed identity or workload identity, + as the value will be provided via environment variables (AZURE_CLIENT_ID). + Required for other authentication methods. minLength: 1 type: string clientSecretRef: @@ -3211,8 +3212,11 @@ spec: - oidc type: object tenantID: - description: TenantId is a unique identifier for an Azure Active - Directory instance. + description: |- + TenantId is a unique identifier for an Azure Active Directory instance. + This field is optional when using workload identity with service account annotations, + as the value will be provided via environment variables (AZURE_TENANT_ID). + Required for other authentication methods. minLength: 1 type: string useManagedIdentity: @@ -3224,8 +3228,6 @@ spec: - System-assigned managed identity (when clientID is not specified) - User-assigned managed identity (when clientID is specified) type: boolean - required: - - tenantID type: object x-kubernetes-validations: - message: When useManagedIdentity is true, clientSecretRef and oidcExchangeToken @@ -3235,10 +3237,6 @@ spec: (!has(self.clientSecretRef) && !has(self.oidcExchangeToken)) : ((has(self.clientSecretRef) && !has(self.oidcExchangeToken)) || (!has(self.clientSecretRef) && has(self.oidcExchangeToken)))' - - message: clientID is optional for system-assigned managed identity - but required otherwise - rule: 'has(self.useManagedIdentity) && self.useManagedIdentity && - !has(self.clientID) ? true : has(self.clientID)' gcpCredentials: description: GCPCredentials is a mechanism to access a backend(s). GCP specific logic will be applied. diff --git a/site/docs/getting-started/connect-providers/azure-openai.md b/site/docs/getting-started/connect-providers/azure-openai.md index 9f69c357b7..e42c264330 100644 --- a/site/docs/getting-started/connect-providers/azure-openai.md +++ b/site/docs/getting-started/connect-providers/azure-openai.md @@ -11,27 +11,27 @@ import vars from '../../\_vars.json'; This guide will help you configure Envoy AI Gateway to work with Azure OpenAI's foundation models. -There are two ways to do the [Azure OpenAI authentication](https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#authentication): Microsoft Entra ID and API Key. +Azure OpenAI supports two authentication methods: -We will use Microsoft Entra ID to authenticate an application to use the Azure OpenAI service. You can obtain an access token using the OAuth 2.0 client credentials grant flow. This process involves registering the application in Microsoft Entra ID (formerly Azure Active Directory), configuring the appropriate permissions, and acquiring a token from the Microsoft identity platform. The access token is then used as proof of authorization in API requests to the Azure OpenAI endpoint. +1. **API Key**: Simple authentication using an API key from your Azure OpenAI resource. -For detailed steps, refer to the official [Microsoft documentation](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-client-creds-grant-flow#get-a-token). +2. **Microsoft Entra ID**: OAuth-based authentication with two options: + - **Client Secret**: Uses client credentials (client ID, tenant ID, and secret) + - **Managed Identity / Workload Identity**: Uses Azure Managed Identity/Workload identity (recommended for production on Azure Kubernetes Clusters) -API Key authentication is not supported yet. +This guide focuses on Microsoft Entra ID authentication. For API Key authentication, refer to the [Azure OpenAI API documentation](https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#authentication). ## Prerequisites Before you begin, you'll need: -- Azure credentials with access to OpenAI service. +- Azure OpenAI resource with model access enabled (e.g., "GPT-4o") - Basic setup completed from the [Basic Usage](../basic-usage.md) guide - Basic configuration removed as described in the [Advanced Configuration](./index.md) overview -## Azure Credential Setup - -1. An Azure account with OpenAI service access enabled -2. Your Azure tenant ID, client ID, and client secret key -3. Enabled model access to "GPT-4o" +For Microsoft Entra ID authentication, additionally: +- Azure tenant ID, client ID, and either a client secret OR a configured Managed Identity +- For AKS Workload Identity: See [Azure documentation](https://learn.microsoft.com/en-us/azure/aks/workload-identity-deploy-cluster) for infrastructure setup ## Configuration Steps @@ -43,15 +43,37 @@ Before you begin, you'll need: ### 2. Configure Azure Credentials +The `azure_openai.yaml` file includes examples for both Entra ID authentication methods. By default, it uses client secret authentication. + +#### Client Secret Authentication (Default) + Edit the `azure_openai.yaml` file to replace these placeholder values: - `AZURE_TENANT_ID`: Your Azure tenant ID - `AZURE_CLIENT_ID`: Your Azure client ID - `AZURE_CLIENT_SECRET`: Your Azure client secret +#### Managed Identity / Workload Identity Authentication + +To use Workload Identity instead, see the commented examples in `azure_openai.yaml`. And configure the service account annotations during Helm installation: + +```shell +helm upgrade --install ai-gateway-controller envoyproxy/ai-gateway-helm \ + --set controller.serviceAccount.annotations."azure\.workload\.identity/client-id"="" \ + --set controller.serviceAccount.annotations."azure\.workload\.identity/tenant-id"="" \ + --create-namespace \ + -n envoy-ai-gateway-system + +# Add the required label: +kubectl label serviceaccount ai-gateway-controller \ + azure.workload.identity/use=true \ + -n envoy-ai-gateway-system +``` + +Then use the Managed Identity `BackendSecurityPolicy` example from the YAML file (uncomment Option 2, comment out Option 1). Omitting the `CLIENT_ID`, `CLIENT_SECRET` and only specify `useManagedIdentity: true`. When configuring the identity on Azure your federated subject should look something like this `system:serviceaccount:envoy-ai-gateway-system:ai-gateway-controller`. + :::caution Security Note Keep your Azure credentials secure and never commit them to version control. -The credentials will be stored in Kubernetes secrets. ::: ### 3. Apply Configuration From ec6ce13798436999800657f0591a5a75c26ca258 Mon Sep 17 00:00:00 2001 From: Martin Ehrnst Date: Mon, 1 Dec 2025 22:05:10 +0100 Subject: [PATCH 4/4] move workload identity from shared tokens to exproc assigned --- internal/backendauth/azure.go | 154 +++++++++++++++++- internal/backendauth/azure_test.go | 2 + .../controller/backend_security_policy.go | 8 +- internal/controller/gateway.go | 10 ++ internal/filterapi/filterconfig.go | 12 +- 5 files changed, 175 insertions(+), 11 deletions(-) diff --git a/internal/backendauth/azure.go b/internal/backendauth/azure.go index 6f51c1f875..5f3f56a8d4 100644 --- a/internal/backendauth/azure.go +++ b/internal/backendauth/azure.go @@ -8,24 +8,168 @@ package backendauth import ( "context" "fmt" + "net/http" + "net/url" + "os" "strings" + "sync" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/envoyproxy/ai-gateway/internal/filterapi" "github.com/envoyproxy/ai-gateway/internal/internalapi" ) +const azureScopeURL = "https://cognitiveservices.azure.com/.default" + type azureHandler struct { + // For controller-managed token rotation (client secret, OIDC) azureAccessToken string + // For extproc-managed workload identity + useManagedIdentity bool + credential azcore.TokenCredential + tokenOptions policy.TokenRequestOptions + cachedToken string + tokenExpiry time.Time + mu sync.RWMutex } func newAzureHandler(auth *filterapi.AzureAuth) (filterapi.BackendAuthHandler, error) { - return &azureHandler{azureAccessToken: strings.TrimSpace(auth.AccessToken)}, nil + if auth.UseManagedIdentity { + // Extproc-managed workload identity: obtain tokens dynamically + credential, err := createAzureCredential(auth.ClientID, auth.TenantID) + if err != nil { + return nil, fmt.Errorf("failed to create Azure credential: %w", err) + } + return &azureHandler{ + useManagedIdentity: true, + credential: credential, + tokenOptions: policy.TokenRequestOptions{Scopes: []string{azureScopeURL}}, + }, nil + } + // Controller-managed token rotation: use pre-obtained token + return &azureHandler{ + useManagedIdentity: false, + azureAccessToken: strings.TrimSpace(auth.AccessToken), + }, nil +} + +// createAzureCredential creates an Azure credential based on the environment and configuration. +// Supports AKS Workload Identity, user-assigned managed identity, and system-assigned managed identity. +func createAzureCredential(clientID, tenantID string) (azcore.TokenCredential, error) { + clientOptions := getDefaultAzureCredentialOptions() + + // Check if running in AKS Workload Identity environment + federatedTokenFile := os.Getenv("AZURE_FEDERATED_TOKEN_FILE") + envTenantID := os.Getenv("AZURE_TENANT_ID") + envClientID := os.Getenv("AZURE_CLIENT_ID") + + if federatedTokenFile != "" && (tenantID != "" || envTenantID != "") { + // Use Workload Identity - this is the AKS Workload Identity pattern + if tenantID == "" { + tenantID = envTenantID + } + if clientID == "" { + clientID = envClientID + } + workloadIDOptions := &azidentity.WorkloadIdentityCredentialOptions{ + ClientID: clientID, + TenantID: tenantID, + TokenFilePath: federatedTokenFile, + } + if clientOptions != nil { + workloadIDOptions.ClientOptions = clientOptions.ClientOptions + } + return azidentity.NewWorkloadIdentityCredential(workloadIDOptions) + } else if clientID != "" { + // User-assigned managed identity - specify the client ID. + // This uses Azure VM/VMSS Managed Identity via IMDS + managedIDOptions := &azidentity.ManagedIdentityCredentialOptions{ + ID: azidentity.ClientID(clientID), + } + if clientOptions != nil { + managedIDOptions.ClientOptions = clientOptions.ClientOptions + } + return azidentity.NewManagedIdentityCredential(managedIDOptions) + } + // Use DefaultAzureCredential which will try multiple credential types, + // including system-assigned managed identity + return azidentity.NewDefaultAzureCredential(clientOptions) +} + +// getDefaultAzureCredentialOptions returns the client options for Azure credentials, +// including proxy configuration if set via environment variable. +func getDefaultAzureCredentialOptions() *azidentity.DefaultAzureCredentialOptions { + if azureProxyURL := os.Getenv("AI_GATEWAY_AZURE_PROXY_URL"); azureProxyURL != "" { + proxyURL, err := url.Parse(azureProxyURL) + if err == nil { + customTransport := &http.Transport{Proxy: http.ProxyURL(proxyURL)} + customHTTPClient := &http.Client{Transport: customTransport} + return &azidentity.DefaultAzureCredentialOptions{ + ClientOptions: azcore.ClientOptions{ + Transport: customHTTPClient, + }, + } + } + } + return nil } // Do implements [Handler.Do]. // -// Extracts the azure access token from the local file and set it as an authorization header. -func (a *azureHandler) Do(_ context.Context, requestHeaders map[string]string, _ []byte) ([]internalapi.Header, error) { - requestHeaders["Authorization"] = fmt.Sprintf("Bearer %s", a.azureAccessToken) - return []internalapi.Header{{"Authorization", fmt.Sprintf("Bearer %s", a.azureAccessToken)}}, nil +// For controller-managed tokens: Uses the pre-obtained access token. +// For extproc-managed workload identity: Obtains tokens dynamically using Azure SDK. +func (a *azureHandler) Do(ctx context.Context, requestHeaders map[string]string, _ []byte) ([]internalapi.Header, error) { + var token string + var err error + + if a.useManagedIdentity { + // Get token dynamically using workload identity + token, err = a.getToken(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get Azure token: %w", err) + } + } else { + // Use pre-obtained token from controller + token = a.azureAccessToken + } + + authorizationValue := fmt.Sprintf("Bearer %s", token) + requestHeaders["Authorization"] = authorizationValue + return []internalapi.Header{{"Authorization", authorizationValue}}, nil +} + +// getToken retrieves an Azure access token, using cached token if still valid. +func (a *azureHandler) getToken(ctx context.Context) (string, error) { + // Check if cached token is still valid (with 5-minute buffer) + a.mu.RLock() + if a.cachedToken != "" && time.Now().Add(5*time.Minute).Before(a.tokenExpiry) { + token := a.cachedToken + a.mu.RUnlock() + return token, nil + } + a.mu.RUnlock() + + // Need to get a new token + a.mu.Lock() + defer a.mu.Unlock() + + // Double-check after acquiring write lock + if a.cachedToken != "" && time.Now().Add(5*time.Minute).Before(a.tokenExpiry) { + return a.cachedToken, nil + } + + // Get new token from Azure + azureToken, err := a.credential.GetToken(ctx, a.tokenOptions) + if err != nil { + return "", err + } + + // Cache the new token + a.cachedToken = azureToken.Token + a.tokenExpiry = azureToken.ExpiresOn + return azureToken.Token, nil } diff --git a/internal/backendauth/azure_test.go b/internal/backendauth/azure_test.go index 4ea2a26345..12028e156a 100644 --- a/internal/backendauth/azure_test.go +++ b/internal/backendauth/azure_test.go @@ -40,3 +40,5 @@ func TestNewAzureHandler_Do(t *testing.T) { require.Equal(t, "Authorization", headers[0][0]) require.Equal(t, "Bearer some-access-token", headers[0][1]) } + +// investigate how to ruun test foor workload identity cases. \ No newline at end of file diff --git a/internal/controller/backend_security_policy.go b/internal/controller/backend_security_policy.go index c3a77e541d..8b769f19c2 100644 --- a/internal/controller/backend_security_policy.go +++ b/internal/controller/backend_security_policy.go @@ -137,11 +137,9 @@ func (c *BackendSecurityPolicyController) rotateCredential(ctx context.Context, options := policy.TokenRequestOptions{Scopes: []string{azureScopeURL}} if bsp.Spec.AzureCredentials.UseManagedIdentity != nil && *bsp.Spec.AzureCredentials.UseManagedIdentity { - // Use managed identity authentication (DefaultAzureCredential). - provider, err = tokenprovider.NewAzureManagedIdentityTokenProvider(ctx, clientID, options) - if err != nil { - return ctrl.Result{}, err - } + // Managed identity: extproc will obtain tokens dynamically, controller doesn't need to rotate + c.enqueueAIServiceBackendsForBackendSecurityPolicy(ctx, bsp) + return ctrl.Result{}, nil } else if oidc := getBackendSecurityPolicyAuthOIDC(bsp.Spec); oidc != nil { var oidcProvider tokenprovider.TokenProvider oidcProvider, err = tokenprovider.NewOidcTokenProvider(ctx, c.client, oidc) diff --git a/internal/controller/gateway.go b/internal/controller/gateway.go index b1aa5bcb58..9853ea3f3a 100644 --- a/internal/controller/gateway.go +++ b/internal/controller/gateway.go @@ -531,6 +531,16 @@ func (c *GatewayController) bspToFilterAPIBackendAuth(ctx context.Context, backe }, }, nil case aigv1a1.BackendSecurityPolicyTypeAzureCredentials: + azureCred := backendSecurityPolicy.Spec.AzureCredentials + if azureCred.UseManagedIdentity != nil && *azureCred.UseManagedIdentity { + return &filterapi.BackendAuth{ + AzureAuth: &filterapi.AzureAuth{ + UseManagedIdentity: true, + ClientID: azureCred.ClientID, + TenantID: azureCred.TenantID, + }, + }, nil + } secretName := rotators.GetBSPSecretName(backendSecurityPolicy.Name) azureAccessToken, err := c.getSecretData(ctx, namespace, secretName, rotators.AzureAccessTokenKey) if err != nil { diff --git a/internal/filterapi/filterconfig.go b/internal/filterapi/filterconfig.go index b0721e4709..4ed2b113f4 100644 --- a/internal/filterapi/filterconfig.go +++ b/internal/filterapi/filterconfig.go @@ -180,7 +180,17 @@ type AnthropicAPIKeyAuth struct { // AzureAuth defines the file containing azure access token that will be mounted to the external proc. type AzureAuth struct { // AccessToken is the access token as a literal string. - AccessToken string `json:"accessToken"` + // ignored if UseManagedIdentity is true. + AccessToken string `json:"accessToken,omitempty"` + // UseManagedIdentity enables Azure Managed Identity authentication. + // Extproc will get token dynamically + UseManagedIdentity bool `json:"useManagedIdentity,omitempty"` + // ClientID is the Azure client ID for user-assigned managed identity. + // Optional when using system-assigned managed identity or when provided via environment variables. + ClientID string `json:"clientID,omitempty"` + // TenantID is the Azure tenant ID. + // Optional when provided via environment variables (e.g., AKS Workload Identity). + TenantID string `json:"tenantID,omitempty"` } // GCPAuth defines the GCP authentication configuration used to access Google Cloud AI services.