Run mode: Azure PostgreSQL Entra auth is unusable under an app-only credential (CI service principal)
- Dominant language
- C#
- Stars
- 6.3k
- Forks
- 991
- Avg merge
- 2d 15h
- Merged PRs (30d)
- 196
Description
### Is there an existing issue for this?
Yes — searched. Related but distinct: #19487 (hosting-side `principalName` for run-mode role assignments). This issue is about the **client** side of the same scenario.
### Describe the bug
Provisioning an Azure PostgreSQL flexible server **in run mode** (`aspire run`) works fine when a developer is signed in as a user, but a referencing service **cannot connect** when the ambient credential is an app-only service principal — which is exactly what you get in CI (`az login --service-principal --federated-token ...`).
This is the "spin up an AppHost in run mode from CI and let it provision real Azure resources" scenario. It's an appealing pattern for integration/E2E testing, because run mode provisions and wires everything in one step without a separate `aspire deploy` and without hand-writing bicep. It currently dead-ends for PostgreSQL.
**Root cause: the hosting side and the client side derive the principal identity from two different, non-overlapping claim chains, and nothing reconciles them.**
| Side | Code | Claim chain |
|---|---|---|
| Hosting → PG Entra administrator `principalName` | `DefaultAzurePrincipalProvider` | `upn` → `email` → (`app_displayname`) → `oid` |
| Client → Npgsql connection username | [`ManagedIdentityTokenCredentialHelpers.TryGetUsernameFromToken`](https://github.com/microsoft/aspire/blob/main/src/Components/Common/ManagedIdentityTokenCredentialHelpers.cs#L93-L113) | `xms_mirid` → `upn` → `preferred_username` → `unique_name` |
For an **app-only** token, none of the client's four claims are present: `xms_mirid` is managed-identity-only, and `upn` / `preferred_username` / `unique_name` are all user-only. So `TryGetUsernameFromToken` returns `false` and the code falls through to:
```csharp
// If we still don't have a username, we let Npgsql handle the error when trying to connect.
// The user will be hinted to provide a username by using the configureDataSourceBuilder callback.
```
`src/Components/Common/ManagedIdentityTokenCredentialHelpers.cs:48-49`
The connection string cannot rescue it either, because it deliberately carries only the host — see `src/Aspire.Hosting.Azure.PostgreSQL/AzurePostgresExtensions.cs:590-595`:
```csharp
// We don't know the principalName, so we can't add it to the connection string.
// The user name will need to come from the application code.
infrastructure.Add(new ProvisioningOutput("connectionString", typeof(string))
{
Value = BicepFunction.Interpolate($"Host={postgres.FullyQualifiedDomainName}")
});
```
So the AppHost knows the principal name it stamped onto the Entra administrator resource, but never hands it to the client, and the client has no way to re-derive it. The failure is silent at provisioning time and only shows up as a connection error.
### Expected Behavior
An AppHost that provisions Azure PostgreSQL in run mode and hands the connection to a referencing service should work under an app-only service principal, without the app author having to write credential-type-specific code.
Some options, roughly in order of preference:
1. **Flow the principal name from the AppHost to the client.** The AppHost already resolves it for the Entra administrator resource, so it could append `Username=` to the connection string (or surface it as a separate connection property) instead of dropping it. This removes the guesswork entirely and fixes the general case, not just app-only.
2. **Teach the client to handle app-only tokens** by extending the chain with `app_displayname` and/or `oid`, matching what the hosting side stamps as the administrator name.
3. At minimum, **fail loudly** with a diagnostic naming the mismatch, rather than falling through to an opaque Npgsql error.
Whichever is chosen, the two derivations should be defined in one place so they cannot drift apart again.
### Steps To Reproduce
Run this AppHost from a CI job (or any shell) whose ambient Azure credential is an app-only service principal.
AppHost (single-file):
```csharp
#:sdk Aspire.AppHost.Sdk
#:package Aspire.Hosting.AppHost@*
#:package Aspire.Hosting.Azure.PostgreSQL@*
var builder = DistributedApplication.CreateBuilder(args);
// Entra auth is the default for a flexible server:
// activeDirectoryAuth = Enabled, passwordAuth = Disabled.
var pg = builder.AddAzurePostgresFlexibleServer("pg");
var db = pg.AddDatabase("appdb");
builder.AddProject("svc", "svc/svc.csproj")
.WithReference(db)
.WaitFor(db);
builder.Build().Run();
```
Service (`svc`), using the Aspire client integration:
```csharp
var builder = WebApplication.CreateBuilder(args);
builder.AddAzureNpgsqlDataSource("appdb");
var app = builder.Build();
app.MapGet("/dbcheck", async (NpgsqlDataSource source) =>
{
await using var connection = await source.OpenConnectionAsync();
await using var command = new NpgsqlCommand("select current_user", connection);
return Results.Ok(new { currentUser = await command.ExecuteScalarAsync() });
});
app.Run();
```
CI steps:
```bash
az login --service-principal --username "$CLIENT_ID" --tenant "$TENANT_ID" --federated-token "$TOKEN"
az account show --query user.type -o tsv # -> servicePrincipal
aspire run
curl http://localhost:/dbcheck
```
The `pg` and `pg-roles` deployments succeed — ARM accepts the Entra administrator — but `/dbcheck` fails because the client never sets a username on the connection.
Note that a developer running the same AppHost after a plain `az login` will **not** reproduce this: a user token carries `upn`, so the client's very first fallback succeeds. The bug is specific to app-only credentials, which is why it only bites in CI.
### Exceptions (if any)
The connection is attempted with no username set, so the error surfaces from Npgsql rather than from Aspire — typically a `Npgsql.PostgresException` / authentication failure naming an empty or unexpected role, depending on server configuration.
### Anything else?
**Discovered while working on #19487 / #19520.** That PR adds run-mode deployment E2E coverage for role assignments under a service principal and fixes the hosting-side `principalName` fallback so it is never empty for an app-only token. A PostgreSQL E2E test was prototyped there to prove the end-to-end path and was removed once this gap was confirmed, because the data-plane assertion cannot pass without a product change. See [this review comment](https://github.com/microsoft/aspire/pull/19520#discussion_r3818998005), which independently identified the same mismatch.
Two related notes for whoever picks this up:
- **This scenario has no coverage today.** Of the playgrounds that call `AddAzurePostgresFlexibleServer`, `playground/PostgresEndToEnd`, `playground/waitfor`, and `playground/publishers` use `RunAsContainer()`; `playground/bicep` (`postgres2`) and `playground/cdk` (`pgsql`) provision a real server but use `WithPasswordAuthentication(...)`, which disables Entra auth. So nothing exercises a real Azure PostgreSQL server over Entra auth in run mode.
- **Azure SQL is worth checking for the same class of problem.** `AzureSqlServerResource.PrincipalReconciliationScript` runs `CREATE USER QUOTENAME(@name) WITH SID = ...`, which likewise depends on a meaningful principal name. #19520 stops that name being empty, but whether the resulting name is usable by a connecting client under an app-only credential has not been verified.
Contributor guide
Research direction
Start with src/Components/Common/ManagedIdentityTokenCredentialHelpers.cs and src/Aspire.Hosting.Azure.PostgreSQL/AzurePostgresExtensions.cs, then review DefaultAzurePrincipalProvider and the related #19520 coverage. Reproduce the app-only run-mode path from the issue and inspect the existing PostgreSQL playgrounds; done means a real Azure PostgreSQL Entra connection works in that scenario and the mismatch has regression coverage or a clear diagnostic.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- azure, csharp, postgresql
- Domain
- authentication, backend, cloud, databases
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100