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..dbd368e1a5 100644 --- a/api/v1alpha1/backendsecurity_policy.go +++ b/api/v1alpha1/backendsecurity_policy.go @@ -232,22 +232,29 @@ 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" type BackendSecurityPolicyAzureCredentials struct { // ClientID is a unique identifier for an application in Azure. + // 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. // - // +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. + // 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. @@ -261,6 +268,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.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/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 1a472eea62..8b769f19c2 100644 --- a/internal/controller/backend_security_policy.go +++ b/internal/controller/backend_security_policy.go @@ -136,8 +136,11 @@ 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 { + // 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) if err != nil { @@ -169,7 +172,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/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/controller/tokenprovider/azure_managed_identity_token_provider.go b/internal/controller/tokenprovider/azure_managed_identity_token_provider.go new file mode 100644 index 0000000000..d1140aab20 --- /dev/null +++ b/internal/controller/tokenprovider/azure_managed_identity_token_provider.go @@ -0,0 +1,111 @@ +// 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 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) +// - 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() + + var credential azcore.TokenCredential + var err error + + // 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), + } + 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/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. 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..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 @@ -1727,8 +1727,11 @@ 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 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: @@ -3209,19 +3212,31 @@ 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 - required: - - clientID - - tenantID + 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 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)))' 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/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 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