Skip to content

Commit 4b3b8f6

Browse files
committed
Merge Azure Managed Identity support from mattmancel-azure-workload/main
2 parents 2861c1b + ad8f619 commit 4b3b8f6

17 files changed

+800
-21
lines changed

.codespell.ignorewords

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
AKS

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ inference-extension-conformance-test-report.yaml
4747
.claude/
4848
.env
4949
.mcp.json
50+
.serena/
51+
CLAUDE.md
5052

5153
.goose
5254
/aigw
55+
56+
# Go test binaries
57+
*.test

api/v1alpha1/backendsecurity_policy.go

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -229,14 +229,18 @@ type BackendSecurityPolicyGCPCredentials struct {
229229
}
230230

231231
// BackendSecurityPolicyAzureCredentials contains the supported authentication mechanisms to access Azure.
232-
// Only one of ClientSecretRef or OIDCExchangeToken must be specified. Credentials will not be generated if
233-
// neither are set.
232+
// One of ClientSecretRef, OIDCExchangeToken, or UseManagedIdentity must be specified.
233+
// When UseManagedIdentity is true, neither ClientSecretRef nor OIDCExchangeToken should be set.
234+
// Otherwise, exactly one of ClientSecretRef or OIDCExchangeToken must be specified.
234235
//
235-
// +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"
236+
// +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"
237+
// +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"
236238
type BackendSecurityPolicyAzureCredentials struct {
237239
// ClientID is a unique identifier for an application in Azure.
240+
// This field is optional when using system-assigned managed identity,
241+
// but required for user-assigned managed identity and other authentication methods.
238242
//
239-
// +kubebuilder:validation:Required
243+
// +optional
240244
// +kubebuilder:validation:MinLength=1
241245
ClientID string `json:"clientID"`
242246

@@ -258,6 +262,16 @@ type BackendSecurityPolicyAzureCredentials struct {
258262
//
259263
// +optional
260264
OIDCExchangeToken *AzureOIDCExchangeToken `json:"oidcExchangeToken,omitempty"`
265+
266+
// UseManagedIdentity enables Azure Managed Identity authentication.
267+
// When set to true, the gateway will use DefaultAzureCredential which supports:
268+
// - Environment variables (AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_CLIENT_SECRET, AZURE_FEDERATED_TOKEN_FILE)
269+
// - AKS Workload Identity (via service account annotations)
270+
// - System-assigned managed identity (when clientID is not specified)
271+
// - User-assigned managed identity (when clientID is specified)
272+
//
273+
// +optional
274+
UseManagedIdentity *bool `json:"useManagedIdentity,omitempty"`
261275
}
262276

263277
// AzureOIDCExchangeToken specifies credentials to obtain oidc token from a sso server.

api/v1alpha1/zz_generated.deepcopy.go

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# Copyright Envoy AI Gateway Authors
2+
# SPDX-License-Identifier: Apache-2.0
3+
# The full text of the Apache license is available in the LICENSE file at
4+
# the root of the repo.
5+
6+
# This example demonstrates Azure OpenAI integration using Managed Identity authentication.
7+
#
8+
# Prerequisites for AKS Workload Identity:
9+
# 1. Enable OIDC Issuer and Workload Identity on your AKS cluster
10+
# 2. Create a User-Assigned Managed Identity in Azure
11+
# 3. Grant the Managed Identity "Cognitive Services OpenAI User" role on your Azure OpenAI resource
12+
# 4. Create a federated identity credential associating the managed identity with your Kubernetes service account
13+
# 5. Annotate your Kubernetes service account with the managed identity client ID
14+
#
15+
# Example service account:
16+
# apiVersion: v1
17+
# kind: ServiceAccount
18+
# metadata:
19+
# name: ai-gateway-sa
20+
# namespace: envoy-gateway-system
21+
# annotations:
22+
# azure.workload.identity/client-id: "YOUR_USER_ASSIGNED_IDENTITY_CLIENT_ID"
23+
#
24+
# Then configure your gateway deployment to use this service account.
25+
26+
---
27+
apiVersion: aigateway.envoyproxy.io/v1alpha1
28+
kind: AIGatewayRoute
29+
metadata:
30+
name: envoy-ai-gateway-basic-azure-mi
31+
namespace: default
32+
spec:
33+
parentRefs:
34+
- name: envoy-ai-gateway-basic
35+
kind: Gateway
36+
group: gateway.networking.k8s.io
37+
rules:
38+
- matches:
39+
- headers:
40+
- type: Exact
41+
name: x-ai-eg-model
42+
value: gpt-4o-preview
43+
backendRefs:
44+
- name: envoy-ai-gateway-basic-azure-mi
45+
---
46+
apiVersion: aigateway.envoyproxy.io/v1alpha1
47+
kind: AIServiceBackend
48+
metadata:
49+
name: envoy-ai-gateway-basic-azure-mi
50+
namespace: default
51+
spec:
52+
schema:
53+
name: AzureOpenAI
54+
version: 2025-01-01-preview
55+
backendRef:
56+
name: envoy-ai-gateway-basic-azure-mi
57+
kind: Backend
58+
group: gateway.envoyproxy.io
59+
---
60+
# User-Assigned Managed Identity Example
61+
apiVersion: aigateway.envoyproxy.io/v1alpha1
62+
kind: BackendSecurityPolicy
63+
metadata:
64+
name: envoy-ai-gateway-basic-azure-mi-credentials
65+
namespace: default
66+
spec:
67+
targetRefs:
68+
- group: aigateway.envoyproxy.io
69+
kind: AIServiceBackend
70+
name: envoy-ai-gateway-basic-azure-mi
71+
type: AzureCredentials
72+
azureCredentials:
73+
clientID: YOUR_USER_ASSIGNED_IDENTITY_CLIENT_ID # Replace with your User-Assigned Managed Identity Client ID
74+
tenantID: YOUR_AZURE_TENANT_ID # Replace with your Azure Tenant ID
75+
useManagedIdentity: true
76+
---
77+
# Alternative: System-Assigned Managed Identity Example (uncomment and use instead of above)
78+
# apiVersion: aigateway.envoyproxy.io/v1alpha1
79+
# kind: BackendSecurityPolicy
80+
# metadata:
81+
# name: envoy-ai-gateway-basic-azure-mi-credentials
82+
# namespace: default
83+
# spec:
84+
# targetRefs:
85+
# - group: aigateway.envoyproxy.io
86+
# kind: AIServiceBackend
87+
# name: envoy-ai-gateway-basic-azure-mi
88+
# type: AzureCredentials
89+
# azureCredentials:
90+
# # No clientID specified for system-assigned managed identity
91+
# tenantID: YOUR_AZURE_TENANT_ID # Replace with your Azure Tenant ID
92+
# useManagedIdentity: true
93+
---
94+
apiVersion: gateway.envoyproxy.io/v1alpha1
95+
kind: Backend
96+
metadata:
97+
name: envoy-ai-gateway-basic-azure-mi
98+
namespace: default
99+
spec:
100+
endpoints:
101+
- fqdn:
102+
hostname: your-azure-openai-resource.openai.azure.com # Replace with your Azure OpenAI resource
103+
port: 443
104+
---
105+
apiVersion: gateway.networking.k8s.io/v1alpha3
106+
kind: BackendTLSPolicy
107+
metadata:
108+
name: envoy-ai-gateway-basic-azure-mi-tls
109+
namespace: default
110+
spec:
111+
targetRefs:
112+
- group: 'gateway.envoyproxy.io'
113+
kind: Backend
114+
name: envoy-ai-gateway-basic-azure-mi
115+
validation:
116+
wellKnownCACertificates: "System"
117+
hostname: your-azure-openai-resource.openai.azure.com # Replace with your Azure OpenAI resource

internal/controller/backend_security_policy.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,8 +136,13 @@ func (c *BackendSecurityPolicyController) rotateCredential(ctx context.Context,
136136
var provider tokenprovider.TokenProvider
137137
options := policy.TokenRequestOptions{Scopes: []string{azureScopeURL}}
138138

139-
oidc := getBackendSecurityPolicyAuthOIDC(bsp.Spec)
140-
if oidc != nil {
139+
if bsp.Spec.AzureCredentials.UseManagedIdentity != nil && *bsp.Spec.AzureCredentials.UseManagedIdentity {
140+
// Use managed identity authentication (DefaultAzureCredential).
141+
provider, err = tokenprovider.NewAzureManagedIdentityTokenProvider(ctx, clientID, options)
142+
if err != nil {
143+
return ctrl.Result{}, err
144+
}
145+
} else if oidc := getBackendSecurityPolicyAuthOIDC(bsp.Spec); oidc != nil {
141146
var oidcProvider tokenprovider.TokenProvider
142147
oidcProvider, err = tokenprovider.NewOidcTokenProvider(ctx, c.client, oidc)
143148
if err != nil {
@@ -169,7 +174,7 @@ func (c *BackendSecurityPolicyController) rotateCredential(ctx context.Context,
169174
return ctrl.Result{}, err
170175
}
171176
} else {
172-
return ctrl.Result{}, fmt.Errorf("one of secret ref or oidc must be defined, namespace %s name %s", bsp.Namespace, bsp.Name)
177+
return ctrl.Result{}, fmt.Errorf("one of secret ref, oidc, or managed identity must be defined, namespace %s name %s", bsp.Namespace, bsp.Name)
173178
}
174179

175180
rotator, err = rotators.NewAzureTokenRotator(c.client, c.kube, c.logger, bsp.Namespace, bsp.Name, preRotationWindow, provider)

internal/controller/backend_security_policy_test.go

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1270,3 +1270,152 @@ func TestGetBSPGeneratedSecretName(t *testing.T) {
12701270
})
12711271
}
12721272
}
1273+
1274+
func TestBackendSecurityPolicyController_ManagedIdentity(t *testing.T) {
1275+
t.Run("Azure managed identity authentication", func(t *testing.T) {
1276+
eventCh := internaltesting.NewControllerEventChan[*aigv1a1.AIServiceBackend]()
1277+
fakeClient := requireNewFakeClientWithIndexes(t)
1278+
c := NewBackendSecurityPolicyController(fakeClient, fake2.NewClientset(), ctrl.Log, eventCh.Ch)
1279+
1280+
namespace := "default"
1281+
bspName := "azure-mi-bsp"
1282+
asbName := "azure-mi-asb"
1283+
1284+
// Create AIServiceBackend.
1285+
asb := &aigv1a1.AIServiceBackend{
1286+
ObjectMeta: metav1.ObjectMeta{
1287+
Name: asbName,
1288+
Namespace: namespace,
1289+
},
1290+
}
1291+
require.NoError(t, fakeClient.Create(context.Background(), asb))
1292+
1293+
// Create BackendSecurityPolicy with user-assigned managed identity.
1294+
bsp := &aigv1a1.BackendSecurityPolicy{
1295+
ObjectMeta: metav1.ObjectMeta{
1296+
Name: bspName,
1297+
Namespace: namespace,
1298+
},
1299+
Spec: aigv1a1.BackendSecurityPolicySpec{
1300+
TargetRefs: []gwapiv1a2.LocalPolicyTargetReference{
1301+
{
1302+
Group: "aigateway.envoyproxy.io",
1303+
Kind: "AIServiceBackend",
1304+
Name: gwapiv1.ObjectName(asbName),
1305+
},
1306+
},
1307+
Type: aigv1a1.BackendSecurityPolicyTypeAzureCredentials,
1308+
AzureCredentials: &aigv1a1.BackendSecurityPolicyAzureCredentials{
1309+
ClientID: "test-user-assigned-mi-client-id",
1310+
TenantID: "test-tenant-id",
1311+
UseManagedIdentity: ptr.To(true),
1312+
},
1313+
},
1314+
}
1315+
require.NoError(t, fakeClient.Create(context.Background(), bsp))
1316+
1317+
// Reconcile - expect it to succeed (token provider creation will fail but that's expected in test).
1318+
res, err := c.Reconcile(context.Background(), reconcile.Request{
1319+
NamespacedName: types.NamespacedName{
1320+
Name: bspName,
1321+
Namespace: namespace,
1322+
},
1323+
})
1324+
require.NoError(t, err)
1325+
require.Positive(t, res.RequeueAfter) // Should requeue for token rotation.
1326+
1327+
// Verify AIServiceBackend event was sent.
1328+
select {
1329+
case event := <-eventCh.Ch:
1330+
receivedASB := event.Object.(*aigv1a1.AIServiceBackend)
1331+
require.Equal(t, asbName, receivedASB.Name)
1332+
require.Equal(t, namespace, receivedASB.Namespace)
1333+
case <-time.After(time.Second):
1334+
t.Fatal("expected AIServiceBackend event")
1335+
}
1336+
})
1337+
1338+
t.Run("Azure system-assigned managed identity authentication", func(t *testing.T) {
1339+
eventCh := internaltesting.NewControllerEventChan[*aigv1a1.AIServiceBackend]()
1340+
fakeClient := requireNewFakeClientWithIndexes(t)
1341+
c := NewBackendSecurityPolicyController(fakeClient, fake2.NewClientset(), ctrl.Log, eventCh.Ch)
1342+
1343+
namespace := "default"
1344+
bspName := "azure-system-mi-bsp"
1345+
asbName := "azure-system-mi-asb"
1346+
1347+
// Create AIServiceBackend.
1348+
asb := &aigv1a1.AIServiceBackend{
1349+
ObjectMeta: metav1.ObjectMeta{
1350+
Name: asbName,
1351+
Namespace: namespace,
1352+
},
1353+
}
1354+
require.NoError(t, fakeClient.Create(context.Background(), asb))
1355+
1356+
// Create BackendSecurityPolicy with system-assigned managed identity (no clientID).
1357+
bsp := &aigv1a1.BackendSecurityPolicy{
1358+
ObjectMeta: metav1.ObjectMeta{
1359+
Name: bspName,
1360+
Namespace: namespace,
1361+
},
1362+
Spec: aigv1a1.BackendSecurityPolicySpec{
1363+
TargetRefs: []gwapiv1a2.LocalPolicyTargetReference{
1364+
{
1365+
Group: "aigateway.envoyproxy.io",
1366+
Kind: "AIServiceBackend",
1367+
Name: gwapiv1.ObjectName(asbName),
1368+
},
1369+
},
1370+
Type: aigv1a1.BackendSecurityPolicyTypeAzureCredentials,
1371+
AzureCredentials: &aigv1a1.BackendSecurityPolicyAzureCredentials{
1372+
// No ClientID for system-assigned managed identity.
1373+
TenantID: "test-tenant-id",
1374+
UseManagedIdentity: ptr.To(true),
1375+
},
1376+
},
1377+
}
1378+
require.NoError(t, fakeClient.Create(context.Background(), bsp))
1379+
1380+
// Reconcile - expect it to succeed.
1381+
res, err := c.Reconcile(context.Background(), reconcile.Request{
1382+
NamespacedName: types.NamespacedName{
1383+
Name: bspName,
1384+
Namespace: namespace,
1385+
},
1386+
})
1387+
require.NoError(t, err)
1388+
require.Positive(t, res.RequeueAfter) // Should requeue for token rotation.
1389+
1390+
// Verify AIServiceBackend event was sent.
1391+
select {
1392+
case event := <-eventCh.Ch:
1393+
receivedASB := event.Object.(*aigv1a1.AIServiceBackend)
1394+
require.Equal(t, asbName, receivedASB.Name)
1395+
require.Equal(t, namespace, receivedASB.Namespace)
1396+
case <-time.After(time.Second):
1397+
t.Fatal("expected AIServiceBackend event")
1398+
}
1399+
})
1400+
}
1401+
1402+
func TestGetBackendSecurityPolicyAuthOIDC_ManagedIdentity(t *testing.T) {
1403+
// Azure managed identity should not return OIDC.
1404+
require.Nil(t, getBackendSecurityPolicyAuthOIDC(aigv1a1.BackendSecurityPolicySpec{
1405+
Type: aigv1a1.BackendSecurityPolicyTypeAzureCredentials,
1406+
AzureCredentials: &aigv1a1.BackendSecurityPolicyAzureCredentials{
1407+
ClientID: "client-id",
1408+
TenantID: "tenant-id",
1409+
UseManagedIdentity: ptr.To(true),
1410+
},
1411+
}))
1412+
1413+
// Azure managed identity should not return OIDC even with empty clientID (system-assigned).
1414+
require.Nil(t, getBackendSecurityPolicyAuthOIDC(aigv1a1.BackendSecurityPolicySpec{
1415+
Type: aigv1a1.BackendSecurityPolicyTypeAzureCredentials,
1416+
AzureCredentials: &aigv1a1.BackendSecurityPolicyAzureCredentials{
1417+
TenantID: "tenant-id",
1418+
UseManagedIdentity: ptr.To(true),
1419+
},
1420+
}))
1421+
}

0 commit comments

Comments
 (0)