Thursday, June 25, 2026

C# 15.0: Closed Class Hierarchies

In this post, let's have a look at a nice new C# language feature: Closed Class Hierarchies that's shipping as part of C# 15.

I am using .NET 11 Preview 5 (latest as of today, it will change) for this post.

You will also want to set the following in your project file to opt into the latest language features.
 <LangVersion>preview</LangVersion>
Let's start with the problem first. Say I have a simple type hierarchy of shapes and a switch expression that calculates the area.
Shape[] shapes =
[
    new Circle(2),
    new Rectangle(3, 4)
];

foreach (Shape shape in shapes)
{
    Console.WriteLine($"{shape.GetType().Name}: {Area(shape):0.00}");
}

static double Area(Shape shape) => shape switch
{
    Circle(var r) => Math.PI * r * r,
    Rectangle(var w, var h) => w * h
};

public abstract record class Shape;

public record class Circle(double Radius) : Shape;

public record class Rectangle(double Width, double Height) : Shape;
Here I am handling Circle and Rectangle, which are the only two subtypes of Shape that exist. But the compiler still gives me a warning.
warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). 
For example, the pattern '_' is not covered.
CS8509: The pattern '_' is not covered
The reason is, even though I have handled every subtype that exists today, the compiler has no way of knowing that. Someone could derive another type from Shape anywhere, so the compiler insists that I add a _ (discard) arm to be safe. And that's exactly the problem: the moment I add a _ => throw ... catch-all to silence this, I lose all compiler help. If I add a new subtype later and forget to handle it, the code happily compiles and falls into the catch-all at runtime.

This is where Closed class hierarchies come in. I just need to mark the base type with the new closed keyword.
// Omitted for brevity

public closed record class Shape;

public record class Circle(double Radius) : Shape;

public record class Rectangle(double Width, double Height) : Shape;
Now the CS8509 warning is gone, and notice I didn't have to add a _ arm at all.

A closed type can only be directly derived from within the same assembly, and it is implicitly abstract. Now the compiler knows the complete set of subtypes, so it can prove the switch expression is exhaustive.

And here is the best part. Let's say the requirements grow and I add a new shape, Triangle, but I forget to update the Area switch.
// Omitted for brevity

public closed record class Shape;

public record class Circle(double Radius) : Shape;

public record class Rectangle(double Width, double Height) : Shape;

public record class Triangle(double Base, double Height) : Shape;
Because Shape is closed, the compiler immediately tells me exactly what I missed.
warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). 
For example, the pattern 'Triangle' is not covered.
CS8509: The pattern 'Triangle' is not covered.
Notice the difference: instead of the vague '_' is not covered, it now says 'Triangle' is not covered, naming the exact subtype I forgot, on every switch I need to update.

One thing while this is still in preview. The closed keyword needs a compiler-required attribute that the BCL doesn't ship yet, so you have to hand-roll it. If you don't, you'll get an error like this:
error CS0656: Missing compiler required member 'System.Runtime.CompilerServices.ClosedAttribute..ctor'
Interestingly, the well-known member name drifted across preview toolsets. The .NET SDK CLI compiler (I am on 11.0.100-preview.5) asks for ClosedAttribute, while my Visual Studio toolset asks for IsClosedTypeAttribute. To keep both happy, I just declared both in a separate file.
namespace System.Runtime.CompilerServices;

[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class ClosedAttribute : Attribute { }

[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class IsClosedTypeAttribute : Attribute { }
This is preview behavior and should get cleaned up as the feature stabilizes, but it's good to know if you want to try it out today.

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