Showing posts with label Azure. Show all posts
Showing posts with label Azure. Show all posts

Monday, August 24, 2026

Azure DevOps: Copy Azure Container Registry (ACR) Images to an External Tenant's ACR

Consider a scenario where an App Service running in an external tenant needs to pull an image from our own ACR. The lazy way is to share our ACR credentials (username/password) with the external party, so they can set DOCKER_REGISTRY_SERVER_USERNAME and DOCKER_REGISTRY_SERVER_PASSWORD.

But obviously that's not something we should be doing. We can't be handing our own passwords to anyone, and we shouldn't be using passwords or secrets in the first place.

You might think the fix is for them to give their App Service a managed identity and grant it AcrPull on our ACR. That doesn't work across tenants. A managed identity only exists in its own tenant.

What does work is copying the image into their ACR first. Then their App Service pulls from their own registry, in their own tenant, with their own managed identity, and that's just the ordinary same-tenant case.

So in this post, let's see how we can copy Azure Container Registry (ACR) images to an external tenant's ACR in Azure DevOps..

Prerequisites
  • Both registries need public network access. A registry on Selected networks still counts as enabled, but it also has to allow trusted Azure services to bypass the network, which is on by default.
  • Both registries accept Entra ARM tokens. You can test it using the following script.
$TENANT_ID = "<TENANT_ID>"
$SUBSCRIPTION_ID = "<SUBSCRIPTION_ID>"

$ACR_RESOURCE_GROUP_NAME = "<ACR_RESOURCE_GROUP_NAME>"
$ACR_NAME = "<ACR_NAME>"
$ACR_RESOURCE_ID = "/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$ACR_RESOURCE_GROUP_NAME/providers/Microsoft.ContainerRegistry/registries/$ACR_NAME"

# Login to tenant and set the subscription
az login `
  --tenant $TENANT_ID

az account set `
  --subscription $SUBSCRIPTION_ID

# Confirm the registry accepts Entra ARM tokens
az rest `
  --method get `
  --url "https://management.azure.com$ACR_RESOURCE_ID`?api-version=2023-01-01-preview" `
  --query "properties.policies.azureADAuthenticationAsArmPolicy.status" `
  --output tsv

The concept

  • Two Azure DevOps service connections, both in our project, each federated to a different managed identity in a different tenant. No secret in either one, just a trust.
  • Ours/Source: A user-assigned managed identity with AcrPull on our ACR, and a service connection federated to it. We add the federated credential to that identity ourselves, because it lives in our tenant.
    • If your source registry is ABAC-enabled, grant Container Registry Repository Reader instead. AcrPull isn't honoured there.
  • Theirs/External:  A user-assigned managed identity with Container Registry Data Importer and Data Reader on their ACR. We create the second service connection pointing at their tenant, and send them the Issuer and Subject it generates. They add the federated credential to their identity.
  • At deploy time the pipeline mints a short-lived token using the first connection, then calls az acr import with the second connection.

az acr import `
  --name "<THEIR_ACR_NAME>" `
  --resource-group "<THEIR_ACR_RESOURCE_GROUP_NAME>" `
  --source "<OUR_ACR_LOGIN_SERVER>/<IMAGE_NAME>:<IMAGE_TAG>" `
  --image "<IMAGE_NAME>:<IMAGE_TAG>" `
  --password "<ACCESS_TOKEN_CREATED_USING_OUR_SERVICE_CONNECTION>"

    • Note there is no --username. The access token is only accepted as a lone --password, and adding a username turns it into a basic auth pair.
    • az acr import authenticates the source and the target separately. 
    • The call goes to their registry as an identity in their tenant, and our registry is named in the same request with its own credential alongside. 
    • Their registry then pulls the image straight from ours, server to server, so the build agent never downloads it.

Now let's see this in action.

Some variables.

$SOURCE_TENANT_ID = "<OUR_TENANT_ID>"
$SOURCE_SUBSCRIPTION_ID = "<OUR_SUBSCRIPTION_ID>"

# Identity
$MIRROR_IDENTITY_NAME = "<MANAGED_IDENTITY_NAME>"
$MIRROR_IDENTITY_RESOURCE_GROUP_NAME = "<MANAGED_IDENTITY_RESOURCE_GROUP_NAME>"
$MIRROR_IDENTITY_LOCATION = "<LOCATION>"

# Source ACR
$SOURCE_ACR_RESOURCE_GROUP_NAME = "<ACR_RESOURCE_GROUP>"
$SOURCE_ACR_NAME = "<ACR_NAME>"
$SOURCE_ACR_RESOURCE_ID = "/subscriptions/$SOURCE_SUBSCRIPTION_ID/resourceGroups/$SOURCE_ACR_RESOURCE_GROUP_NAME/providers/Microsoft.ContainerRegistry/registries/$SOURCE_ACR_NAME"

First step is creating a Managed Identity.

# Login to the tenant and set subscription
az login `
  --tenant $SOURCE_TENANT_ID

az account set `
  --subscription $SOURCE_SUBSCRIPTION_ID

# Create a user-assigned managed identity
az identity create `
  --name $MIRROR_IDENTITY_NAME `
  --resource-group $MIRROR_IDENTITY_RESOURCE_GROUP_NAME `
  --subscription $SOURCE_SUBSCRIPTION_ID `
  --location $MIRROR_IDENTITY_LOCATION

$sourceIdentity = az identity show `
  --name $MIRROR_IDENTITY_NAME `
  --resource-group $MIRROR_IDENTITY_RESOURCE_GROUP_NAME `
  --subscription $SOURCE_SUBSCRIPTION_ID | ConvertFrom-Json

$MIRROR_IDENTITY_PRINCIPAL_ID = $sourceIdentity.principalId
$MIRROR_IDENTITY_CLIENT_ID = $sourceIdentity.clientId

Now let's grant the MI AcrPull OR Container Registry Repository Reader on our ACR.

# Assign the AcrPull OR Container Registry Repository Reader role to the managed identity
az role assignment create `
  --assignee-object-id $MIRROR_IDENTITY_PRINCIPAL_ID `
  --assignee-principal-type ServicePrincipal `
  --role "<AcrPull OR Container Registry Repository Reader>" `
  --scope $SOURCE_ACR_RESOURCE_ID

Now let's create the source Service Connection.

New Service Connection: Azure Resource Manager

Note: 

  • Identity Type: App registration or managed identity (manual)
  • Credential: Workload identity federation
  • Directory (tenant) ID: Our Tenant ID.

New Service Connection App Registration Details
Here, the Application (client) ID is the MIRROR_IDENTITY_CLIENT_ID.

Now before clicking on Verify and save, run the following using displayed Issuer and Subject identifier to create federated credentials.

# Create federated credential for the managed identity
az identity federated-credential create `
  --name azure-devops-mirror-source `
  --identity-name $MIRROR_IDENTITY_NAME `
  --resource-group $MIRROR_IDENTITY_RESOURCE_GROUP_NAME `
  --subscription $SOURCE_SUBSCRIPTION_ID `
  --issuer "<Issuer>" `
  --subject "<Subject identifier>" `
  --audiences "api://AzureADTokenExchange"
After this is executed, wait for a few seconds and then click on Verify and save. It should be verified and saved successfully.

Now we need to configure the external side. Almost all of the steps are exact same as above with different values. On external side,
  • The managed identity needs to be granted role: Container Registry Data Importer and Data Reader on their ACR.
  • To create the second service connection, the only thing we need from them up front is their Tenant ID. Note the Directory (tenant) ID is their tenant, not ours.
    • Fill in the tenant id, move to the next step, and Azure DevOps generates the Issuer and Subject identifier. Copy both and select Keep as draft
    • Send them the Issuer and Subject identifier so they can create the federated credential on their managed identity. When they confirm, they need to send back the following
      • Subscription ID
      • Subscription Name
      • ACR Name
      • ACR Resource Group Name
      • Client ID of their Managed Identity
    • Once the information is received, go back to the draft connection, fill those in, then Finish setup and Verify and save.
One caution: Tenant ID cannot be edited after the connection is created. If it is wrong, you have to delete the connection and create a new one, and therefore a new Subject identifier, so the federated credential has to be recreated as well.

At this point, second service connection should be verified and saved successfully.

Now the moment of truth. We can create a simple pipeline to test the az acr import end-to-end.
trigger: none
pr: none

parameters:
  - name: sourceImageName
    displayName: Source image, repository:tag with no host
    type: string
    default: <IMAGE_NAME>:<IMAGE_TAG>

pool:
  vmImage: ubuntu-latest

variables:
  sourceServiceConnection: <SOURCE_SERVICE_CONNECTION_NAME>
  targetServiceConnection: <EXTERNAL_SERVICE_CONNECTION_NAME>
  sourceAcrLoginServer: <SOURCE_ACR_NAME>.azurecr.io
  targetAcrName: <TARGET_ACR_NAME>
  targetAcrResourceGroup: <TARGET_ACR_RESOURCE_GROUP>

steps:
  - task: AzureCLI@2
    displayName: Get source ACR read token
    inputs:
      azureSubscription: $(sourceServiceConnection)
      scriptType: pscore
      scriptLocation: inlineScript
      inlineScript: |
        az account show `
          --query "{tenant:tenantId, subscription:name, id:id, principal:user.name}" `
          --output table

        $SOURCE_ACCESS_TOKEN = (az account get-access-token `
          --query accessToken `
          --output tsv).Trim()

        Write-Host "##vso[task.setvariable variable=sourceAccessToken;issecret=true]$SOURCE_ACCESS_TOKEN"

  - task: AzureCLI@2
    displayName: Import image into target ACR
    inputs:
      azureSubscription: $(targetServiceConnection)
      scriptType: pscore
      scriptLocation: inlineScript
      inlineScript: |
        az account show `
          --query "{tenant:tenantId, subscription:name, id:id, principal:user.name}" `
          --output table

        az acr import `
          --name "$(targetAcrName)" `
          --resource-group "$(targetAcrResourceGroup)" `
          --source "$(sourceAcrLoginServer)/${{ parameters.sourceImageName }}" `
          --image "${{ parameters.sourceImageName }}" `
          --password "$(sourceAccessToken)" `
          --force

  - task: AzureCLI@2
    displayName: Confirm the tag landed
    inputs:
      azureSubscription: $(targetServiceConnection)
      scriptType: pscore
      scriptLocation: inlineScript
      inlineScript: |
        $IMAGE_NAME = "${{ parameters.sourceImageName }}"

        az acr repository show-tags `
          --name "$(targetAcrName)" `
          --repository ($IMAGE_NAME.Split(":")[0]) `
          --output table
And when you run this, it should do the import.
Pipeline Run
Hope this helps.

Happy Coding.

Regards,
Jaliya

Thursday, August 20, 2026

Use of AZURE_TOKEN_CREDENTIALS in Azure.Identity

In this post, let's have a look at the AZURE_TOKEN_CREDENTIALS environment variable in Azure.Identity. I only came across it recently, and it has removed something that used to annoy me every single day.

We all know why we should be authenticating to Azure services with Microsoft Entra ID rather than with keys, connection strings or passwords. 
TokenCredential credential = new DefaultAzureCredential();
When I am working on something locally, what I used to do is grant my own Azure account access to the development resource, sign in through Visual Studio or the Azure CLI, and let DefaultAzureCredential pick that identity up. 

But this can be a pain when running locally. DefaultAzureCredential is a chain. It attempts credentials one after the other, in order, and the first one that returns a token wins. The deployed service credentials (EnvironmentCredentialWorkloadIdentityCredentialManagedIdentityCredential) come first and then the developer tool credentials like VisualStudioCredentialAzureCliCredential, AzureDeveloperCliCredential etc.

In our local machine, deployed credentials obviously won't work, but still it will get tried. ManagedIdentityCredential is the worst among them, because it actually goes out to the Azure Instance Metadata Service (IMDS) endpoint at 169.254.169.254 and only gives up once that socket fails or times out. So every run and every debug session pays for these guaranteed failures.

Let's look by an example. I am using latest Azure.Identity package as of today.
<PackageReference Include="Azure.Identity" Version="1.21.0" />
Here is the basic code.
using Azure.Core;
using Azure.Core.Diagnostics;
using Azure.Identity;
using System.Diagnostics.Tracing;

using AzureEventSourceListener listener = new((eventArgs, message) =>
{
    // Helper method to print out the provider
    PrintProvider(eventArgs, message);
}, EventLevel.Informational);

TokenCredential credential = new DefaultAzureCredential();

// Omitted: some call that exercises the credentials

Console.WriteLine("\nDone");
When I run this, I can see something like below:
Trying   : DefaultAzureCredential
Trying   : EnvironmentCredential
Trying   : WorkloadIdentityCredential
Trying   : ManagedIdentityCredential
Trying   : VisualStudioCredential
Selected : VisualStudioCredential

Done
You can see all the credentials it's trying and it takes unnecessary time. For a long time my workaround was to branch on the environment, and when running in Development, switch off the credentials that were never going to work anyway, using the Exclude prefixed properties on DefaultAzureCredentialOptions, something like below.
TokenCredential credential = new DefaultAzureCredential();

// Environment check, could be ASPNETCORE_ENVIRONMENT, AZURE_FUNCTIONS_ENVIRONMENT etc.
bool isDevelopment = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") == "Development";
if (isDevelopment)
{
    credential = new DefaultAzureCredential(new DefaultAzureCredentialOptions
    {
        ExcludeEnvironmentCredential = true,
        ExcludeWorkloadIdentityCredential = true,
        ExcludeManagedIdentityCredential = true,
    });
}
With this, it will print something like this.
Trying   : DefaultAzureCredential
Trying   : VisualStudioCredential
Selected : VisualStudioCredential

Done
It works, but it's a pain to do this in each project I am working on.

Since Azure.Identity 1.14.0, all of the above can be replaced with an environment variable. Set AZURE_TOKEN_CREDENTIALS to dev and the chain drops every deployed service credential, leaving only the developer tool ones.
{
  "profiles": {
    "ConsoleApp1": {
      "commandName": "Project",
      "environmentVariables": {
        "DOTNET_ENVIRONMENT": "Development",
        "AZURE_TOKEN_CREDENTIALS": "dev"
      }
    }
  }
}

And the code then goes back to being the single line.
TokenCredential credential = new DefaultAzureCredential();
Exactly the same outcome as the Exclude block, with no branching, nothing to maintain. It is purely configuration.

One thing to keep in mind is that if AZURE_TOKEN_CREDENTIALS isn't set at all, DefaultAzureCredential quietly falls back to the full chain. So if someone clones the repo without the launch profile, or the variable gets dropped somewhere along the way, you are silently back to where you started. If you would rather fail fast, since Azure.Identity 1.16.0 there is a constructor overload that takes the environment variable name and requires it to be set to a valid value.
TokenCredential credential =
    new DefaultAzureCredential(DefaultAzureCredential.DefaultEnvironmentVariableName);
Now if it's missing, you get a clear error instead of a silent fallback.
Unhandled exception. 
System.InvalidOperationException: Environment variable 'AZURE_TOKEN_CREDENTIALS' is not set or is empty.
And if you typo the value, it throws as well and lists every value it accepts, which is quite handy.

There is a prod value too, which does the opposite and keeps only the deployed service credentials. And from Azure.Identity 1.15.0 onwards you can go further and name a single credential, which reduces the chain to just that one. The comparison is case insensitive.
"AZURE_TOKEN_CREDENTIALS": "VisualStudioCredential"
A small feature, but if you have been quietly copying that Exclude block around for years like I have, it is a life saver.

Happy Coding.

Regards,
Jaliya

Thursday, August 13, 2026

Using Azure AI Foundry resource for .NET Azure.AI.TextAnalytics and Azure.AI.Translation.Text Clients

In this post, let's see how we can move .NET clients from consuming Azure AI Language and Azure AI Translator resource onto a single Azure AI Foundry resource.

I had some .NET code using TextAnalyticsClient (Azure.AI.TextAnalytics) and TextTranslationClient (Azure.AI.Translation.Text). These were pointing to two different Azure AI resources.
// API Kind: TextAnalytics
https://lang-demo-service-001.cognitiveservices.azure.com

// API Kind: TextTranslation
https://trsl-demo-service-001.cognitiveservices.azure.com
These are the NuGet packages (latest as of today, it will change).
<ItemGroup>
  <PackageReference Include="Azure.AI.TextAnalytics" Version="5.3.0" />
  <PackageReference Include="Azure.AI.Translation.Text" Version="2.0.0" />
  <PackageReference Include="Azure.Identity" Version="1.21.0" />
</ItemGroup>
And this is the existing code.
using Azure;
using Azure.AI.TextAnalytics;
using Azure.AI.Translation.Text;
using Azure.Identity;

DefaultAzureCredential credentials = new DefaultAzureCredential();

// API Kind: TextAnalytics
string languageEndpoint = "https://lang-demo-service-001.cognitiveservices.azure.com";

TextAnalyticsClient textAnalyticsClient = new(new Uri(languageEndpoint), credentials);
Azure.AI.TextAnalytics.DetectedLanguage response =
    await textAnalyticsClient.DetectLanguageAsync("Hello");
Console.WriteLine(response.Name);

// API Kind: TextTranslation
string translatorEndpoint = "https://trsl-demo-service-001.cognitiveservices.azure.com";

TextTranslationClient textTranslationClient = new(credentials, new Uri(translatorEndpoint));
Response<IReadOnlyList<TranslatedTextItem>> translateTextResponse =
    await textTranslationClient.TranslateAsync("fr", "Hello", "en");

TranslatedTextItem? translatedTextItem = translateTextResponse.Value.FirstOrDefault();
Console.WriteLine(translatedTextItem?.Translations.FirstOrDefault()?.Text);
I didn't want to maintain more services when we can have single Microsoft Foundry resource (API Kind: AIServices). It's a multi-service resource, it bundles Language, Translator, Speech, Vision and more, so it's one resource, one credential and one endpoint in configuration.

I thought that should be a simple endpoint change.
string apiEndpoint = "https://aif-demo-service-001.services.ai.azure.com";

DefaultAzureCredential credentials = new DefaultAzureCredential();

// Language
TextAnalyticsClient textAnalyticsClient = new(new Uri(apiEndpoint), credentials);

// Translation
TextTranslationClient textTranslationClient = new(credentials, new Uri(apiEndpoint));

// Omitted for brevity
TextAnalyticsClient seemed to work, it wrote out English. But TextTranslationClient didn't.
English

Unhandled exception. Azure.RequestFailedException: Resource not found Status: 404(Resource Not Found) ErrorCode: 404 Content: { "error":{ "code":"404","message": "Resource not found"} } Headers: apim - request - id: REDACTED Strict-Transport-Security: REDACTED X-Content-Type-Options: REDACTED Date: Tue, 11 Aug 2026 08:57:57 GMT Content-Length: 56 Content - Type: application / json at Azure.AI.Translation.Text.ClientPipelineExtensions.ProcessMessageAsync(HttpPipeline pipeline, HttpMessage message, RequestContext context) at Azure.AI.Translation.Text.TextTranslationClient.TranslateAsync(RequestContent content, String clientTraceId, RequestContext context) at Azure.AI.Translation.Text.TextTranslationClient.TranslateAsync(IEnumerable`1 inputs, CancellationToken cancellationToken) at Program.< Main >$(String[] args) in C: \Users\Jaliya\Desktop\ConsoleApp1\ConsoleApp1\Program.cs:line 45 at Program.<Main>(String[] args)
My first thought was that Translator simply isn't served on the services.ai.azure.com endpoint. That's not it, a raw request against the very same endpoint works.
$token = az account get-access-token `
    --resource https://cognitiveservices.azure.com `
    --query accessToken -o tsv

curl -X POST "https://aif-demo-service-001.services.ai.azure.com/translator/text/v3.0/translate?api-version=3.0&to=fr" `
     -H "Authorization: Bearer $token" `
     -H "Content-Type: application/json" `
     -d "[{'Text':'Hello'}]"

# 200 OK
#[
#    {
#        "detectedLanguage": {
#            "language": "en",
#            "score": 1.0
#        },
#        "translations": [
#            {
#                "text": "Bonjour",
#                "to": "fr"
#            }
#        ]
#    }
#]     
So the host is fine, and note the route, Translator lives under /translator/text. May be something fishy with the SDK.

Looking at the source of Azure.AI.Translation.Text, this is how it decides whether to add that route prefix.
private const string PLATFORM_HOST = "cognitiveservices";

internal static bool IsPlatformHost(this Uri uri)
{
    return uri.Host?.Contains(PLATFORM_HOST) == true;
}
Which is then used in the constructor.
private const string PLATFORM_PATH = "/translator/text";

if (endpoint.IsPlatformHost())
{
    this._endpoint = new Uri(endpoint, PLATFORM_PATH);
}
There it is. The prefix is added only when the endpoint host contains the literal string cognitiveservices.

My old endpoint was trsl-demo-service-001.cognitiveservices.azure.com, which contains it, so the SDK built /translator/text/translate. The new one is aif-demo-service-001.services.ai.azure.com, which doesn't, so the SDK treated it like a global endpoint and posted to /translate. Hence the 404.

The fix is to pass the prefix ourselves when constructing the Uri.
using Azure;
using Azure.AI.Translation.Text;
using Azure.Identity;

string apiEndpoint = "https://aif-inf-sl-tenant1-dev-001.services.ai.azure.com";

DefaultAzureCredential credentials = new DefaultAzureCredential();

// Translation
TextTranslationClient textTranslationClient = new(credentials, new Uri($"{apiEndpoint}/translator/text"));
Response<IReadOnlyList<TranslatedTextItem>> translateTextResponse =
    await textTranslationClient.TranslateAsync("fr", "Hello", "en");

TranslatedTextItem? translatedTextItem = translateTextResponse.Value.FirstOrDefault();
Console.WriteLine(translatedTextItem?.Translations.FirstOrDefault()?.Text);
And now both work off the single endpoint.
English
Bonjour
One thing I wanted to confirm, what happens if a future version of the SDK starts recognizing services.ai.azure.com? Would it add the prefix on top of mine and put me back at a 404? No, because PLATFORM_PATH is rooted, so new Uri(endpoint, PLATFORM_PATH) replaces the path instead of appending to it.
Uri uri = new Uri(new Uri("https://aif-demo-service-001.services.ai.azure.com/translator/text"), "/translator/text");
Console.WriteLine(uri.ToString());

// https://aif-demo-service-001.services.ai.azure.com/translator/text
I have raised this with the SDK team as azure-sdk-for-net#61912, so the behavior might change.

More read:

Hope this helps.

Happy Coding.

Regards,
Jaliya

Sunday, July 19, 2026

Azure Content Understanding: Classify and Route to Different Analyzers

In this post, let's have a look at how we can classify a document that contains multiple document types, and route each part to a different analyzer, using Azure AI Content Understanding and the .NET SDK.

Say you receive a single PDF that contains an invoice, a bank statement and a loan application, all combined together, something like this:
Combined Document
Now you want each part identified, and you want different fields extracted from each. Content Understanding supports exactly this: a classifier splits the file into segments, and each category can optionally point at its own analyzer that does the field extraction.

Let's have a look on how to achieve this. 

We are going to create two custom analyzers, one for invoices and one for loan applications. Then we are going to create a classifier with three categories, where the third one (Bank_Statement) is deliberately left without an analyzer. That last bit turns out to be the most interesting part of the whole exercise.

First, the packages (versions are the latest as of today, they will change).
dotnet add package Azure.AI.ContentUnderstanding
dotnet add package Azure.Identity
You will need a Microsoft Foundry resource in a supported region, with the required models deployed and set as defaults, and the Cognitive Services User role assigned to yourself. That role is needed even if you own the resource.
using Azure;
using Azure.AI.ContentUnderstanding;
using Azure.Identity;

const string Endpoint = "https://<your-foundry-resource>.services.ai.azure.com";
const string DocumentUrl = "https://github.com/Azure-Samples/azure-ai-content-understanding-python/raw/refs/heads/main/data/mixed_financial_docs.pdf";

var credential = new DefaultAzureCredential();
ContentUnderstandingClient client = new(new Uri(Endpoint), credential);

var suffix = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var loanApplicationAnalyzerId = $"loan_application_analyzer_{suffix}";
var invoiceAnalyzerId = $"invoice_analyzer_{suffix}";
var classifierId = $"classifier_{suffix}";
var completionModel = "gpt-5.2";
Now the first custom analyzer for Invoice. Nothing fancy here, just a field schema describing what we want out of an invoice.
// Invoice analyzer.
ContentAnalyzer invoiceAnalyzer = new()
{
    BaseAnalyzerId = "prebuilt-document",
    Description = "Extracts key fields from invoices",
    Models =
    {
        ["completion"] = completionModel
    },
    FieldSchema = new ContentFieldSchema(new Dictionary<string, ContentFieldDefinition>
    {
        ["InvoiceNumber"] = new()
        {
            Type = ContentFieldType.String,
            Method = GenerationMethod.Extract,
            Description = "Invoice identifier."
        },
        ["VendorName"] = new()
        {
            Type = ContentFieldType.String,
            Method = GenerationMethod.Extract,
            Description = "Name of the vendor issuing the invoice."
        },
        ["TotalAmount"] = new()
        {
            Type = ContentFieldType.Number,
            Method = GenerationMethod.Extract,
            Description = "Invoice total including tax."
        },
    })
};

await CreateAnalyzer(client, invoiceAnalyzerId, invoiceAnalyzer);
Now the second analyzer for Loan Application, same like before, just different fields.
// Loan application analyzer.
ContentAnalyzer loanApplicationAnalyzer = new()
{
    BaseAnalyzerId = "prebuilt-document",
    Description = "Extracts key fields from loan applications",
    Models =
    {
        ["completion"] = completionModel
    },
    FieldSchema = new ContentFieldSchema(new Dictionary<string, ContentFieldDefinition>
    {
        ["ApplicantName"] = new()
        {
            Type = ContentFieldType.String,
            Method = GenerationMethod.Extract,
            Description = "Full name of the loan applicant."
        },
        ["LoanAmountRequested"] = new()
        {
            Type = ContentFieldType.Number,
            Method = GenerationMethod.Extract,
            Description = "Total loan amount requested."
        },
        ["LoanPurpose"] = new()
        {
            Type = ContentFieldType.String,
            Method = GenerationMethod.Generate,
            Description = "Stated purpose of the loan."
        },
    }),
};

await CreateAnalyzer(client, loanApplicationAnalyzerId, loanApplicationAnalyzer);
Next, the classifier. This is where the routing happens. Note EnableSegment, which is what makes the service split a multi-document file rather than treating it as one document, and AnalyzerId on each category, which is what points a category at an analyzer.
// Classifier: Invoice and Loan application route to their analyzers, Bank statement is classified only.
ContentAnalyzer classifier = new()
{
    BaseAnalyzerId = "prebuilt-document",
    Description = "Splits a multi-type document and routes each part to its analyzer",
    Models =
    {
        ["completion"] = completionModel
    },
    Config = new ContentAnalyzerConfig
    {
        EnableSegment = true,
        ContentCategories =
        {
            ["Invoice"] = new ContentCategoryDefinition
            {
                Description = "Billing documents requesting payment for goods or services, with line items, taxes and totals.",
                AnalyzerId = invoiceAnalyzerId,
            },
            ["Loan_Application"] = new ContentCategoryDefinition
            {
                Description = "Requests for funding, including applicant details, financial history, loan amount and purpose.",
                AnalyzerId = loanApplicationAnalyzerId,
            },
            ["Bank_Statement"] = new ContentCategoryDefinition
            {
                Description = "Statements summarizing account activity over a period, including deposits, withdrawals and balances.",
                // No AnalyzerId: this category is classified but not routed to an analyzer.
            }
        }
    }
};

await CreateAnalyzer(client, classifierId, classifier);
And then we analyze. The sample PDF above conveniently contains all three document types across four pages.
Operation<AnalysisResult> operation = await client.AnalyzeAsync(
    WaitUntil.Completed,
    classifierId,
    inputs: [new AnalysisInput { Uri = new Uri(DocumentUrl) }]);

AnalysisResult result = operation.Value;

// The first content is the whole file. Its segments show how the classifier split it,
// including categories that were not routed to an analyzer.
var wholeDocument = (DocumentContent)result.Contents![0];

Console.WriteLine($"Split into {wholeDocument.Segments?.Count ?? 0} segment(s):");
foreach (DocumentContentSegment segment in wholeDocument.Segments ?? [])
{
    Console.WriteLine($"  {segment.Category,-18} pages {segment.StartPageNumber}-{segment.EndPageNumber}");
}

// Only categories with an AnalyzerId get their own content entry, with extracted fields.
foreach (AnalysisContent content in result.Contents.Where(c => c.Category is not null))
{
    var document = (DocumentContent)content;
    Console.WriteLine($"\n{document.Category} (pages {document.StartPageNumber}-{document.EndPageNumber}) via {document.AnalyzerId}");

    foreach ((string name, ContentField field) in document.Fields)
    {
        Console.WriteLine($"  {name}: {field.Value ?? "(null)"}");
    }
}
And the output.
Split into 3 segment(s):
  Invoice            pages 1-1
  Bank_Statement     pages 2-3
  Loan_Application   pages 4-4

Invoice (pages 1-1) via invoice_analyzer_1784529162
  InvoiceNumber: INV-100
  VendorName: CONTOSO LTD.
  TotalAmount: 110

Loan_Application (pages 4-4) via loan_application_analyzer_1784529162
  ApplicantName: John Smith
  LoanAmountRequested: 25000
  LoanPurpose: Debt Consolidation
Now here is the part that is easy to get wrong. Bank_Statement shows up in the segment list, but it never appears in the second loop. That is not a bug, it is how the response is shaped, and it is much clearer if we look at the raw JSON.
{
  // omitted: id, status
  "result": {
    "analyzerId": "classifier_1784529162",
    "apiVersion": "2025-11-01",
    // omitted: createdAt, stringEncoding, warnings
    "contents": [
      {
        "path": "input1",
        "markdown": "CONTOSO LTD.\n\n# INVOICE\n...",
        "startPageNumber": 1,
        "endPageNumber": 4,
        "unit": "inch",
        "pages": [
          // omitted: pageNumber, angle, width, height for each of the 4 pages
        ],
        "segments": [
          {
            "segmentId": "segment1",
            "startPageNumber": 1,
            "endPageNumber": 1,
            "category": "Invoice"
          },
          {
            "segmentId": "segment2",
            "startPageNumber": 2,
            "endPageNumber": 3,
            "category": "Bank_Statement"
          },
          {
            "segmentId": "segment3",
            "startPageNumber": 4,
            "endPageNumber": 4,
            "category": "Loan_Application"
          }
        ],
        "analyzerId": "classifier_1784529162",
        "mimeType": "application/pdf"
      },
      {
        "path": "input1/segment1",
        "category": "Invoice",
        "markdown": "CONTOSO LTD.\n\n# INVOICE\n...",
        "fields": {
          "InvoiceNumber": {
            "type": "string",
            "valueString": "INV-100",
            "spans": [
              {
                "offset": 90,
                "length": 7
              }
            ],
            "confidence": 0.738,
            "source": "D(1,7.4772,1.3993,8.0103,1.3987,8.0105,1.5459,7.4774,1.5465)"
          },
          "VendorName": {
            "type": "string",
            "valueString": "CONTOSO LTD.",
            "confidence": 0.939
            // omitted: spans, source
          },
          "TotalAmount": {
            "type": "number",
            "valueNumber": 110,
            "confidence": 0.9
            // omitted: spans, source
          }
        },
        "startPageNumber": 1,
        "endPageNumber": 1,
        // omitted: kind, unit, pages, segments
        "analyzerId": "invoice_analyzer_1784529162"
      },
      {
        "path": "input1/segment3",
        "category": "Loan_Application",
        "markdown": "# Contoso Bank Loan Application Form\n...",
        "fields": {
          "ApplicantName": {
            "type": "string",
            "valueString": "John Smith",
            "confidence": 0.981
            // omitted: spans, source
          },
          "LoanAmountRequested": {
            "type": "number",
            "valueNumber": 25000,
            "confidence": 0.745
            // omitted: spans, source
          },
          "LoanPurpose": {
            "type": "string",
            "valueString": "Debt Consolidation",
            "confidence": 0.675
            // omitted: spans, source
          }
        },
        "startPageNumber": 4,
        "endPageNumber": 4,
        // omitted: kind, unit, pages, segments
        "analyzerId": "loan_application_analyzer_1784529162"
      }
    ]
  },
  "usage": {
    "documentPagesStandard": 4,
    "contextualizationTokens": 4000,
    "tokens": {
      "gpt-5.2-input": 7710,
      "gpt-5.2-output": 222
    }
  }
}
Look at the path values. We have input1, input1/segment1 and input1/segment3. There is no input1/segment2. The service numbered all three segments in the parent content, then only emitted content entries for the two that had an analyzer attached. The gap in the numbering is the giveaway.

I could not find this spelled out in the docs, but from what I can see the response has two layers, and you need both:

  • Classification lives in Contents[0].Segments, and contains every category the classifier found, routed or not.
  • Extraction lives in  Contents[1..], and contains only the categories that had an AnalyzerId, each with its Fields.
One last practical note: analyzers are resources that live on your Foundry resource until you delete them, so remember to clean them up.
// Classifier first: it references the two analyzers.
foreach (var analyzerId in new[] { classifierId, invoiceAnalyzerId, loanApplicationAnalyzerId })
{
    try
    {
        await client.DeleteAnalyzerAsync(analyzerId);
    }
    catch (RequestFailedException ex) when (ex.Status == 404)
    {
        // Creation failed earlier, so let that exception surface instead of this one.
    }
}
More read:

Hope this helps.

Happy Coding.

Regards,
Jaliya

Thursday, June 18, 2026

Using Microsoft Entra ID Workload Identity Federation (WIF) to Deploy from GitHub Actions to Azure

In this post, let's have a look at how to deploy to Azure from GitHub Actions without storing any secrets, using Microsoft Entra ID Workload Identity Federation (WIF).

The traditional way to let a GitHub Actions workflow talk to Azure is to create a service principal, export its credentials, and paste them into a repository secret (commonly named AZURE_CREDENTIALS). It works, but now you own a long-lived secret: it sits in GitHub, it can leak, and someone has to remember to rotate it before it expires.

With workload identity federation, there is no secret at all. You tell Entra ID to trust tokens that GitHub issues for a specific repository. At run time, GitHub mints a short-lived OIDC token, Azure validates it against that trust, and hands back an access token. Nothing long-lived is stored anywhere.

Let's see how to set it up.

1. Add a federated credential to the app registration

You still need an Entra ID application. Instead of giving it a client secret, you add a federated credential that describes which GitHub workflow is allowed to sign in. The most important field is the subject, which must match the token GitHub sends.
az ad app federated-credential create `
  --id <app-client-id> `
  --parameters '{
    "name": "github-main",
    "issuer": "https://token.actions.githubusercontent.com",
    "subject": "repo:my-org/my-repo:ref:refs/heads/main",
    "audiences": ["api://AzureADTokenExchange"]
  }'
The subject is what scopes the trust. You bind it to exactly the situation that should be allowed to authenticate - a branch, or a GitHub Environment:
# a specific branch
repo:my-org/my-repo:ref:refs/heads/main

# a GitHub Environment (great for gating production)
repo:my-org/my-repo:environment:production
Add one federated credential per subject you need (for example, one for the main branch and one for the production environment). The issuer and audiences values above are the standard ones for GitHub Actions - leave them as-is.

2. Update the workflow

Here is the old, secret-based login:
- name: Azure Login
  uses: azure/login@v2
  with:
    creds: ${{ secrets.AZURE_CREDENTIALS }}   
And here is the federated version. Two things matter: the job needs the id-token: write permission so GitHub can mint the OIDC token, and azure/login now takes the three identifiers instead of a secret.
permissions:
  id-token: write   # required for OIDC
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
    - name: Azure Login
      uses: azure/login@v2
      with:
        client-id: ${{ vars.AZURE_CLIENT_ID }}
        tenant-id: ${{ vars.AZURE_TENANT_ID }}
        subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}  
Notice these three values are stored as vars, not secrets. A client ID, tenant ID, and subscription ID are just identifiers - they are not sensitive, so there is nothing to rotate and nothing to leak. That is the whole point: the deployment now has no stored credentials at all.

One thing to watch: if you get an AADSTS70021: No matching federated identity record found error, the subject on your federated credential does not match what the workflow actually sent. Double check the branch name or environment name, they have to line up exactly.

Hope this helps.

Happy Coding.

Regards,
Jaliya

Tuesday, June 9, 2026

Connecting to Azure DocumentDB (with MongoDB compatibility) using Microsoft Entra ID Managed Identity

In this post, let's have a look at how we can connect a .NET application to Azure DocumentDB (with MongoDB compatibility) using a Microsoft Entra ID Managed Identity instead of a password in the connection string. Idea is Azure hands the identity a token at runtime, the driver passes it to the cluster, and the cluster validates it against Microsoft Entra ID. For local development, DefaultAzureCredential falls back to your local identity as in any other services.

What is Azure DocumentDB (with MongoDB compatibility)?

If the name is new to you: Azure DocumentDB (with MongoDB compatibility) is the rebrand of what you may know as Azure Cosmos DB for MongoDB (vCore). It's a fully managed, MongoDB-compatible document database, around 99% compatible via the MongoDB wire protocol and BSON. So our existing MongoDB drivers and tools (the C# driver, mongosh, Compass, Studio 3T) will work against it as-is.

Prerequisites

A few things to have in place before we start:
  • An Azure Cosmos DB for MongoDB (vCore) cluster on a paid tier (M10 or higher). Entra auth is not available on Free.
  • A user-assigned managed identity assigned to your app (App Service, Container Apps, AKS, etc.).
  • For local development, Azure CLI, Azure Developer CLI etc logged in to the right tenant.
Step 1: Enable Entra auth on the cluster

First, we need to enable Microsoft Entra ID as an allowed auth mode on the cluster. 

This currently lives behind a preview API surface, so we need --latest-include-preview.
# Set these once for the session.
$RESOURCE_GROUP = "<resource-group>"
$CLUSTER = "<cluster-name>"
$LOCATION = "<region>"

# Enable Entra ID auth (keep NativeAuth).
'{"authConfig":{"allowedModes":["MicrosoftEntraID","NativeAuth"]}}'| 
Set-Content authConfig.json -Encoding ascii -NoNewline az resource patch ` --resource-group $RESOURCE_GROUP ` --name $CLUSTER ` --resource-type Microsoft.DocumentDB/mongoClusters ` --properties "@authConfig.json" ` --latest-include-preview
Step 2: Register the principal as a cluster user

Now he cluster needs to know which principals are allowed in and what they can do. We register each principal (by its object id) as a cluster user with a role on a database.

For the managed identity, register it as a ServicePrincipal
# App's managed identity, registered as a ServicePrincipal.
$PRINCIPAL_ID = az identity show `
    --resource-group $RESOURCE_GROUP `
    --name <identity-name> `
    --query principalId `
    --output tsv

'{"identityProvider":{"type":"MicrosoftEntraID","properties":{"principalType":"ServicePrincipal"}},"roles":[{"db":"admin","role":"root"}]}' |
    Set-Content user.json -Encoding ascii -NoNewline

az resource create `
    --resource-group $RESOURCE_GROUP `
    --name "$CLUSTER/users/$PRINCIPAL_ID" `
    --resource-type Microsoft.DocumentDB/mongoClusters/users `
    --location $LOCATION `
    --properties "@user.json" `
    --latest-include-preview
A couple of notes:
  • principalType is either ServicePrincipal or User. A managed identity is registered as a ServicePrincipal and User for your own account during local development.
  • vCore currently only allows the cluster root role on the admin database for Entra principals, there is no per-database readWrite option, so Entra access is effectively cluster-admin.
For local development, register your own Entra user the same way, object id from your signed-in CLI session, and the type is User:
# Your own user (local dev).
$PRINCIPAL_ID = az ad signed-in-user show `
    --query id `
    --output tsv

'{"identityProvider":{"type":"MicrosoftEntraID","properties":{"principalType":"User"}},"roles":[{"db":"admin","role":"root"}]}' |
    Set-Content user.json -Encoding ascii -NoNewline

az resource create `
    --resource-group $RESOURCE_GROUP `
    --name "$CLUSTER/users/$PRINCIPAL_ID" `
    --resource-type Microsoft.DocumentDB/mongoClusters/users `
    --location $LOCATION `
    --properties "@user.json" `
    --latest-include-preview
If you prefer Portal, Step 1 and 2 are basically these.
Authentication
Step 3: The .NET code

Now the code. We need a tiny shim that fetches an Entra access token and feeds it to the driver. The driver exposes an IOidcCallback interface (from the MongoDB.Driver.Authentication.Oidc namespace), and we back it with an Azure.Core.TokenCredential.

The two facts that are easy to get wrong:
  • The token scope is https://ossrdbms-aad.database.windows.net/.default.
  • For a guest or local user, you typically need to pin the tenantId in the TokenRequestContext. A managed identity ignores it, so leaving it null is fine in Azure.
Here's the callback. It implements both the sync and async variants, returning an OidcAccessToken with the token and its remaining lifetime:
using Azure.Core;
using MongoDB.Driver.Authentication.Oidc;

internal sealed class EntraOidcCallback(TokenCredential credential, string? tenantId) : IOidcCallback
{
    // vCore validates a token issued for the Azure OSS RDBMS resource.
    private static readonly string[] _scopes = ["https://ossrdbms-aad.database.windows.net/.default"];

    public OidcAccessToken GetOidcAccessToken(OidcCallbackParameters parameters, CancellationToken cancellationToken)
    {
        TokenRequestContext tokenRequestContext = BuildContext();

        AccessToken accessToken = credential.GetToken(tokenRequestContext, cancellationToken);

        return ToOidcAccessToken(accessToken);
    }

    public async Task<OidcAccessToken> GetOidcAccessTokenAsync(OidcCallbackParameters parameters, CancellationToken cancellationToken)
    {
        TokenRequestContext tokenRequestContext = BuildContext();

        AccessToken accessToken = await credential.GetTokenAsync(tokenRequestContext, cancellationToken);

        return ToOidcAccessToken(accessToken);
    }

    private static OidcAccessToken ToOidcAccessToken(AccessToken accessToken) =>
        new(accessToken.Token, accessToken.ExpiresOn - DateTimeOffset.UtcNow);
private TokenRequestContext BuildContext() { // A guest/local user needs the home tenant pinned.
// A managed identity ignores it.
if (string.IsNullOrEmpty(tenantId)) { return new TokenRequestContext(_scopes);
} return new TokenRequestContext(_scopes, tenantId: tenantId);
} }
And now let's build the MongoClient.
using Azure.Identity;
using MongoDB.Driver;

string host = "<HOST_NAME>.mongocluster.cosmos.azure.com";
string tenantId = "<TENANT_ID>"; 

MongoClientSettings settings = MongoClientSettings.FromUrl(MongoUrl.Create($"mongodb+srv://{host}/"));
settings.UseTls = true;
settings.RetryWrites = false;
settings.MaxConnectionIdleTime = TimeSpan.FromMinutes(2);

TokenCredential credential = new DefaultAzureCredential();
settings.Credential = MongoCredential.CreateOidcCredential(new EntraOidcCallback(credential, tenantId));

MongoClient mongoClient = new(settings);

// Omitted for brevity

Hope this helps.

Happy Coding.

Regards,
Jaliya