Skip to content

Commit 9a207a9

Browse files
gladjohnCopilot
andauthored
Add managed identity E2E tests + self-hosted CI stages (MSAL Go #641 parity) (#948)
* test(managed-identity): add real MI E2E tests + self-hosted CI stages Mirror the MSAL Go MI E2E setup (#641). Add tests/test_mi_e2e.py, which acquires ARM tokens for the system-assigned identity and each user-assigned binding (client id / resource id / object id) over IMDS, plus the system-assigned identity over Azure Arc, asserting the first call reaches the identity provider and the second is served from the cache (token_source). Uses the same lab identities as the Go / .NET MI E2E tests. Wire two OneBranch stages that run the test on the self-hosted lab pools (MISEManagedIdentity VM/IMDS and MISEAZUREARC), gated so IMDS cases run only on the IMDS pool (MSAL_TEST_MI_IMDS) and the Arc case only on an Arc machine; the test self-skips everywhere else. Exclude the E2E file from the unit stage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ad57850-7d1f-4b9a-bf9f-723d69a9687c * ci(mi-e2e): run pytest directly on self-hosted pools; drop runtime pip install The self-hosted MI E2E pools carry the toolchain (like Go/.NET); pre-provision msal's deps + pytest on them instead of installing at run time. This removes the runtime "pip install", which the Azure Arc pool cannot do anyway - its egress TLS inspection blocks files.pythonhosted.org (the same restriction MSAL Go documented). Also drop --timeout so pytest-timeout is not required. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ad57850-7d1f-4b9a-bf9f-723d69a9687c * ci(mi-e2e): resolve python.exe by full path so the step doesn't depend on the agent PATH Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ad57850-7d1f-4b9a-bf9f-723d69a9687c * test(mi-e2e): redact tokens in logs, close session, fork-guard self-hosted stages Address PR review feedback: - Assert access_token present on the cached (second) acquisition before indexing. - Never log token material: summarize error results to safe fields only, and compare the cached token to the original via SHA-256 digest instead of raw values. - Close the requests.Session in a finally block. - Add a stage-level fork guard so forked-PR code never runs on the self-hosted MISEManagedIdentity / MISEAZUREARC pools. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ad57850-7d1f-4b9a-bf9f-723d69a9687c --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ad57850-7d1f-4b9a-bf9f-723d69a9687c
1 parent b299e3b commit 9a207a9

2 files changed

Lines changed: 254 additions & 0 deletions

File tree

.Pipelines/template-pipeline-stages.yml

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ stages:
125125
--ignore=tests/test_e2e.py \
126126
--ignore=tests/test_e2e_manual.py \
127127
--ignore=tests/test_fmi_e2e.py \
128+
--ignore=tests/test_mi_e2e.py \
128129
--deselect tests/test_cryptography.py::CryptographyTestCase::test_ceiling_should_be_latest_cryptography_version_plus_three \
129130
--deselect tests/test_cryptography.py::CryptographyTestCase::test_should_be_run_with_latest_version_of_cryptography
130131
displayName: 'Run pytest (unit)'
@@ -275,3 +276,123 @@ stages:
275276
- bash: rm -f "$(Agent.TempDirectory)/lab-auth.pfx"
276277
displayName: 'Remove lab certificate from agent'
277278
condition: always()
279+
280+
# ══════════════════════════════════════════════════════════════════════════════
281+
# Stage 4 - MI E2E (IMDS) - REAL managed identity token acquisition on an Azure VM.
282+
# Runs on the self-hosted "MISEManagedIdentity" pool, a Windows Azure VM
283+
# that has the lab system-assigned + user-assigned identities assigned.
284+
# Mirrors the MSAL Go "MI E2E - IMDS" stage. Only the IMDS cases in
285+
# tests/test_mi_e2e.py run here (gated on MSAL_TEST_MI_IMDS); the Arc case
286+
# self-skips. No lab certificate is needed - the VM's own managed identity
287+
# is used.
288+
#
289+
# Pool assumptions (self-hosted): Python 3.x on PATH and outbound access to
290+
# PyPI for "pip install". Adjust if the pool differs.
291+
# ══════════════════════════════════════════════════════════════════════════════
292+
- stage: MIE2EImds
293+
displayName: 'MI E2E - IMDS'
294+
dependsOn: UnitTests
295+
# Fork guard: never run untrusted forked-PR code on the self-hosted pool.
296+
condition: and(eq(dependencies.UnitTests.result, 'Succeeded'), ne(variables['System.PullRequest.IsFork'], 'True'))
297+
jobs:
298+
- job: Pytest
299+
displayName: 'Managed Identity E2E - VM / IMDS'
300+
pool:
301+
type: windows
302+
isCustom: true
303+
name: MISEManagedIdentity
304+
timeoutInMinutes: 30
305+
variables:
306+
ob_outputDirectory: '$(Build.ArtifactStagingDirectory)'
307+
steps:
308+
# msal + its dependencies (requests, cryptography, PyJWT) and pytest are PRE-PROVISIONED on the
309+
# self-hosted pool (like the Go/.NET toolchains already are), so there is no runtime pip install.
310+
# This also avoids the Arc pool's egress TLS inspection blocking files.pythonhosted.org.
311+
- task: PowerShell@2
312+
displayName: 'Run pytest (MI E2E - IMDS)'
313+
inputs:
314+
targetType: 'inline'
315+
workingDirectory: '$(System.DefaultWorkingDirectory)'
316+
script: |
317+
$ErrorActionPreference = 'Stop'
318+
$py = (Get-Command python.exe -ErrorAction SilentlyContinue).Source
319+
if (-not $py) {
320+
$py = Get-ChildItem 'C:\Program Files\Python3*\python.exe','C:\Program Files (x86)\Python3*\python.exe','C:\Python3*\python.exe' -ErrorAction SilentlyContinue |
321+
Select-Object -First 1 -ExpandProperty FullName
322+
}
323+
if (-not $py) { throw 'Python not found on this agent. Install Python 3.x on the pool machine.' }
324+
Write-Host "Using Python: $py"
325+
& $py --version
326+
New-Item -ItemType Directory -Force -Path test-results | Out-Null
327+
& $py -m pytest -vv --junitxml=test-results/junit-mi-e2e-imds.xml tests/test_mi_e2e.py
328+
env:
329+
PYTHONUNBUFFERED: '1'
330+
MSAL_TEST_MI_IMDS: '1'
331+
332+
- task: PublishTestResults@2
333+
displayName: 'Publish MI E2E (IMDS) test results'
334+
condition: succeededOrFailed()
335+
inputs:
336+
testResultsFormat: 'JUnit'
337+
testResultsFiles: 'test-results/junit-mi-e2e-imds.xml'
338+
failTaskOnFailedTests: true
339+
testRunTitle: 'MI E2E - IMDS'
340+
341+
# ══════════════════════════════════════════════════════════════════════════════
342+
# Stage 5 - MI E2E (Azure Arc) - REAL managed identity token acquisition on an
343+
# Azure Arc-enabled machine. Runs on the self-hosted "MISEAZUREARC" pool.
344+
# Azure Arc supports the system-assigned identity only. Mirrors the MSAL Go
345+
# "MI E2E - Azure Arc" stage. tests/test_mi_e2e.py's Arc case runs here; the
346+
# IMDS cases self-skip (MSAL_TEST_MI_IMDS unset).
347+
#
348+
# Pool note: as observed for MSAL Go, the Arc machine's egress inspection can
349+
# reset TLS for package downloads. If "pip install" cannot reach PyPI, pre-provision
350+
# the dependencies on the pool (or use an offline wheel cache) and drop the install step.
351+
# ══════════════════════════════════════════════════════════════════════════════
352+
- stage: MIE2EAzureArc
353+
displayName: 'MI E2E - Azure Arc'
354+
dependsOn: UnitTests
355+
# Fork guard: never run untrusted forked-PR code on the self-hosted pool.
356+
condition: and(eq(dependencies.UnitTests.result, 'Succeeded'), ne(variables['System.PullRequest.IsFork'], 'True'))
357+
jobs:
358+
- job: Pytest
359+
displayName: 'Managed Identity E2E - Azure Arc'
360+
pool:
361+
type: windows
362+
isCustom: true
363+
name: MISEAZUREARC
364+
timeoutInMinutes: 30
365+
variables:
366+
ob_outputDirectory: '$(Build.ArtifactStagingDirectory)'
367+
steps:
368+
# msal + its dependencies (requests, cryptography, PyJWT) and pytest are PRE-PROVISIONED on the
369+
# self-hosted pool (like the Go/.NET toolchains already are), so there is no runtime pip install.
370+
# This is required here because the Arc machine's egress TLS inspection blocks files.pythonhosted.org.
371+
- task: PowerShell@2
372+
displayName: 'Run pytest (MI E2E - Azure Arc)'
373+
inputs:
374+
targetType: 'inline'
375+
workingDirectory: '$(System.DefaultWorkingDirectory)'
376+
script: |
377+
$ErrorActionPreference = 'Stop'
378+
$py = (Get-Command python.exe -ErrorAction SilentlyContinue).Source
379+
if (-not $py) {
380+
$py = Get-ChildItem 'C:\Program Files\Python3*\python.exe','C:\Program Files (x86)\Python3*\python.exe','C:\Python3*\python.exe' -ErrorAction SilentlyContinue |
381+
Select-Object -First 1 -ExpandProperty FullName
382+
}
383+
if (-not $py) { throw 'Python not found on this agent. Install Python 3.x on the pool machine.' }
384+
Write-Host "Using Python: $py"
385+
& $py --version
386+
New-Item -ItemType Directory -Force -Path test-results | Out-Null
387+
& $py -m pytest -vv --junitxml=test-results/junit-mi-e2e-arc.xml tests/test_mi_e2e.py
388+
env:
389+
PYTHONUNBUFFERED: '1'
390+
391+
- task: PublishTestResults@2
392+
displayName: 'Publish MI E2E (Azure Arc) test results'
393+
condition: succeededOrFailed()
394+
inputs:
395+
testResultsFormat: 'JUnit'
396+
testResultsFiles: 'test-results/junit-mi-e2e-arc.xml'
397+
failTaskOnFailedTests: true
398+
testRunTitle: 'MI E2E - Azure Arc'

tests/test_mi_e2e.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"""End-to-end Managed Identity tests (real token acquisition).
2+
3+
These tests perform REAL token acquisition and therefore only run on the
4+
self-hosted Azure DevOps pools that are actual Azure VM / Azure Arc machines with
5+
the lab managed identities assigned:
6+
7+
* IMDS tests -> the "MISEManagedIdentity" pool (an Azure VM). Gated on the
8+
MSAL_TEST_MI_IMDS environment variable, which that pipeline
9+
stage sets. (DEFAULT_TO_VM is also the fallback source on hosted
10+
agents, so an explicit flag is used instead of source detection.)
11+
* Azure Arc -> the "MISEAZUREARC" pool (an Azure Arc-enabled machine). Gated on
12+
the Azure Arc source being detected on the machine.
13+
14+
They mirror the MSAL Go E2E tests
15+
(apps/tests/e2e/managedidentity_e2e_test.go and managedidentity_arc_e2e_test.go)
16+
and use the SAME lab identities and ARM resource, so both SDKs exercise the same
17+
lab configuration on the same machines.
18+
19+
Everywhere else (hosted agents, local dev) the tests self-skip.
20+
"""
21+
import hashlib
22+
import os
23+
import unittest
24+
25+
import requests
26+
27+
from msal import (
28+
ManagedIdentityClient,
29+
SystemAssignedManagedIdentity,
30+
UserAssignedManagedIdentity,
31+
)
32+
from msal.managed_identity import get_managed_identity_source, AZURE_ARC
33+
34+
35+
# Azure Resource Manager resource. Matches the ARM scope used by the MSAL .NET and
36+
# Go managed identity E2E tests.
37+
_ARM_RESOURCE = "https://management.azure.com"
38+
39+
# User-assigned managed identities assigned to the MISEManagedIdentity VM. These are
40+
# the SAME values used by the MSAL Go / .NET IMDS E2E tests, so all SDKs exercise the
41+
# same lab configuration on the same VM.
42+
_UAMI_CLIENT_ID = "6325cd32-9911-41f3-819c-416cdf9104e7"
43+
_UAMI_OBJECT_ID = "ecb2ad92-3e30-4505-b79f-ac640d069f24"
44+
_UAMI_RESOURCE_ID = (
45+
"/subscriptions/c1686c51-b717-4fe0-9af3-24a20a41fb0c/resourcegroups/"
46+
"MSIV2-Testing-MSALNET/providers/Microsoft.ManagedIdentity/userAssignedIdentities/msiv2uami"
47+
)
48+
49+
50+
def _safe_error(result):
51+
"""Return a log-safe summary of a failed result.
52+
53+
Only the non-sensitive error fields are surfaced, so an assertion failure can
54+
never spill an access token (or the whole result dict) into the CI logs.
55+
"""
56+
return {
57+
key: result[key]
58+
for key in ("error", "error_description", "correlation_id")
59+
if key in result
60+
}
61+
62+
63+
def _acquire_token_twice_assert_caching(test, managed_identity):
64+
"""Acquire an ARM token twice for the given managed identity and assert the first
65+
call reaches the identity provider while the second is served from the token cache.
66+
67+
Shared by the IMDS and Azure Arc E2E tests, mirroring the Go helper of the same name.
68+
"""
69+
http_client = requests.Session()
70+
client = ManagedIdentityClient(managed_identity, http_client=http_client)
71+
try:
72+
first = client.acquire_token_for_client(resource=_ARM_RESOURCE)
73+
test.assertNotIn(
74+
"error", first, "first acquisition failed: {}".format(_safe_error(first)))
75+
test.assertIn("access_token", first)
76+
test.assertEqual(
77+
"identity_provider", first.get("token_source"),
78+
"first call should reach the identity provider")
79+
80+
second = client.acquire_token_for_client(resource=_ARM_RESOURCE)
81+
test.assertNotIn(
82+
"error", second, "second acquisition failed: {}".format(_safe_error(second)))
83+
test.assertIn("access_token", second)
84+
test.assertEqual(
85+
"cache", second.get("token_source"),
86+
"second call should be served from the token cache")
87+
# Compare tokens by SHA-256 digest so a mismatch never prints the actual
88+
# token material into CI logs.
89+
test.assertEqual(
90+
hashlib.sha256(first["access_token"].encode("utf-8")).hexdigest(),
91+
hashlib.sha256(second["access_token"].encode("utf-8")).hexdigest(),
92+
"cached token should match the original token")
93+
finally:
94+
http_client.close()
95+
96+
97+
@unittest.skipUnless(
98+
os.getenv("MSAL_TEST_MI_IMDS"),
99+
"Set MSAL_TEST_MI_IMDS to run on the MISEManagedIdentity Azure VM (IMDS) pool")
100+
class ManagedIdentityImdsE2ETestCase(unittest.TestCase):
101+
"""Acquires ARM tokens over IMDS v1 for the system-assigned identity and each
102+
user-assigned identity binding (client id / resource id / object id). Each test
103+
asserts the first call reaches the identity provider and the second is cached."""
104+
105+
def test_system_assigned(self):
106+
_acquire_token_twice_assert_caching(self, SystemAssignedManagedIdentity())
107+
108+
def test_user_assigned_client_id(self):
109+
_acquire_token_twice_assert_caching(
110+
self, UserAssignedManagedIdentity(client_id=_UAMI_CLIENT_ID))
111+
112+
def test_user_assigned_resource_id(self):
113+
_acquire_token_twice_assert_caching(
114+
self, UserAssignedManagedIdentity(resource_id=_UAMI_RESOURCE_ID))
115+
116+
def test_user_assigned_object_id(self):
117+
_acquire_token_twice_assert_caching(
118+
self, UserAssignedManagedIdentity(object_id=_UAMI_OBJECT_ID))
119+
120+
121+
@unittest.skipUnless(
122+
get_managed_identity_source() == AZURE_ARC,
123+
"Runs only on an Azure Arc-enabled machine (the MISEAZUREARC pool)")
124+
class ManagedIdentityAzureArcE2ETestCase(unittest.TestCase):
125+
"""Azure Arc supports the system-assigned identity only, so unlike the IMDS tests
126+
there are no user-assigned variants."""
127+
128+
def test_system_assigned(self):
129+
_acquire_token_twice_assert_caching(self, SystemAssignedManagedIdentity())
130+
131+
132+
if __name__ == "__main__":
133+
unittest.main()

0 commit comments

Comments
 (0)