Skip to content

fix(azure): restrict the storage credential chain to deployment identities - #39637

Merged
yucheng-berri merged 4 commits into
litellm_internal_stagingfrom
litellm_azure_storage_credential_scope
Sep 4, 2026
Merged

yucheng-berri merged 4 commits into
litellm_internal_stagingfrom
litellm_azure_storage_credential_scope

Conversation

@yucheng-berri

@yucheng-berri yucheng-berri commented Sep 3, 2026 •

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • Keyless Azure Storage logs in as any identity the host has
  • A developer's az login on the box writes the blobs
  • The Azure OpenAI service principal writes them too
  • Nothing says which identity was picked

How it solves it:

  • Storage asks the three identities a deployment can carry, and nothing else
  • Developer sign-ins and the LLM secret are out of the chain
  • Azure OpenAI, Postgres IAM auth and AI Search keep the full chain
  • Account key and storage service principal auth are untouched

User Flow

Before: an admin turns on Azure Storage logging without storage credentials, and the logs land in the storage account under whichever Azure identity happens to be signed in on the machine

  1. The admin sets AZURE_STORAGE_ACCOUNT_NAME and AZURE_STORAGE_FILE_SYSTEM and leaves the three AZURE_STORAGE_* service principal variables blank, which is what a Helm chart renders for an unset value
  2. The machine already has an az login session, or the AZURE_CLIENT_ID and AZURE_CLIENT_SECRET the admin set up for Azure OpenAI
  3. A user sends POST https://litellm-domain/v1/chat/completions and gets a normal 200
  4. A prompt and response log blob appears in the container, written as that signed-in person or as the Azure OpenAI principal, not as the identity the admin granted Storage Blob Data Contributor
  5. A user sends POST https://litellm-domain/v1/files with target_storage=azure_storage and gets a 200 with a file id, and their uploaded file lands in the same account under the same borrowed identity
  6. Anyone holding a virtual key can drive step 5, and if the account name is mistyped the borrowed identity's storage token is handed to whoever owns that account name

After: the same setup either uses the deployment's own Azure identity or refuses, and nothing else can stand in for it

  1. The admin sets the same two variables and leaves the same three blank
  2. The proxy runs on AKS with workload identity, or on a host with a managed identity, and that identity holds Storage Blob Data Contributor
  3. A user sends POST https://litellm-domain/v1/chat/completions and gets a normal 200
  4. The log blob appears in the container, written as the federated or managed identity
  5. A user sends POST https://litellm-domain/v1/files with target_storage=azure_storage and gets a 200, and the file lands under that same identity
  6. On a host with no such identity, step 5 returns 500 and the proxy log names the two credentials it tried, and a key holder can no longer make the proxy send anyone's storage token anywhere

Relevant issues

Linear ticket

Resolves LIT-6906

Pre-Submission checklist

  • I have added meaningful tests
  • The handful of test files covering my change pass locally, e.g. uv run pytest tests/test_litellm/<your_test_file>.py -v
  • My PR passes all required CI/CD checks (e.g., lint, schema.d.ts sync check, etc.)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review

Screenshots / Proof of Fix

Every case below was re-run on 9ed28d9.

Shared setup for every case: real Azure Blob Storage account litellm2, container lit6604-e2e, AZURE_STORAGE_ACCOUNT_NAME and AZURE_STORAGE_FILE_SYSTEM set, the three AZURE_STORAGE_* service principal variables exported blank, no account key. Chat traffic goes to real Gemini and real Azure OpenAI. Both proxies patch the expired local premium check so the callback runs; the audited code is untouched and the patch is byte identical on both sides. Every bearer token below is decoded off the Authorization header the proxy actually emits, so the identity is observed rather than inferred. The AKS cases run a disposable cluster created with --enable-oidc-issuer --enable-workload-identity, a service account federated to an Entra app that holds Storage Blob Data Contributor, and this branch's three modules overlaid into the stock ghcr.io/berriai/litellm:main-latest image with the sha printed in the pod.

Before (1c14ded)

Managed file upload, developer signed in on the host

  1. curl -sS -X POST http://127.0.0.1:20604/v1/files -H "Authorization: Bearer sk-1234" -F purpose=batch -F target_storage=azure_storage -F "file=@batch.jsonl"
  2. HTTP 200 and {"id":"file-9f33252874a249ea8a57246a","bytes":147,...,"status":"uploaded"}
  3. az storage blob list --account-name litellm2 --container-name lit6604-e2e --auth-mode login lists a87238a6-5e9d-4a48-9160-90cc1535d4f3.jsonl at 147 bytes
  4. The token on the wire decodes to {"aud":"https://storage.azure.com","idtyp":"user","upn":"yucheng@berri.ai","appid":"04b07795-8ddb-461a-bbee-02f9e1bf7b46"}, which is the Azure CLI first party app, so the blob was written as a person

Chat completion logged to Azure Storage, developer signed in on the host

  1. curl -sS -X POST http://127.0.0.1:20604/v1/chat/completions -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" -d '{"model":"gemini-2.5-flash","messages":[{"role":"user","content":"say the single word ok"}]}'
  2. HTTP 200 with "content":"ok" from real Gemini
  3. The blob listing gains e90e3354-ec7c-47fa-8759-c3606227eb61.json at 10880 bytes, the full prompt and response payload, written under that same human identity

Managed file upload, only the Azure OpenAI service principal on the host

  1. Start the proxy with AZURE_CONFIG_DIR pointing at an empty directory so no az login is reachable, and only AZURE_CLIENT_ID, AZURE_CLIENT_SECRET and AZURE_TENANT_ID set
  2. curl -sS -X POST http://127.0.0.1:20604/v1/files -H "Authorization: Bearer sk-1234" -F purpose=batch -F target_storage=azure_storage -F "file=@batch.jsonl"
  3. HTTP 200 and {"id":"file-6ea00e5173f14af2b91b8a70",...,"status":"uploaded"}, and the blob listing gains f868f8f5-79f4-48fa-a0be-e705311ab190.jsonl
  4. The token decodes to {"aud":"https://storage.azure.com","idtyp":"app","appid":"0ca1558c-fce6-4be7-b284-e7c77531b863"}, the LLM principal

Managed file upload with AZURE_TOKEN_CREDENTIALS=dev on the host

  1. Start the proxy with AZURE_TOKEN_CREDENTIALS=dev, which tells the Azure SDK to use developer credentials only
  2. curl -sS -X POST http://127.0.0.1:20606/v1/files -H "Authorization: Bearer sk-1234" -F purpose=batch -F target_storage=azure_storage -F "file=@batch.jsonl"
  3. HTTP 200 and {"id":"file-4ca17299827b4be0a7e64a6d",...,"status":"uploaded"}, and the blob listing gains 2e497199-b3a9-479c-b7d6-12faa1e30e43.jsonl
  4. The token decodes to the same upn":"yucheng@berri.ai" Azure CLI identity

Managed file download

  1. AzureBlobStorageBackend.download_file("https://litellm2.blob.core.windows.net/lit6604-e2e/a87238a6-5e9d-4a48-9160-90cc1535d4f3.jsonl") on the same host
  2. Returns 147 bytes starting {"custom_id":"req-1","method":"POST",...

AKS workload identity, every storage secret absent

  1. kubectl apply the probe pod with the base modules overlaid, then kubectl logs azstor-base -n azstor
  2. The pod prints "has_fix": false, the token {"aud":"https://storage.azure.com","idtyp":"app","appid":"0ca1558c-fce6-4be7-b284-e7c77531b863","oid":"7fc4140a-9e0b-4e11-9688-2ca973ab47b3"} and "uploaded": "azstor-chain-base-1788473984"
  3. The blob listing shows azstor-chain-base-1788473984.json at 70 bytes

AKS workload identity with AZURE_TOKEN_CREDENTIALS=dev

  1. The same pod plus AZURE_TOKEN_CREDENTIALS=dev
  2. The pod prints the same federated token and "uploaded": "azstor-chain-head-1788473984", so workload identity federation works under that setting today

Azure OpenAI through Entra ID token refresh

  1. curl -sS -X POST http://127.0.0.1:20608/v1/chat/completions -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" -d '{"model":"aoai-entra","messages":[{"role":"user","content":"hi"}]}' against the real litellm-ptu.openai.azure.com
  2. HTTP 401 from Azure with The principal 7fc4140a-9e0b-4e11-9688-2ca973ab47b3 lacks the required data action ..., so the token was minted from the full chain and presented

Azure VM on a system assigned managed identity, Azure OpenAI service principal also set

  1. A disposable Standard_B2s VM created with --assign-identity, its identity granted Storage Blob Data Contributor on the account, running the stock image with the base modules overlaid, with AZURE_CLIENT_ID, AZURE_CLIENT_SECRET and AZURE_TENANT_ID set for Azure OpenAI
  2. The probe writes the blob and prints {"aud":"https://storage.azure.com","idtyp":"app","appid":"0ca1558c-fce6-4be7-b284-e7c77531b863","oid":"7fc4140a-9e0b-4e11-9688-2ca973ab47b3"} and "uploaded": "sami2-base-sp-1788473364"
  3. That is the Azure OpenAI principal, not the VM's own identity, even though the VM's identity is the one holding Storage Blob Data Contributor

After (9ed28d9)

Managed file upload, developer signed in on the host

  1. curl -sS -X POST http://127.0.0.1:20605/v1/files -H "Authorization: Bearer sk-1234" -F purpose=batch -F target_storage=azure_storage -F "file=@batch.jsonl"
  2. HTTP 500 and {"error":{"message":"Internal server error","type":"internal_server_error"}}
  3. The blob listing gains nothing, and the proxy log names exactly what was tried: WorkloadIdentityCredential: ... authentication unavailable and ManagedIdentityCredential: ... no response from the IMDS endpoint
  4. Nothing reached Azure Storage, so no token left the process

Chat completion logged to Azure Storage, developer signed in on the host

  1. curl -sS -X POST http://127.0.0.1:20605/v1/chat/completions -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" -d '{"model":"gemini-2.5-flash","messages":[{"role":"user","content":"say the single word ok"}]}'
  2. HTTP 200 with "content":"ok" from real Gemini, so the chat path is unaffected
  3. The blob listing gains nothing

Managed file upload, only the Azure OpenAI service principal on the host

  1. Start the proxy the same way, AZURE_CONFIG_DIR empty and only AZURE_CLIENT_ID, AZURE_CLIENT_SECRET and AZURE_TENANT_ID set
  2. curl -sS -X POST http://127.0.0.1:20605/v1/files -H "Authorization: Bearer sk-1234" -F purpose=batch -F target_storage=azure_storage -F "file=@batch.jsonl"
  3. HTTP 500, the blob listing gains nothing, and the log again names only the two deployment credentials

Managed file upload with AZURE_TOKEN_CREDENTIALS=dev on the host

  1. Start the proxy with AZURE_TOKEN_CREDENTIALS=dev
  2. curl -sS -X POST http://127.0.0.1:20607/v1/files -H "Authorization: Bearer sk-1234" -F purpose=batch -F target_storage=azure_storage -F "file=@batch.jsonl"
  3. HTTP 500, the blob listing gains nothing, and the log names WorkloadIdentityCredential and ManagedIdentityCredential, so the developer only setting cannot empty the chain or reintroduce a developer sign in

Managed file download

  1. The same download_file call on the same host
  2. Raises ClientAuthenticationError naming the same two credentials, and no blob bytes are returned

AKS workload identity, every storage secret absent

  1. kubectl logs azstor-head -n azstor
  2. The pod prints "has_fix": true, the same token {"aud":"https://storage.azure.com","idtyp":"app","appid":"0ca1558c-fce6-4be7-b284-e7c77531b863","oid":"7fc4140a-9e0b-4e11-9688-2ca973ab47b3"} and "uploaded": "azstor-chain-head-1788473984"
  3. The blob listing shows azstor-chain-head-1788473984.json at 70 bytes, so workload identity federation is untouched

AKS workload identity with AZURE_TOKEN_CREDENTIALS=dev

  1. kubectl logs azstor-head-atc -n azstor
  2. The pod prints the same federated token and "uploaded": "azstor-chain-head-1788473984", and the blob listing shows that blob at 73 bytes, so a deployment that sets this variable keeps working

Azure OpenAI through Entra ID token refresh

  1. The same curl against the same real endpoint on port 20609
  2. HTTP 401 from Azure with Principal does not have access to API/Operation., the same authorization refusal against the same principal, so the Azure OpenAI credential chain is unchanged

Admin UI still shows Azure Storage logging turned on

  1. Open http://127.0.0.1:20605/ui/?page=logging-and-alerts on the fixed build and sign in with the master key
  2. The Logging Callbacks tab lists azure_storage with mode Success & Failure, so the callback is registered and running; only the identity it authenticates with changed

Admin UI logging callbacks on the fixed build

Azure VM on a system assigned managed identity, Azure OpenAI service principal also set

  1. The same VM, the same identity, the same three Azure OpenAI variables, this branch's modules overlaid
  2. The probe writes the blob and prints {"aud":"https://storage.azure.com","idtyp":"app","appid":"b9639c5a-a292-4fef-9038-81816764e0b4","oid":"e048a658-04fd-4bf5-951f-c3d2729a623e"} and "uploaded": "sami2-head-sp-1788473364"
  3. That is the VM's own system assigned identity, so the deployment keeps working and the Azure OpenAI principal is no longer used
  4. With AZURE_CLIENT_ID unset the result is the same identity and "uploaded": "sami2-head-noid-1788473364"

Reviewer check in the Azure portal

  1. Open https://portal.azure.com and go to Storage accounts, then litellm2, then Storage browser, then Blob containers, then lit6604-e2e
  2. Sort by Last modified and expect azstor-chain-base-1788473984.json, azstor-chain-head-1788473984.json and azstor-chain-head-1788473984.json from the AKS cases, sami2-base-sp-1788473364.json and sami2-head-sp-1788473364.json from the VM cases, and no blob at all from the After cases on the laptop

Type

🐛 Bug Fix

Caveats (if any)

Severe

  • Blank storage credentials plus a developer sign-in now fail instead of writing
  • AZURE_CLIENT_SECRET no longer reaches storage, use AZURE_STORAGE_CLIENT_* instead

Medium

  • AZURE_CLIENT_ID names one identity proxy wide
    • A user assigned identity named there is tried for storage before the system assigned one
    • So if it points at the Azure OpenAI user assigned identity and that identity also holds Storage Blob Data Contributor, storage still uses it
    • Splitting them needs a storage specific client id, tracked on LIT-6906
  • A misconfigured keyless upload still returns a generic 500 body
  • Hosts that now fail pay an IMDS probe on every flush, tracked on LIT-6906

Low

  • AZURE_CREDENTIAL=DeploymentIdentityCredential is newly accepted for every caller, previously an AttributeError
  • Other bare DefaultAzureCredential sites keep the full chain, listed on LIT-6906

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

ran /live-pr-risk and found no regressions/backward incompatible risks


Note

Medium Risk
Intentionally breaks keyless storage on machines that relied on CLI or LLM service-principal auth, but changes are scoped to storage token acquisition and are security-motivated.

Overview
Keyless Azure Blob Storage logging and file uploads (when AZURE_STORAGE_* service principal vars and account key are unset) no longer authenticate with DefaultAzureCredential. They now use a new DeploymentIdentityCredential mode that only tries workload identity (when federated token env vars are present), then user-assigned managed identity (if AZURE_CLIENT_ID is set), then system-assigned managed identity.

That blocks developer sign-ins (az login), AZURE_CLIENT_SECRET / environment-based service principals, and other developer credentials from minting storage tokens—hosts without a deployment identity fail auth instead of writing blobs under the wrong principal. DefaultAzureCredential and other credential types are unchanged for Azure OpenAI and other callers; dedicated storage SP and account-key paths are untouched.

Tests cover the credential chain composition, isolation from DefaultAzureCredential, and the Azure storage logger wiring.

Reviewed by Cursor Bugbot for commit 9ed28d9. Bugbot is set up for automated code reviews on this repo. Configure here.

…ities

The keyless Azure Storage path walks the full DefaultAzureCredential chain, so a
proxy with no storage service principal authenticates as whichever identity the
host happens to carry: an operator's az login on a workstation, or the
AZURE_CLIENT_ID/AZURE_CLIENT_SECRET service principal set for Azure OpenAI.
Neither is the identity granted Storage Blob Data Contributor.

Narrow the chain to workload identity and managed identity, the two credentials
a deployment legitimately holds. Azure OpenAI, Postgres IAM auth and the other
callers of get_azure_ad_token_provider keep the full chain.

@devin-ai-integration devin-ai-integration Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This report is out of date. Scroll down for Devin Review's latest report on this PR.

🔍 Devin Review: 2 flags

Not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)

Devin Review

@greptile-apps

greptile-apps Bot commented Sep 3, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR hardens Azure Storage authentication while leaving other Azure token-provider callers on their existing credential behavior.

  • Adds an explicit deployment-identity credential chain.
  • Selects that chain for keyless Azure Storage operations.
  • Adds focused coverage for workload and managed identities, environment isolation, and storage-scoped token creation.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
litellm/integrations/azure_storage/azure_storage.py Selects the deployment-identity credential type for cached, storage-scoped token providers.
litellm/secret_managers/get_azure_ad_token_provider.py Adds explicit construction of a workload-identity and managed-identity credential chain.
litellm/types/secret_managers/get_azure_ad_token_provider.py Extends the Azure credential enum with the deployment-identity option.
tests/test_litellm/integrations/azure_storage/test_azure_storage.py Updates storage credential expectations and replaces mutable credential capture with direct mock-call inspection.
tests/test_litellm/secret_managers/test_get_azure_ad_token_provider.py Covers the deployment credential chain across workload, user-assigned, and system-assigned identity configurations.

Reviews (4): Last reviewed commit: "fix(azure): let the storage chain reach ..." | Re-trigger Greptile

greptile-apps[bot]

This comment was marked as resolved.

@codspeed

codspeed Bot commented Sep 3, 2026 •

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_azure_storage_credential_scope (9ed28d9) with litellm_internal_staging (942a46f)

Open in CodSpeed

@yucheng-berri

Copy link
Copy Markdown
Contributor Author

@greptileai please review the current head 0f61ead. The accumulator in both test helpers is gone, replaced by the mock's own call record.

@codecov

codecov Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@yucheng-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

cursor[bot]

This comment was marked as resolved.

@yucheng-berri

Copy link
Copy Markdown
Contributor Author

@greptileai please review the current head f52771c. It deletes a stray file; the source is unchanged.

@yucheng-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

1 issue from previous review remains unresolved.

Fix All in Cursor

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit f52771c. Configure here.

…ntity

DefaultAzureCredential keeps one managed identity link and pins it to
AZURE_CLIENT_ID, so a host that sets that variable for Azure OpenAI and runs as
a system assigned identity never got asked for a storage token. Build the chain
from the three credentials a deployment can carry instead of subtracting the
ones it cannot.
@yucheng-berri

Copy link
Copy Markdown
Contributor Author

@greptileai please review the current head 9ed28d9. The storage chain is now built explicitly instead of by excluding credentials.

@yucheng-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

2 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)

Devin Review

Comment on lines +125 to +126
*((ManagedIdentityCredential(client_id=workload_client_id),) if workload_client_id else ()),
ManagedIdentityCredential(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 System identity fallback never runs

When AZURE_CLIENT_ID names an unavailable user identity, ChainedTokenCredential stops on its error. The system identity is never attempted, so storage fails

Prompt for agents
The DeploymentIdentityCredential branch creates a ChainedTokenCredential with the client-ID-selected managed identity before the system-assigned managed identity. Azure's ChainedTokenCredential continues only after CredentialUnavailableError. An IMDS response such as "Identity not found" for the selected client ID is an authentication error, so the chain stops and never calls the system-assigned credential. This leaves Azure VMs with a valid system identity unable to use storage whenever the proxy-wide AZURE_CLIENT_ID names a different service identity. Rework this fallback so a missing selected managed identity allows the system-assigned attempt without hiding genuine managed-identity failures. Add a behavioral token-acquisition test where the selected identity returns "Identity not found" and the system identity succeeds.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested that exact case on a real VM: an unavailable user identity raises CredentialUnavailableError, so the chain continues and the system identity wins.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 9ed28d9. Configure here.

@yucheng-berri
yucheng-berri enabled auto-merge (squash) September 4, 2026 01:23
@yucheng-berri
yucheng-berri merged commit 4e18c0f into litellm_internal_staging Sep 4, 2026
83 of 84 checks passed
@yucheng-berri
yucheng-berri deleted the litellm_azure_storage_credential_scope branch September 4, 2026 01:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants