From 8b85d1e164d5e82ab4384459756b976d982880e4 Mon Sep 17 00:00:00 2001 From: Timothy Trowbridge Date: Thu, 17 Sep 2026 23:32:39 -0300 Subject: [PATCH 1/6] Document the resource-first workload identity model Package README: the resource-first model, two quickstarts (Azure SQL from Google Cloud Run over workload identity federation; Azure SQL and Blob Storage from Azure App Service with a system- or user-assigned managed identity, including a BlobServiceClient built on WorkloadIdentityTokenCredential), a reference of every option for both providers, and a migration section from Neolution.AzureSqlFederatedIdentity with the before/after configuration and the AddWorkloadIdentity rename. Every C# sample compiles against the library. Setup guide: restructured as a choice between an Azure managed identity and Google Cloud to Azure federation, with both holders of the federated credential (app registration or user-assigned managed identity), the Azure SQL and Blob Storage grants, the new configuration keys, App Service and Cloud Run deployment, and Microsoft Entra-only authentication. Vendor facts and every URL verified. Root README, SECURITY.md and a patch changeset follow the new name and model. Co-Authored-By: Claude Fable 5.1 --- .changeset/workload-identity-docs.md | 5 + Csag.WorkloadIdentity/README.md | 297 +++++++++++++++++++------ README.md | 10 +- SECURITY.md | 2 +- docs/cloud-identity-setup.md | 309 +++++++++++++++++++++------ 5 files changed, 482 insertions(+), 141 deletions(-) create mode 100644 .changeset/workload-identity-docs.md diff --git a/.changeset/workload-identity-docs.md b/.changeset/workload-identity-docs.md new file mode 100644 index 0000000..202d2d3 --- /dev/null +++ b/.changeset/workload-identity-docs.md @@ -0,0 +1,5 @@ +--- +"@neolution-ch/csag-workload-identity": patch +--- + +Document the resource-first model. The package README explains how each resource obtains its token from a managed identity or through Google workload identity federation, and carries two complete quickstarts (Azure SQL from Google Cloud Run; Azure SQL and Blob Storage from Azure App Service with a system- or user-assigned managed identity, including a `BlobServiceClient` built on `WorkloadIdentityTokenCredential`), a reference of every configuration key for both providers, and a migration section from `Neolution.AzureSqlFederatedIdentity` with the before/after configuration and the `AddWorkloadIdentity` rename. The cloud setup guide is restructured around the choice between an Azure managed identity and Google Cloud to Azure federation, describes both holders of the federated credential (app registration or user-assigned managed identity), the Azure SQL and Blob Storage grants, the new configuration keys, and Microsoft Entra-only authentication. diff --git a/Csag.WorkloadIdentity/README.md b/Csag.WorkloadIdentity/README.md index 509a463..41aaec3 100644 --- a/Csag.WorkloadIdentity/README.md +++ b/Csag.WorkloadIdentity/README.md @@ -3,38 +3,40 @@ [![NuGet](https://img.shields.io/nuget/v/Csag.WorkloadIdentity.svg)](https://www.nuget.org/packages/Csag.WorkloadIdentity) [![License: MIT](https://img.shields.io/badge/License-MIT-lightgray.svg)](https://github.com/neolution-ch/Neolution.AzureSqlFederatedIdentity/blob/main/LICENSE) -Passwordless access to Azure SQL for .NET applications that run on Google Cloud. The library turns the application's Google identity into a Microsoft Entra ID access token through workload identity federation; you attach that token to your `SqlConnection`. No database password, client secret or service account key is stored anywhere. +Passwordless access tokens for Azure SQL and Azure Blob Storage, obtained from the identity a .NET application already runs as. The model is resource-first: each Azure resource the application uses has its own configuration section that selects the identity provider the token comes from, either the **managed identity** of the Azure resource the application runs on (`ManagedIdentity`) or the application's **Google identity**, exchanged for a Microsoft Entra ID access token through workload identity federation (`Google`). The library holds one access token per resource, refreshes it ahead of expiry, and hands it to your code as a string for `SqlConnection.AccessToken` or as a `TokenCredential` for Azure SDK clients such as `BlobServiceClient`. No database password, storage key, client secret or service account key is stored anywhere. -How it works: +How a token is obtained, per provider: -1. Using Application Default Credentials, the library asks the IAM Service Account Credentials API for a Google-signed ID token for the configured service account, with the audience `api://AzureADTokenExchange`. -2. It presents that ID token to Microsoft Entra ID as a client assertion for your app registration, which trusts the service account through a federated credential, and receives an access token for Azure SQL (scope `https://database.windows.net/.default`). -3. Your code assigns the access token to `SqlConnection.AccessToken` and opens the connection. +- **`ManagedIdentity`.** The library requests the token from the managed identity endpoint of the Azure resource the application runs on (App Service, Container Apps, Functions, a virtual machine, AKS and so on), as the system-assigned identity of that resource or as a user-assigned identity selected by its client ID. +- **`Google`.** Using Application Default Credentials, the library asks the IAM Service Account Credentials API for a Google-signed ID token for the configured service account with the audience `api://AzureADTokenExchange`, and presents that ID token to Microsoft Entra ID as the client assertion of the identity that holds a federated credential trusting the service account: an app registration or a user-assigned managed identity. + +Either way the result is a Microsoft Entra ID access token for the resource's scope, `https://database.windows.net/.default` for Azure SQL and `https://storage.azure.com/.default` for Blob Storage. - Repository: -- Cloud setup guide (Google Cloud, Microsoft Entra ID, Azure SQL, Cloud Run): +- Cloud and identity setup guide (managed identities, Google Cloud to Microsoft Entra ID federation, Azure SQL users, Blob Storage roles, deployment): ## Features -- Exchanges a Google-signed ID token for a Microsoft Entra ID access token for Azure SQL; there are no secrets to store or rotate. -- Also serves Azure Blob Storage (`IBlobStorageTokenProvider`), and an application that runs on Azure can obtain the tokens from its managed identity instead (`"Provider": "ManagedIdentity"`). `WorkloadIdentityTokenCredential` presents a provider to Azure SDK clients as a `TokenCredential`. -- Holds the current access token in memory and hands it out until it enters the configured refresh-ahead window. Callers that find no usable token share a single exchange instead of each running their own. -- Background refresh (on by default): a hosted service exchanges a fresh token whenever the held one enters the refresh-ahead window, so requests are served from a valid token without waiting for an exchange. Failed exchanges are retried with exponential backoff. +- Access tokens for **Azure SQL** (`IAzureSqlTokenProvider`) and **Azure Blob Storage** (`IBlobStorageTokenProvider`). Each resource is configured on its own, so the two can use different identities or different providers. +- Two identity providers with no secrets to store or rotate: the Azure managed identity the application runs as, or its Google identity through workload identity federation. +- `WorkloadIdentityTokenCredential` presents any of the providers to Azure SDK clients as a `TokenCredential`, for example to a `BlobServiceClient`. +- Holds the current access token of each resource in memory and hands it out until it enters the configured refresh-ahead window. Callers that find no usable token share a single token request instead of each running their own. +- Background refresh (on by default): a hosted service refreshes each resource's token ahead of its expiry, so requests are served from a valid token without waiting for a token request. Failed requests are retried with exponential backoff. - Options are validated when the host starts, so a missing or invalid value fails fast with a message that names it. -- The public services, `IAzureSqlTokenProvider`, `IAzureSqlTokenExchanger` and `IGoogleIdTokenProvider`, are registered with `TryAdd`, so you can replace any of them by registering your own implementation first. The background refresh hosted service is always added; turn it off with `EnableBackgroundRefresh` instead. +- Every service is registered with `TryAdd`, so you can replace any part of the pipeline, including a token provider, by registering your own implementation first. - Targets `net8.0` and `net10.0`. ## Prerequisites - An application on .NET 8 or .NET 10 that uses `Microsoft.Extensions.DependencyInjection` (ASP.NET Core, a worker service or the generic host). Microsoft's support for .NET 8 ends on 10 November 2026. -- **Google Cloud:** a service account; the IAM Service Account Credentials API enabled in its project (`gcloud services enable iamcredentials.googleapis.com`); and the identity the application runs as granted **Service Account OpenID Connect Identity Token Creator** (`roles/iam.serviceAccountOpenIdTokenCreator`) on that service account. -- **Microsoft Entra ID:** an app registration with a federated credential that trusts the service account (issuer `https://accounts.google.com`, subject = the service account's unique ID, audience `api://AzureADTokenExchange`). -- **Azure SQL:** a contained database user for the app registration (`CREATE USER [...] FROM EXTERNAL PROVIDER`) with the permissions your application needs. -- **Application Default Credentials at runtime:** on Cloud Run, GKE or Compute Engine the attached service account; on a workstation `gcloud auth application-default login`. +- **With `ManagedIdentity`:** the application runs on an Azure resource that has a system-assigned managed identity enabled or a user-assigned managed identity attached. That identity has a database user in Azure SQL (`CREATE USER [...] FROM EXTERNAL PROVIDER`) and/or a Blob data role (`Storage Blob Data Reader` or `Storage Blob Data Contributor`) on the storage account. +- **With `Google`:** a Google service account, with the IAM Service Account Credentials API enabled in its project (`gcloud services enable iamcredentials.googleapis.com`) and the identity the application runs as granted **Service Account OpenID Connect Identity Token Creator** (`roles/iam.serviceAccountOpenIdTokenCreator`) on it; a Microsoft Entra ID app registration or user-assigned managed identity with a federated credential that trusts the service account (issuer `https://accounts.google.com`, subject = the service account's unique ID, audience `api://AzureADTokenExchange`), holding the same database user and/or Blob role; and Application Default Credentials at runtime (on Cloud Run, GKE or Compute Engine the attached service account; on a workstation `gcloud auth application-default login`). The setup guide linked above walks through each of these step by step. -## Quickstart +## Quickstart A: Azure SQL from Google Cloud Run + +An application on Cloud Run runs as a Google service account, so its Azure SQL token comes from the `Google` provider. ### 1. Install @@ -46,7 +48,7 @@ The library returns a token string and does not depend on `Microsoft.Data.SqlCli ### 2. Configure -The options bind from the `Csag.WorkloadIdentity` section of the configuration. Each resource has its own section that selects the identity provider and carries that provider's settings; this is Azure SQL over Google federation: +The options bind from the `Csag.WorkloadIdentity` section of the configuration. The `AzureSql` section selects the provider and carries that provider's settings: ```json { @@ -55,12 +57,10 @@ The options bind from the `Csag.WorkloadIdentity` section of the configuration. "Provider": "Google", "Google": { "TenantId": "", - "ClientId": "", + "ClientId": "", "ServiceAccountEmail": "@.iam.gserviceaccount.com" } - }, - "RefreshAheadWindow": "00:05:00", - "EnableBackgroundRefresh": true + } }, "ConnectionStrings": { "AzureSql": "Server=tcp:.database.windows.net,1433;Initial Catalog=;Encrypt=True" @@ -68,20 +68,7 @@ The options bind from the `Csag.WorkloadIdentity` section of the configuration. } ``` -| Key | Required | Default | Description | -|---|---|---|---| -| `AzureSql:Provider` | no | `ManagedIdentity` | `Google` for workload identity federation as shown here. The default, `ManagedIdentity`, expects an `AzureSql:ManagedIdentity` section instead of `AzureSql:Google`. | -| `AzureSql:Google:TenantId` | yes | – | Directory (tenant) ID of the Microsoft Entra tenant. | -| `AzureSql:Google:ClientId` | yes | – | Application (client) ID of the app registration that holds the federated credential (or the client ID of a user-assigned managed identity holding it). | -| `AzureSql:Google:ServiceAccountEmail` | yes | – | The Google service account the ID token is minted for. The federated credential names this account's unique ID as its subject. | -| `RefreshAheadWindow` | no | `00:05:00` | How long before an access token expires it is treated as due for refresh. Applies to every resource. Must be positive. | -| `EnableBackgroundRefresh` | no | `true` | Whether a hosted service keeps the tokens refreshed ahead of their expiry. | - -A `BlobStorage` section of the same shape configures `IBlobStorageTokenProvider`. With `"Provider": "ManagedIdentity"` a section carries `"ManagedIdentity": { "UseSystemAssignedIdentity": true }` or `"ManagedIdentity": { "ClientId": "" }` instead of `Google`. At least one resource section is required. - -Environment variables follow the usual .NET mapping, with `__` as the section separator: `Csag.WorkloadIdentity__AzureSql__Provider`, `Csag.WorkloadIdentity__AzureSql__Google__TenantId`, `Csag.WorkloadIdentity__AzureSql__Google__ClientId`, `Csag.WorkloadIdentity__AzureSql__Google__ServiceAccountEmail`, and so on. - -The connection string deliberately carries no credentials; see the notes below. +`ClientId` is the application (client) ID of the app registration, or the client ID of the user-assigned managed identity, that holds the federated credential. On Cloud Run supply the same keys as environment variables, with `__` as the section separator: `Csag.WorkloadIdentity__AzureSql__Provider`, `Csag.WorkloadIdentity__AzureSql__Google__TenantId`, `Csag.WorkloadIdentity__AzureSql__Google__ClientId`, `Csag.WorkloadIdentity__AzureSql__Google__ServiceAccountEmail` and `ConnectionStrings__AzureSql`. The connection string deliberately carries no credentials; see the notes below. ### 3. Register @@ -97,38 +84,11 @@ var app = builder.Build(); app.Run(); ``` -Two further overloads exist, for a host whose configuration is not registered as `IConfiguration` in the container and for configuring in code. Given an `IServiceCollection services` and an `IConfiguration configuration`: - -```csharp -using Csag.WorkloadIdentity; -using Csag.WorkloadIdentity.Options; - -// Binds the "Csag.WorkloadIdentity" section of the given configuration. -services.AddWorkloadIdentity(configuration); - -// Sets the options in code; the section name is available as WorkloadIdentityOptions.ConfigurationSectionName. -services.AddWorkloadIdentity(options => -{ - options.AzureSql = new WorkloadIdentityResourceOptions - { - Provider = WorkloadIdentityProvider.Google, - Google = new GoogleOptions - { - TenantId = "", - ClientId = "", - ServiceAccountEmail = "@.iam.gserviceaccount.com", - }, - }; - options.RefreshAheadWindow = TimeSpan.FromMinutes(10); - options.EnableBackgroundRefresh = true; -}); -``` - -`WorkloadIdentityOptions`, `WorkloadIdentityResourceOptions`, `GoogleOptions` and `ManagedIdentityOptions` live in `Csag.WorkloadIdentity.Options`. The options are validated when the host starts: no resource section at all, a missing value in a configured resource's provider section, or a non-positive `RefreshAheadWindow` throws an `OptionsValidationException` that names every offending value, for example `AzureSql:Google:ServiceAccountEmail must be provided.` Calling `AddWorkloadIdentity` more than once is harmless. Both token providers are always registered; resolving the provider of a resource whose section is absent throws an `InvalidOperationException` that names the missing section. +The options reference below shows the other two overloads: binding a given `IConfiguration`, and configuring the options in code. ### 4. Use the token -Resolve `IAzureSqlTokenProvider` (namespace `Csag.WorkloadIdentity.Abstractions`), call `GetAzureSqlAccessTokenAsync` and assign the result to `SqlConnection.AccessToken` before opening the connection. Do this for every new connection: until the held token enters the refresh-ahead window the call returns it without any network round trip; inside the window (reachable only when background refresh is off or has been failing) the first caller exchanges a new token and concurrent callers wait for that one exchange. +Resolve `IAzureSqlTokenProvider` (namespace `Csag.WorkloadIdentity.Abstractions`), call `GetAzureSqlAccessTokenAsync` and assign the result to `SqlConnection.AccessToken` before opening the connection. Do this for every new connection: while the held token is valid the call returns it without any network round trip. ```csharp using Csag.WorkloadIdentity.Abstractions; @@ -173,7 +133,7 @@ public sealed class AppDbContextFactory(IConfiguration configuration, IAzureSqlT public async Task CreateDbContextAsync(CancellationToken cancellationToken = default) { - // The connection string carries no credentials; the federated access token authenticates the connection. + // The connection string carries no credentials; the access token authenticates the connection. var accessToken = await tokenProvider.GetAzureSqlAccessTokenAsync(cancellationToken); var context = new AppDbContext(this.options); @@ -189,23 +149,218 @@ public sealed class AppDbContextFactory(IConfiguration configuration, IAzureSqlT Register the factory (`builder.Services.AddScoped();`) and call `CreateDbContextAsync` wherever you need a context. If you prefer `AddDbContext`, make the same assignment from a `DbConnectionInterceptor` that overrides `ConnectionOpeningAsync`. +## Quickstart B: Azure SQL and Blob Storage from Azure App Service + +An application on App Service (or Container Apps, Functions, a virtual machine, AKS) has a managed identity, so both tokens come from the `ManagedIdentity` provider. Blob Storage is reached through the Azure SDK, with `WorkloadIdentityTokenCredential` adapting the library's provider to the `TokenCredential` the SDK expects. + +### 1. Install + +```shell +dotnet add package Csag.WorkloadIdentity +dotnet add package Azure.Storage.Blobs +``` + +Add `Microsoft.Data.SqlClient` or an EF Core provider for Azure SQL as in quickstart A. + +### 2. Configure + +With the App Service's system-assigned identity for both resources: + +```json +{ + "Csag.WorkloadIdentity": { + "AzureSql": { + "Provider": "ManagedIdentity", + "ManagedIdentity": { "UseSystemAssignedIdentity": true } + }, + "BlobStorage": { + "Provider": "ManagedIdentity", + "ManagedIdentity": { "UseSystemAssignedIdentity": true } + } + }, + "ConnectionStrings": { + "AzureSql": "Server=tcp:.database.windows.net,1433;Initial Catalog=;Encrypt=True" + }, + "BlobStorage": { + "ServiceUri": "https://.blob.core.windows.net" + } +} +``` + +For a user-assigned managed identity, name it by its client ID instead: + +```json +"ManagedIdentity": { "ClientId": "" } +``` + +`Provider` defaults to `ManagedIdentity`, so it could be omitted here; it is written out for clarity. The two resources are independent: one can use the system-assigned identity and the other a user-assigned one, or the `Google` provider. In App Service, supply the values as application settings with the same `__` separator, for example `Csag.WorkloadIdentity__AzureSql__ManagedIdentity__UseSystemAssignedIdentity` = `true`. `BlobStorage:ServiceUri` is the application's own setting, read below. + +### 3. Register + +```csharp +using Azure.Storage.Blobs; +using Csag.WorkloadIdentity; +using Csag.WorkloadIdentity.Abstractions; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddWorkloadIdentity(); + +// One BlobServiceClient for the application. The adapter serves every token request of the client from the +// library's held Blob Storage token, so the client needs neither a key nor a connection string. +builder.Services.AddSingleton(serviceProvider => new BlobServiceClient( + new Uri(builder.Configuration["BlobStorage:ServiceUri"] + ?? throw new InvalidOperationException("Setting 'BlobStorage:ServiceUri' is not configured.")), + new WorkloadIdentityTokenCredential(serviceProvider.GetRequiredService()))); + +var app = builder.Build(); +app.Run(); +``` + +Azure SDK clients are thread-safe and meant to be shared, so a singleton is the right lifetime for the `BlobServiceClient`. + +### 4. Use the tokens + +Azure SQL works exactly as in quickstart A: `IAzureSqlTokenProvider` and `SqlConnection.AccessToken`, with plain ADO.NET or the EF Core factory. For Blob Storage inject the `BlobServiceClient`; the SDK asks the credential for a token on every request and the adapter answers from the held token: + +```csharp +using Azure.Storage.Blobs; + +public sealed class DocumentStore(BlobServiceClient blobServiceClient) +{ + public async Task ReadTextAsync(string containerName, string blobName, CancellationToken cancellationToken) + { + var blobClient = blobServiceClient.GetBlobContainerClient(containerName).GetBlobClient(blobName); + var download = await blobClient.DownloadContentAsync(cancellationToken); + return download.Value.Content.ToString(); + } +} +``` + +`IBlobStorageTokenProvider.GetBlobStorageAccessTokenAsync` returns the raw token string for code that calls the Blob REST API directly and sets the `Authorization: Bearer` header itself. `WorkloadIdentityTokenCredential` accepts any of the library's providers (`IAccessTokenProvider`), so the same adapter can present the Azure SQL provider to an SDK client that authenticates with a `TokenCredential`. + +## Options reference + +All keys live under the `Csag.WorkloadIdentity` section. `` stands for `AzureSql` or `BlobStorage`; the two sections have the same shape and are independent of each other. + +| Key | Required | Default | Description | +|---|---|---|---| +| `AzureSql` | at least one resource section | – | Configures how Azure SQL tokens are obtained. Without it, resolving `IAzureSqlTokenProvider` throws. | +| `BlobStorage` | at least one resource section | – | Configures how Blob Storage tokens are obtained. Without it, resolving `IBlobStorageTokenProvider` throws. | +| `:Provider` | no | `ManagedIdentity` | `ManagedIdentity` or `Google`. Selects which of the two provider sections is read; the other is ignored. | +| `:ManagedIdentity:UseSystemAssignedIdentity` | no | `false` | `true` uses the system-assigned identity of the hosting resource; `ClientId` is then ignored. | +| `:ManagedIdentity:ClientId` | with `ManagedIdentity`, unless `UseSystemAssignedIdentity` is `true` | – | Client ID of the user-assigned managed identity. | +| `:Google:TenantId` | with `Google` | – | Directory (tenant) ID of the Microsoft Entra tenant that issues the access token. | +| `:Google:ClientId` | with `Google` | – | Application (client) ID of the app registration, or client ID of the user-assigned managed identity, that holds the federated credential. | +| `:Google:ServiceAccountEmail` | with `Google` | – | The Google service account the ID token is minted for. The federated credential names this account's unique ID as its subject. | +| `RefreshAheadWindow` | no | `00:05:00` | How long before an access token expires it is treated as due for refresh. Applies to every resource. Must be positive. | +| `EnableBackgroundRefresh` | no | `true` | Whether a hosted service keeps the token of every configured resource refreshed ahead of its expiry. | + +Environment variables follow the usual .NET mapping, with `__` as the section separator and the `.` of the section name kept as is: `Csag.WorkloadIdentity__AzureSql__Provider`, `Csag.WorkloadIdentity__BlobStorage__ManagedIdentity__ClientId`, `Csag.WorkloadIdentity__RefreshAheadWindow`, and so on. + +### Registering with a given configuration or in code + +Besides `AddWorkloadIdentity()`, which binds the section from the `IConfiguration` registered in the container, two overloads exist. Given an `IServiceCollection services` and an `IConfiguration configuration`: + +```csharp +using Csag.WorkloadIdentity; +using Csag.WorkloadIdentity.Options; + +// Binds the "Csag.WorkloadIdentity" section of the given configuration. +services.AddWorkloadIdentity(configuration); + +// Sets the options in code; the section name is available as WorkloadIdentityOptions.ConfigurationSectionName. +services.AddWorkloadIdentity(options => +{ + options.AzureSql = new WorkloadIdentityResourceOptions + { + Provider = WorkloadIdentityProvider.Google, + Google = new GoogleOptions + { + TenantId = "", + ClientId = "", + ServiceAccountEmail = "@.iam.gserviceaccount.com", + }, + }; + options.BlobStorage = new WorkloadIdentityResourceOptions + { + Provider = WorkloadIdentityProvider.ManagedIdentity, + ManagedIdentity = new ManagedIdentityOptions { UseSystemAssignedIdentity = true }, + }; + options.RefreshAheadWindow = TimeSpan.FromMinutes(10); + options.EnableBackgroundRefresh = true; +}); +``` + +`WorkloadIdentityOptions`, `WorkloadIdentityResourceOptions`, `WorkloadIdentityProvider`, `GoogleOptions` and `ManagedIdentityOptions` live in `Csag.WorkloadIdentity.Options`; the provider interfaces and `IAccessTokenProvider` in `Csag.WorkloadIdentity.Abstractions`; `AddWorkloadIdentity`, `WorkloadIdentityTokenCredential` and `TokenScope` in `Csag.WorkloadIdentity`. + +The options are validated when the host starts: no resource section at all, a missing value in a configured resource's provider section, an unknown `Provider`, or a non-positive `RefreshAheadWindow` throws an `OptionsValidationException` that names every offending value by its path, for example `AzureSql:Google:ServiceAccountEmail must be provided.` Calling `AddWorkloadIdentity` more than once is harmless. Both token providers are always registered; resolving the provider of a resource whose section is absent throws an `InvalidOperationException` that names the missing section. + +## Migrating from Csag.AzureSqlFederatedIdentity / Neolution.AzureSqlFederatedIdentity + +`Csag.WorkloadIdentity` continues the `Neolution.AzureSqlFederatedIdentity` package (briefly renamed `Csag.AzureSqlFederatedIdentity`, without a release under that name). Azure SQL over Google federation works as before and needs no change on the Google or Microsoft side; what changes is the naming and the shape of the configuration. + +1. **Package and namespaces.** Replace the package reference with `Csag.WorkloadIdentity`, and the `Neolution.AzureSqlFederatedIdentity` (or `Csag.AzureSqlFederatedIdentity`) namespace prefix with `Csag.WorkloadIdentity` in `using` directives: `Csag.WorkloadIdentity.Abstractions` for the provider interfaces, `Csag.WorkloadIdentity.Options` for the options types. +2. **Registration.** `AddAzureSqlFederatedIdentity` becomes `AddWorkloadIdentity`, with the same three overloads (host configuration, `IConfiguration`, configure in code). `AzureSqlFederatedIdentityOptions` becomes `WorkloadIdentityOptions`, whose `ConfigurationSectionName` is `Csag.WorkloadIdentity`. +3. **Configuration.** The section is renamed and becomes resource-first: `TenantId` and `ClientId` move into the `Google` section, and that section moves under the resource it serves, `AzureSql`, next to `"Provider": "Google"`. + + Before: + + ```json + { + "Neolution.AzureSqlFederatedIdentity": { + "TenantId": "", + "ClientId": "", + "Google": { + "ServiceAccountEmail": "@.iam.gserviceaccount.com" + } + } + } + ``` + + After: + + ```json + { + "Csag.WorkloadIdentity": { + "AzureSql": { + "Provider": "Google", + "Google": { + "TenantId": "", + "ClientId": "", + "ServiceAccountEmail": "@.iam.gserviceaccount.com" + } + } + } + } + ``` + + Environment variables move the same way: `Neolution.AzureSqlFederatedIdentity__TenantId` becomes `Csag.WorkloadIdentity__AzureSql__Google__TenantId`, and so on. +4. **Unchanged.** `IAzureSqlTokenProvider.GetAzureSqlAccessTokenAsync` and the way the token is attached to `SqlConnection.AccessToken`; the token is held in memory, and a hosted service keeps it refreshed. +5. **New or removed.** `RefreshAheadWindow` and `EnableBackgroundRefresh` are optional root keys; keep their defaults unless you have a reason not to. `IGoogleIdTokenProvider.GetIdTokenAsync` now takes the service account email, so resources can federate through different service accounts. `IAzureSqlTokenExchanger` no longer exists; the exchange is performed by internal per-provider services, and `IAzureSqlTokenProvider` remains the seam to substitute. + ## Notes - **Connection string.** The access token is the credential, so the connection string must not contain `User ID`/`Password`, `Integrated Security` or an `Authentication` keyword: `SqlClient` throws an `InvalidOperationException` when `AccessToken` is combined with conflicting authentication settings. Keep `Encrypt=True` so the token and your data travel over TLS. -- **Runtime identity.** The Google ID token is requested through Application Default Credentials (ADC). Whatever identity ADC resolves to (the service account attached to the Cloud Run, GKE or Compute Engine resource, or your own account after `gcloud auth application-default login`) must hold `roles/iam.serviceAccountOpenIdTokenCreator` on the configured `ServiceAccountEmail`. The intended deployment is the simplest one: the application runs *as* that service account, with the role granted to the account on itself. +- **Google runtime identity.** The Google ID token is requested through Application Default Credentials (ADC). Whatever identity ADC resolves to (the service account attached to the Cloud Run, GKE or Compute Engine resource, or your own account after `gcloud auth application-default login`) must hold `roles/iam.serviceAccountOpenIdTokenCreator` on the configured `ServiceAccountEmail`. The intended deployment is the simplest one: the application runs *as* that service account, with the role granted to the account on itself. Two resources may name different service accounts; each is exchanged through its own credential. +- **Managed identity at runtime.** The token comes from the identity endpoint of the Azure resource the application runs on, so a system-assigned identity must be enabled on that resource and a user-assigned one attached to it. A workstation has no such endpoint; `ManagedIdentity` only works on Azure. A `ClientId` next to `"UseSystemAssignedIdentity": true` is ignored. - **Token handling.** The provider holds the token; do not cache or persist it yourself. `AccessToken` is part of the `SqlClient` connection pool key, so a refreshed token starts a new pool, which is expected. If your connection string sets `Min Pool Size` above zero, call `SqlConnection.ClearPool` with a connection that carries the old token once it has expired; otherwise the pool keeps that token's physical connections open indefinitely (see the [`AccessToken` remarks](https://learn.microsoft.com/en-us/dotnet/api/microsoft.data.sqlclient.sqlconnection.accesstoken)). -- **Background refresh.** When enabled, the hosted service exchanges a token as soon as the host starts and again when the held token enters the refresh-ahead window, or at half the token's remaining lifetime when that is shorter than the window (the wait is kept between 10 seconds and 1 day). A failed exchange is logged at `Error` and retried, waiting 5 seconds and doubling up to 5 minutes between attempts. When disabled, the first caller to find the token due for refresh performs the exchange while concurrent callers wait for its result. If you register your own `IAzureSqlTokenProvider`, the hosted service leaves it alone. -- **Logging.** All categories start with `Csag.WorkloadIdentity`. Exchanges and refreshes log at `Debug`; per-call reuse of the held token logs at `Trace`. +- **Background refresh.** When enabled, the hosted service runs one loop per configured resource, so a slow or failing resource does not delay the other. Each loop requests a token as soon as the host starts and again when the held token enters the refresh-ahead window, or at half the token's remaining lifetime when that is shorter than the window (the wait is kept between 10 seconds and 1 day). A failed request is logged at `Error` and retried, waiting 5 seconds and doubling up to 5 minutes between attempts. When disabled, the first caller to find the token due for refresh performs the request while concurrent callers wait for its result. If you register your own `IAzureSqlTokenProvider` or `IBlobStorageTokenProvider`, the hosted service leaves it alone. +- **Logging.** All categories start with `Csag.WorkloadIdentity`. Token requests and refreshes log at `Debug`; per-call reuse of the held token logs at `Trace`. ## Troubleshooting | Symptom | Likely cause | |---|---| -| The host fails to start with `OptionsValidationException` | A required key is missing or `RefreshAheadWindow` is not positive; the message names the value. | +| The host fails to start with `OptionsValidationException` | No resource section, a required key missing in a configured resource, an unknown `Provider`, or a non-positive `RefreshAheadWindow`; the message names the value. | +| `InvalidOperationException`: "The AzureSql resource is not configured" (or `BlobStorage`) | `IAzureSqlTokenProvider` or `IBlobStorageTokenProvider` was resolved, but the configuration has no section for that resource. | +| `CredentialUnavailableException`: no managed identity endpoint found | `ManagedIdentity` is selected but the application is not running on an Azure resource with a managed identity, for example on a workstation. | +| The managed identity endpoint reports that the identity was not found | The user-assigned identity is not attached to the hosting resource, or `ManagedIdentity:ClientId` is wrong. | | `RpcException` with status `PermissionDenied` in the log; the caller receives it as the inner exception of an `AuthenticationFailedException` | The runtime identity lacks `roles/iam.serviceAccountOpenIdTokenCreator` on the service account, or the IAM Service Account Credentials API is not enabled in the project. | | "Failed to create IAMCredentialsClient" in the log | No Application Default Credentials were found; set the runtime service account, or run `gcloud auth application-default login` on a workstation. | -| Microsoft Entra ID rejects the assertion because no matching federated identity credential was found | The federated credential's issuer, subject (the service account's unique ID) or audience does not match the ID token. | -| Azure SQL reports "Login failed for user" | No database user exists for the app registration in the target database, or the server's network rules block the connection. | +| Microsoft Entra ID rejects the assertion because no matching federated identity credential was found | The federated credential's issuer, subject (the service account's unique ID) or audience does not match the ID token, or `Google:ClientId` names a different identity than the one holding the credential. | +| Azure SQL reports "Login failed for user" | No database user exists for the identity in the target database, or the server's network rules block the connection. | +| Blob Storage returns `403` with `AuthorizationPermissionMismatch` | The identity has no Blob data role on the storage account or container, or the role assignment has not propagated yet (it can take up to 10 minutes). | ## License diff --git a/README.md b/README.md index c5e3d8b..5924959 100644 --- a/README.md +++ b/README.md @@ -4,26 +4,26 @@ [![NuGet](https://img.shields.io/nuget/v/Csag.WorkloadIdentity.svg)](https://www.nuget.org/packages/Csag.WorkloadIdentity) [![License: MIT](https://img.shields.io/badge/License-MIT-lightgray.svg)](./LICENSE) -A .NET library that lets an application running on Google Cloud connect to Azure SQL without a password, secret or key. It exchanges the application's Google identity for a Microsoft Entra ID access token through workload identity federation (Google-signed ID token → Microsoft Entra ID access token) and you attach that token to the `SqlConnection`. +A .NET library that gives an application passwordless access tokens for Azure SQL and Azure Blob Storage from the identity it already runs as. Each resource is configured on its own and obtains its token either from the **managed identity** of the Azure resource the application runs on, or from the application's **Google identity**, exchanged for a Microsoft Entra ID access token through workload identity federation. You attach the token to the `SqlConnection`, or hand it to Azure SDK clients such as `BlobServiceClient` through the library's `TokenCredential` adapter. No password, key or secret is stored anywhere. ## Projects | Project | Description | |---|---| | [`Csag.WorkloadIdentity`](./Csag.WorkloadIdentity) | The library, published as the [Csag.WorkloadIdentity](https://www.nuget.org/packages/Csag.WorkloadIdentity) NuGet package for `net8.0` and `net10.0`. Its [README](./Csag.WorkloadIdentity/README.md) is the package documentation; the [CHANGELOG](./Csag.WorkloadIdentity/CHANGELOG.md) is generated from changesets. | -| [`Csag.WorkloadIdentity.Demo`](./Csag.WorkloadIdentity.Demo) | An ASP.NET Core application that reads from Azure SQL and, optionally, Blob Storage through the library, on Cloud Run or an Azure host. Its [README](./Csag.WorkloadIdentity.Demo/README.md) explains how to run it from source, in Docker and on Cloud Run. | +| [`Csag.WorkloadIdentity.Demo`](./Csag.WorkloadIdentity.Demo) | An ASP.NET Core application that reads from Azure SQL and, optionally, Blob Storage through the library, on Cloud Run or an Azure host. Its [README](./Csag.WorkloadIdentity.Demo/README.md) explains how to configure it and run it from source, in Docker and in the cloud. | | [`Csag.WorkloadIdentity.UnitTests`](./Csag.WorkloadIdentity.UnitTests) | xunit tests for the library, run on both target frameworks. | ## Quick start -1. Set up the cloud side once: the Google service account and its IAM role, the Microsoft Entra ID app registration with a federated credential, the Azure SQL database user and the Cloud Run runtime identity. Every step is in [docs/cloud-identity-setup.md](./docs/cloud-identity-setup.md). -2. Install the package and wire it into your application. The [package README](./Csag.WorkloadIdentity/README.md) has the complete quickstart: configuration keys, service registration and attaching the token to `SqlConnection`, with plain ADO.NET and with EF Core. +1. Set up the cloud side once. Choose the identity configuration that matches where the application runs, an Azure managed identity or Google Cloud to Azure federation, then grant that identity access to Azure SQL and Blob Storage. Every step is in [docs/cloud-identity-setup.md](./docs/cloud-identity-setup.md). +2. Install the package and wire it into your application. The [package README](./Csag.WorkloadIdentity/README.md) has two complete quickstarts, Azure SQL from Google Cloud Run and Azure SQL plus Blob Storage from Azure App Service, the reference of every configuration key, and a migration section for users of `Neolution.AzureSqlFederatedIdentity`. ```shell dotnet add package Csag.WorkloadIdentity ``` -3. To see it end to end, run the [Demo](./Csag.WorkloadIdentity.Demo/README.md) against your own database. +3. To see it end to end, run the [Demo](./Csag.WorkloadIdentity.Demo/README.md) against your own resources. ## Release process diff --git a/SECURITY.md b/SECURITY.md index 208fc4b..f4b703d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,6 +1,6 @@ # Security policy -Csag.WorkloadIdentity handles credentials: it obtains Google ID tokens and Microsoft Entra ID access tokens and keeps the current access token in memory. We take reports about it seriously. +Csag.WorkloadIdentity handles credentials: it obtains Microsoft Entra ID access tokens for Azure SQL and Azure Blob Storage, from a managed identity or by exchanging Google ID tokens, and keeps the current access tokens in memory. We take reports about it seriously. ## Supported versions diff --git a/docs/cloud-identity-setup.md b/docs/cloud-identity-setup.md index c0e61cc..aa437a0 100644 --- a/docs/cloud-identity-setup.md +++ b/docs/cloud-identity-setup.md @@ -1,8 +1,74 @@ # Cloud and identity setup -This guide sets up everything outside your code so that an application on Google Cloud can connect to Azure SQL through `Csag.WorkloadIdentity`: the Google Cloud service account and its permissions, the app registration and federated credential in Microsoft Entra ID (formerly Azure Active Directory), the database user in Azure SQL, the application's configuration and the Cloud Run deployment. For the code that uses the token, see the [package README](../Csag.WorkloadIdentity/README.md). +This guide sets up everything outside your code so that an application can reach Azure SQL and Azure Blob Storage through `Csag.WorkloadIdentity` without a password, key or secret: the identity the application presents to Microsoft Entra ID (formerly Azure Active Directory), the trust that identity needs, the grants on each resource, the application's configuration and the deployment. For the code that uses the tokens, see the [package README](../Csag.WorkloadIdentity/README.md); the [Demo](../Csag.WorkloadIdentity.Demo/README.md) is a complete application. -## How the pieces fit +## Choose your identity configuration + +Every access token the library obtains is issued by Microsoft Entra ID to one identity. Where the application runs decides which identity that is: + +| | A. Azure managed identity | B. Google Cloud to Azure federation | +|---|---|---| +| The application runs on | Azure: App Service, Container Apps, Functions, a virtual machine, AKS | Google Cloud: Cloud Run, GKE, Compute Engine; or a workstation with Application Default Credentials | +| The identity | The system-assigned managed identity of the hosting resource, or a user-assigned managed identity attached to it | An app registration or a user-assigned managed identity that holds a federated credential trusting the application's Google service account | +| `Provider` in the configuration | `ManagedIdentity` | `Google` | +| Set up in | [Section A](#a-azure-managed-identity) | [Section B](#b-google-cloud-to-azure-federation) | + +The choice is made per resource (`AzureSql`, `BlobStorage`), so one application can, for example, use its system-assigned identity for Azure SQL and a user-assigned identity for Blob Storage. Whatever the identity, the grants in [section C](#c-grant-access-to-the-resources) are the same, [section D](#d-configure-the-application) maps everything onto the configuration keys, and [section E](#e-deploy) covers the deployment. + +Values you collect along the way: + +| Value | Comes from | Used as | +|---|---|---| +| Identity name: the App Service name (system-assigned), the managed identity's name or the app registration's display name | A.1, A.2 or B.2 | Database user name (C.1); the member of the Blob role assignment (C.2) | +| Client ID of a user-assigned managed identity | A.2 or B.2 | `ManagedIdentity:ClientId` (A) or `Google:ClientId` (B) | +| Application (client) ID of an app registration | B.2 | `Google:ClientId` | +| Directory (tenant) ID | B.2 | `Google:TenantId` | +| Service account email and unique ID | B.1 | `Google:ServiceAccountEmail`; subject of the federated credential (B.2) | + +## A. Azure managed identity + +A [managed identity](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview) is an identity in Microsoft Entra ID whose credentials Azure creates and rotates; the application obtains tokens from the hosting resource's local identity endpoint and never sees a secret. Nothing is needed at runtime beyond the identity being enabled on, or attached to, the resource the application runs on. The steps below use App Service; Container Apps, Functions, virtual machines and AKS expose the same **Identity** setting. + +### A.1 System-assigned identity + +A system-assigned identity is created with the resource, shares its lifecycle and cannot be attached to anything else. It is the simplest choice for a single application. + +1. In the Azure portal open the App Service and go to **Settings** > **Identity**. +2. On the **System assigned** tab switch **Status** to **On** and select **Save**. + +Or with the Azure CLI: + +```shell +az webapp identity assign --resource-group --name +``` + +The name of a system-assigned identity is always the name of the App Service; that is the `` the database user in C.1 is created with. Configure the resource with `"UseSystemAssignedIdentity": true`; no client ID is needed. + +### A.2 User-assigned identity + +A user-assigned identity is a standalone Azure resource that can be attached to several resources and outlives any of them, so its grants can be made before the application exists and survive its re-creation. + +1. In the Azure portal search for **Managed Identities**, select **Create**, choose the subscription, resource group, region and a name, and create it. +2. On the identity's **Overview** page note the **Client ID**. +3. Open the App Service, go to **Settings** > **Identity** > **User assigned**, select **Add**, pick the identity and confirm. + +Or with the Azure CLI: + +```shell +az identity create --resource-group --name +az identity show --resource-group --name --query clientId --output tsv +az webapp identity assign --resource-group --name --identities +``` + +`` is the identity's full resource ID (`az identity show ... --query id --output tsv`). The identity's name is the `` for C.1; its client ID goes into `ManagedIdentity:ClientId`. + +### A.3 Local development + +A workstation has no managed identity endpoint, so `"Provider": "ManagedIdentity"` only works on Azure. To run the same code locally, either configure the resource with the `Google` provider and Application Default Credentials (section B), or register your own `IAzureSqlTokenProvider` or `IBlobStorageTokenProvider` before calling `AddWorkloadIdentity` (every registration is a `TryAdd`, so yours wins), for example one built on `Azure.Identity`'s `AzureCliCredential`. + +## B. Google Cloud to Azure federation + +With [workload identity federation](https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation), Microsoft Entra ID accepts a token from an external identity provider as the credential of one of its own identities. Here the external token is a Google-signed ID token for the application's service account, and the Microsoft Entra identity that trusts it is either an app registration or a user-assigned managed identity: ```text Your application, running as a Google service account (Application Default Credentials) @@ -11,28 +77,18 @@ Your application, running as a Google service account (Application Default Crede ▼ Google-signed ID token iss = https://accounts.google.com, sub = │ - │ 2. client assertion for app registration in tenant Microsoft Entra ID + │ 2. client assertion for in tenant Microsoft Entra ID ▼ -Access token for https://database.windows.net/.default +Access token for https://database.windows.net/.default or https://storage.azure.com/.default │ - │ 3. SqlConnection.AccessToken + │ 3. SqlConnection.AccessToken, or TokenCredential for BlobServiceClient ▼ -Azure SQL, as the database user created for the app registration +Azure SQL or Blob Storage, as the identity that holds the federated credential ``` -Values you collect along the way: - -| Value | Comes from | Used as | -|---|---|---| -| Service account email | Google Cloud, step 1.2 | `Google:ServiceAccountEmail` (step 4) | -| Service account unique ID | Google Cloud, step 1.2 | Subject of the federated credential (step 2.2) | -| Directory (tenant) ID | Microsoft Entra ID, step 2.1 | `TenantId` (step 4) | -| Application (client) ID | Microsoft Entra ID, step 2.1 | `ClientId` (step 4) | -| App registration display name | Microsoft Entra ID, step 2.1 | Database user name (step 3.2) | - -## 1. Google Cloud +### B.1 Google Cloud -### 1.1 Enable the IAM Service Account Credentials API +#### B.1.1 Enable the IAM Service Account Credentials API The library mints the ID token by calling `generateIdToken` on this API. It must be enabled in the project that owns the service account, otherwise every call fails with `PERMISSION_DENIED`: @@ -40,13 +96,13 @@ The library mints the ID token by calling `generateIdToken` on this API. It must gcloud services enable iamcredentials.googleapis.com --project ``` -### 1.2 Create the service account +#### B.1.2 Create the service account ```shell gcloud iam service-accounts create --project --display-name "" ``` -The service account's email is `@.iam.gserviceaccount.com`. Also note its numeric **unique ID**, which the federated credential in step 2.2 uses as the subject: +The service account's email is `@.iam.gserviceaccount.com`. Also note its numeric **unique ID**, which the federated credential in B.2 uses as the subject: ```shell gcloud iam service-accounts describe @.iam.gserviceaccount.com --format 'value(uniqueId)' @@ -54,7 +110,7 @@ gcloud iam service-accounts describe @.iam.gserviceaccount.com The Google Cloud console shows both values on the service account's details page under **IAM & Admin** > **Service Accounts**. -### 1.3 Grant the least privilege needed +#### B.1.3 Grant the least privilege needed The only IAM operation the library performs is `generateIdToken`, which requires the single permission `iam.serviceAccounts.getOpenIdToken` on the service account. The predefined role that contains exactly that permission is **Service Account OpenID Connect Identity Token Creator** (`roles/iam.serviceAccountOpenIdTokenCreator`). Grant it to the identity the application runs as, **on the service account resource itself**: @@ -64,7 +120,7 @@ gcloud iam service-accounts add-iam-policy-binding @.iam.gserv --role roles/iam.serviceAccountOpenIdTokenCreator ``` -The member here is the service account itself, which is the intended deployment: the Cloud Run service runs as this account (step 5) and mints ID tokens for it. Google's Service Account Credentials API allows this; its [self-impersonation](https://cloud.google.com/iam/docs/service-account-creds) restriction covers generating access tokens and signing, not ID tokens. A developer who runs the application on a workstation needs the same role on the service account for their own account: +The member here is the service account itself, which is the intended deployment: the Cloud Run service runs as this account (E.2) and mints ID tokens for it. Google's Service Account Credentials API allows this; its [self-impersonation](https://cloud.google.com/iam/docs/service-account-creds) restriction covers generating access tokens and signing, not ID tokens. A developer who runs the application on a workstation needs the same role on the service account for their own account: ```shell gcloud iam service-accounts add-iam-policy-binding @.iam.gserviceaccount.com \ @@ -77,94 +133,154 @@ Why this binding and not a broader one: - **Bind on the service account, not on the project.** A project-level binding (`gcloud projects add-iam-policy-binding`) applies to every service account in the project, including ones created later, so the member could mint ID tokens for all of them. A binding on the service account limits the grant to that one account, which is what Google recommends for [direct short-lived credentials](https://cloud.google.com/iam/docs/create-short-lived-credentials-direct). - **Prefer the OpenID Token Creator role to Service Account Token Creator.** `roles/iam.serviceAccountTokenCreator` additionally grants `iam.serviceAccounts.getAccessToken`, `signBlob`, `signJwt` and `implicitDelegation`, that is, full impersonation of the account. The library needs none of those. -### 1.4 Application Default Credentials +#### B.1.4 Application Default Credentials The library never reads a key file of its own. It uses [Application Default Credentials](https://cloud.google.com/docs/authentication/application-default-credentials) (ADC), which the Google client library resolves from the `GOOGLE_APPLICATION_CREDENTIALS` environment variable, then from the credential file written by `gcloud auth application-default login`, and finally from the service account attached to the Cloud Run, GKE or Compute Engine resource. -- On Cloud Run nothing needs configuring beyond the runtime service account (step 5). -- On a workstation run `gcloud auth application-default login` once. The application then acts as your user account, which needs the role from step 1.3. +- On Cloud Run nothing needs configuring beyond the runtime service account (E.2). +- On a workstation run `gcloud auth application-default login` once. The application then acts as your user account, which needs the role from B.1.3. - Do not create a service account key; nothing in this setup requires one. -## 2. Microsoft Entra ID +### B.2 Microsoft Entra ID: the holder of the federated credential + +The federated credential is attached to a Microsoft Entra identity, and that identity is what Azure SQL and Blob Storage see. The library accepts either kind of holder in `Google:ClientId`; pick one: + +| | Option 1: app registration | Option 2: user-assigned managed identity | +|---|---|---| +| Created in | Microsoft Entra ID (**App registrations**); requires permission to register applications in the tenant | An Azure subscription (**Managed Identities**); requires Azure RBAC rights on a resource group and no Microsoft Entra role | +| `Google:ClientId` | The registration's **Application (client) ID** | The identity's **Client ID** | +| `` for the grants in section C | The registration's display name | The identity's name | -### 2.1 Register an application +Both options need the **Directory (tenant) ID** of the tenant, shown on the tenant's **Microsoft Entra ID** > **Overview** page, as `Google:TenantId`. -1. In the [Azure portal](https://portal.azure.com/) go to **Microsoft Entra ID** > **App registrations** > **New registration**. +#### B.2.1 Option 1: register an application + +1. In the Azure portal go to **Microsoft Entra ID** > **App registrations** > **New registration**. 2. Enter a name. "Accounts in this organizational directory only" is sufficient, and no redirect URI is needed. 3. On the registration's **Overview** page copy the **Application (client) ID** and the **Directory (tenant) ID**, and note the display name. -Do not create a client secret or certificate: the federated credential in the next step replaces them. +Do not create a client secret or certificate: the federated credential in B.2.3 replaces them. + +#### B.2.2 Option 2: create a user-assigned managed identity + +Create the identity as in A.2 (portal or `az identity create`) and note its **Client ID** and name. It is only the holder of the federated credential, so it does not need to be attached to any Azure resource. Microsoft documents this scenario under [Configure a user-assigned managed identity to trust an external identity provider](https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation-create-trust-user-assigned-managed-identity). + +#### B.2.3 Add the federated credential + +Where the credential is added depends on the holder: -### 2.2 Add a federated credential +- **App registration:** under the registration go to **Certificates & secrets** > **Federated credentials** > **Add credential** and choose the **Other issuer** scenario. +- **User-assigned managed identity:** under the identity go to **Settings** > **Federated credentials** > **Add Credential** and choose the **Other issuer** scenario. -Under the registration go to **Certificates & secrets** > **Federated credentials** > **Add credential** and choose the **Other issuer** scenario: +Fill in the same values in both cases: | Field | Value | |---|---| | Issuer | `https://accounts.google.com` | -| Subject identifier | The service account's **unique ID** from step 1.2 (the numeric ID, not the email) | +| Subject identifier | The service account's **unique ID** from B.1.2 (the numeric ID, not the email) | | Audience | `api://AzureADTokenExchange` (the default) | | Name | Any name, for example the service account's name | -The audience is fixed: the library requests every Google ID token with exactly `api://AzureADTokenExchange`, which is the value Microsoft Entra ID recommends for workload identity federation. It is not the app registration's client ID and not the Azure SQL resource. Microsoft Entra ID fetches Google's signing keys through the issuer, verifies the ID token's signature, and accepts it as a client assertion only if its `sub` and `aud` claims match this credential. +The audience is fixed: the library requests every Google ID token with exactly `api://AzureADTokenExchange`, which is the value Microsoft Entra ID recommends for workload identity federation. It is neither the holder's client ID nor the Azure resource. Microsoft Entra ID fetches Google's signing keys through the issuer, verifies the ID token's signature, and accepts it as a client assertion only if its `sub` and `aud` claims match this credential. -## 3. Azure SQL +## C. Grant access to the resources -### 3.1 Set a Microsoft Entra admin for the server +The identity from section A or B needs a grant on each resource the application is configured for. The grant is the same whichever way the identity was set up. `` below is the name from the table at the top: the App Service name for a system-assigned identity, the managed identity's name, or the app registration's display name. -In the Azure portal open the logical SQL server and, under **Settings** > **Microsoft Entra ID**, set an admin if none is set. Only a Microsoft Entra identity can create database users from an external provider, so connect to the database as that admin (for example with SQL Server Management Studio, Azure Data Studio or `sqlcmd` using Microsoft Entra authentication) to run the statements below. +### C.1 Azure SQL -### 3.2 Create a database user for the app registration +#### C.1.1 Set a Microsoft Entra admin for the server -In the target database (not in `master`), create a contained user for the app registration. The user represents the app registration; the Google service account never appears in Azure SQL: +In the Azure portal open the logical SQL server, select **Microsoft Entra ID** under **Settings**, then **Set admin**, if no admin is set. Only a Microsoft Entra identity can create database users from an external provider, so connect to the database as that admin (for example with SQL Server Management Studio, Azure Data Studio or `sqlcmd` using Microsoft Entra authentication) to run the statements below. If the admin is itself a service principal or managed identity rather than a user, the server additionally needs a server identity with permission to read Microsoft Graph before it can create such users; Microsoft describes that setup under [service principals with Azure SQL](https://learn.microsoft.com/en-us/azure/azure-sql/database/authentication-aad-service-principal). A user admin needs nothing extra. + +#### C.1.2 Create a database user for the identity + +In the target database (not in `master`), create a contained user for the identity. With Google federation, the user represents the holder of the federated credential; the Google service account never appears in Azure SQL: Creating a user for a service principal, which is what an app registration or a managed identity is, needs one more thing than creating one for a person: Azure SQL cannot look the principal up with the connected admin's permissions, so the SQL engine uses the *server identity*, the managed identity assigned to the logical server, to query Microsoft Graph. Assign the server an identity (in the portal under the server's **Identity** page, or `az sql server update --resource-group --name --assign-identity`) and grant that identity permission to read the directory: add it to the Microsoft Entra **Directory Readers** role, or grant it the Microsoft Graph application permissions `User.Read.All`, `GroupMember.Read.All` and `Application.Read.All`. Without this, the statement below fails with "Principal '…' could not be found or this principal type is not supported". See [Microsoft Entra service principals with Azure SQL](https://learn.microsoft.com/en-us/azure/azure-sql/database/authentication-aad-service-principal). ```sql -CREATE USER [] FROM EXTERNAL PROVIDER; +CREATE USER [] FROM EXTERNAL PROVIDER; ``` Then grant permissions. The built-in roles are a convenient starting point: ```sql -ALTER ROLE db_datareader ADD MEMBER []; -ALTER ROLE db_datawriter ADD MEMBER []; +ALTER ROLE db_datareader ADD MEMBER []; +ALTER ROLE db_datawriter ADD MEMBER []; ``` `db_datareader` and `db_datawriter` allow reading and writing every table in the database. Narrow that to what the application actually needs: a read-only service needs only `db_datareader`, and explicit grants scope access to particular objects, for example: ```sql -GRANT SELECT, INSERT ON dbo.Orders TO []; -GRANT EXECUTE ON SCHEMA::dbo TO []; +GRANT SELECT, INSERT ON dbo.Orders TO []; +GRANT EXECUTE ON SCHEMA::dbo TO []; ``` Neither built-in role allows schema changes; run migrations with a separate, more privileged identity rather than widening this one. -### 3.3 Allow the network path +#### C.1.3 Allow the network path + +The server's [network access controls](https://learn.microsoft.com/en-us/azure/azure-sql/database/network-access-controls-overview) must admit connections from where the application runs. + +- **From Azure:** the server's **Allow Azure services and resources to access this server** setting admits every resource inside Azure, including other customers', which is more permissive than most deployments want. Prefer an IP firewall rule for the App Service's outbound addresses, or private connectivity through virtual network integration and a private endpoint. +- **From Cloud Run:** its outbound IP addresses are not fixed by default, so to use IP firewall rules give the service a [static outbound IP address](https://cloud.google.com/run/docs/configuring/static-outbound-ip), or use private connectivity instead. + +### C.2 Azure Blob Storage + +Blob access is granted with Azure role-based access control on the storage account or, more narrowly, on a single container. Two [built-in roles](https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/storage) cover most applications: + +| Role | Grants | +|---|---| +| **Storage Blob Data Reader** | Read and list containers and blobs | +| **Storage Blob Data Contributor** | Read, write and delete containers and blobs | + +1. In the Azure portal open the storage account (or the container) and go to **Access control (IAM)** > **Add** > **Add role assignment**. +2. Select the role. On the **Members** tab choose **Managed identity** and pick the identity (a system-assigned identity is listed under its resource type, for example App Service), or choose **User, group, or service principal** and search for the app registration by name. +3. Select **Review + assign**. + +Or with the Azure CLI, given the object (principal) ID of the identity: + +```shell +az role assignment create \ + --role "Storage Blob Data Reader" \ + --assignee-object-id \ + --assignee-principal-type ServicePrincipal \ + --scope /subscriptions//resourceGroups//providers/Microsoft.Storage/storageAccounts/ +``` + +The object ID is `az identity show ... --query principalId --output tsv` for a user-assigned identity, `az webapp identity show --resource-group --name --query principalId --output tsv` for a system-assigned one, and `az ad sp show --id --query id --output tsv` for an app registration's service principal. Append `/blobServices/default/containers/` to the scope to restrict the grant to one container. A new [role assignment](https://learn.microsoft.com/en-us/azure/storage/blobs/assign-azure-role-data-access) can take up to 10 minutes to take effect. -The server's [network access controls](https://learn.microsoft.com/en-us/azure/azure-sql/database/network-access-controls-overview) must admit connections from where the application runs. Cloud Run's outbound IP addresses are not fixed by default, so to use IP firewall rules give the service a [static outbound IP address](https://cloud.google.com/run/docs/configuring/static-outbound-ip), or use private connectivity instead. +The application then needs only the account's blob endpoint, `https://.blob.core.windows.net`. Because nothing uses the account keys any more, you can [disallow Shared Key authorization](https://learn.microsoft.com/en-us/azure/storage/common/shared-key-authorization-prevent) on the account (**Settings** > **Configuration** > **Allow storage account key access** set to **Disabled**), so that a leaked key cannot be used. -## 4. Configure the application +## D. Configure the application -The library binds its options from the `Csag.WorkloadIdentity` configuration section. These are the exact keys, with the environment variable form that .NET maps to the same keys (`__` stands for the `:` separator; the `.` in the section name is part of the variable name): +The library binds its options from the `Csag.WorkloadIdentity` configuration section. Each resource has its own section that selects the `Provider` and carries that provider's settings. These are the exact keys, with the environment variable form that .NET maps to the same keys (`__` stands for the `:` separator; the `.` in the section name is part of the variable name): | Configuration key | Environment variable | Value | |---|---|---| -| `Csag.WorkloadIdentity:TenantId` | `Csag.WorkloadIdentity__TenantId` | Directory (tenant) ID (step 2.1) | -| `Csag.WorkloadIdentity:ClientId` | `Csag.WorkloadIdentity__ClientId` | Application (client) ID (step 2.1) | -| `Csag.WorkloadIdentity:Google:ServiceAccountEmail` | `Csag.WorkloadIdentity__Google__ServiceAccountEmail` | Service account email (step 1.2) | -| `Csag.WorkloadIdentity:RefreshAheadWindow` | `Csag.WorkloadIdentity__RefreshAheadWindow` | Optional; how long before expiry the token is refreshed. Default `00:05:00`, must be positive | +| `Csag.WorkloadIdentity:AzureSql:Provider` | `Csag.WorkloadIdentity__AzureSql__Provider` | `ManagedIdentity` (the default) or `Google` | +| `Csag.WorkloadIdentity:AzureSql:ManagedIdentity:UseSystemAssignedIdentity` | `Csag.WorkloadIdentity__AzureSql__ManagedIdentity__UseSystemAssignedIdentity` | `true` for a system-assigned identity (A.1) | +| `Csag.WorkloadIdentity:AzureSql:ManagedIdentity:ClientId` | `Csag.WorkloadIdentity__AzureSql__ManagedIdentity__ClientId` | Client ID of the user-assigned identity (A.2); required unless the previous key is `true` | +| `Csag.WorkloadIdentity:AzureSql:Google:TenantId` | `Csag.WorkloadIdentity__AzureSql__Google__TenantId` | Directory (tenant) ID (B.2) | +| `Csag.WorkloadIdentity:AzureSql:Google:ClientId` | `Csag.WorkloadIdentity__AzureSql__Google__ClientId` | Application (client) ID of the app registration, or client ID of the user-assigned identity, holding the federated credential (B.2) | +| `Csag.WorkloadIdentity:AzureSql:Google:ServiceAccountEmail` | `Csag.WorkloadIdentity__AzureSql__Google__ServiceAccountEmail` | Service account email (B.1.2) | +| `Csag.WorkloadIdentity:BlobStorage:...` | `Csag.WorkloadIdentity__BlobStorage__...` | The same keys, for Blob Storage | +| `Csag.WorkloadIdentity:RefreshAheadWindow` | `Csag.WorkloadIdentity__RefreshAheadWindow` | Optional; how long before expiry a token is refreshed. Default `00:05:00`, must be positive | | `Csag.WorkloadIdentity:EnableBackgroundRefresh` | `Csag.WorkloadIdentity__EnableBackgroundRefresh` | Optional; default `true` | -The first three are required; the application refuses to start if any of them is missing. In `appsettings.json`: +At least one resource section is required, and a configured resource must carry the complete settings of the provider it selects; the application refuses to start otherwise and names the missing value. For the identity configuration A, using the system-assigned identity for both resources: ```json { "Csag.WorkloadIdentity": { - "TenantId": "", - "ClientId": "", - "Google": { - "ServiceAccountEmail": "@.iam.gserviceaccount.com" + "AzureSql": { + "Provider": "ManagedIdentity", + "ManagedIdentity": { "UseSystemAssignedIdentity": true } + }, + "BlobStorage": { + "Provider": "ManagedIdentity", + "ManagedIdentity": { "UseSystemAssignedIdentity": true } } }, "ConnectionStrings": { @@ -173,17 +289,55 @@ The first three are required; the application refuses to start if any of them is } ``` -The connection string is your own application's setting (the Demo reads `ConnectionStrings:DefaultConnection`). It names only the server and database: the access token is the credential, so it must not contain `User ID`/`Password`, `Integrated Security` or an `Authentication` keyword, and `Encrypt=True` keeps the token and the data on TLS. The [package README](../Csag.WorkloadIdentity/README.md) shows how to register the library and attach the token to a `SqlConnection`. +For a user-assigned identity replace the `ManagedIdentity` section with `{ "ClientId": "" }`. For the identity configuration B, with Azure SQL only: -## 5. Cloud Run +```json +{ + "Csag.WorkloadIdentity": { + "AzureSql": { + "Provider": "Google", + "Google": { + "TenantId": "", + "ClientId": "", + "ServiceAccountEmail": "@.iam.gserviceaccount.com" + } + } + }, + "ConnectionStrings": { + "DefaultConnection": "Server=tcp:.database.windows.net,1433;Initial Catalog=;Encrypt=True" + } +} +``` + +The connection string and the storage account's blob endpoint are your own application's settings (the Demo reads `ConnectionStrings:DefaultConnection`). The connection string names only the server and database: the access token is the credential, so it must not contain `User ID`/`Password`, `Integrated Security` or an `Authentication` keyword, and `Encrypt=True` keeps the token and the data on TLS. The [package README](../Csag.WorkloadIdentity/README.md) shows how to register the library, attach the token to a `SqlConnection` and build a `BlobServiceClient` on the library's `TokenCredential` adapter. + +## E. Deploy + +### E.1 Azure App Service + +The identity from section A is already attached to the App Service, so only the settings remain. Supply them as application settings, which App Service exposes to the process as environment variables: in the portal under **Settings** > **Environment variables** > **App settings**, or with the Azure CLI: + +```shell +az webapp config appsettings set --resource-group --name --settings \ + Csag.WorkloadIdentity__AzureSql__Provider=ManagedIdentity \ + Csag.WorkloadIdentity__AzureSql__ManagedIdentity__UseSystemAssignedIdentity=true \ + Csag.WorkloadIdentity__BlobStorage__Provider=ManagedIdentity \ + Csag.WorkloadIdentity__BlobStorage__ManagedIdentity__UseSystemAssignedIdentity=true \ + "ConnectionStrings__DefaultConnection=Server=tcp:.database.windows.net,1433;Initial Catalog=;Encrypt=True" +``` + +The same keys work for Container Apps, Functions and virtual machines, in whatever way each supplies environment variables. + +### E.2 Google Cloud Run -Deploy the application with the service account from step 1 as its runtime identity, so that ADC resolves to that account, and supply the settings as environment variables. A YAML file keeps the connection string's commas out of the command line: +Deploy the application with the service account from B.1 as its runtime identity, so that ADC resolves to that account, and supply the settings as environment variables. A YAML file keeps the connection string's commas out of the command line: ```yaml # env.yaml -Csag.WorkloadIdentity__TenantId: "" -Csag.WorkloadIdentity__ClientId: "" -Csag.WorkloadIdentity__Google__ServiceAccountEmail: "@.iam.gserviceaccount.com" +Csag.WorkloadIdentity__AzureSql__Provider: "Google" +Csag.WorkloadIdentity__AzureSql__Google__TenantId: "" +Csag.WorkloadIdentity__AzureSql__Google__ClientId: "" +Csag.WorkloadIdentity__AzureSql__Google__ServiceAccountEmail: "@.iam.gserviceaccount.com" ConnectionStrings__DefaultConnection: "Server=tcp:.database.windows.net,1433;Initial Catalog=;Encrypt=True" ``` @@ -198,9 +352,36 @@ gcloud run deploy \ To change only the identity of an existing service, use `gcloud run services update --service-account @.iam.gserviceaccount.com`. In the console, the runtime service account is under the service's **Security** tab and the variables under **Variables & Secrets**. -At startup the application validates its configuration: the three required settings must be present and `RefreshAheadWindow` must be positive. With background refresh on (the default) the first token exchange happens right after startup; with it off, on the first database access. Either way the application requests an ID token for the configured service account through ADC (as the runtime service account), exchanges it at Microsoft Entra ID, and opens the connection with the resulting access token. If something fails, the application log names the failing step; the troubleshooting table in the [package README](../Csag.WorkloadIdentity/README.md) maps the usual messages to their cause. +At startup the application validates its configuration. On the first access to a resource (or right away, with the background refresh on) it requests an ID token for the configured service account through ADC (as the runtime service account), exchanges it at Microsoft Entra ID, and uses the resulting access token. If something fails, the application log names the failing step; the troubleshooting table in the [package README](../Csag.WorkloadIdentity/README.md) maps the usual messages to their cause. + +## Security best practice: disable SQL authentication + +Once every application and every person reaches the database with a Microsoft Entra identity, password-based access is only an attack surface. Remove it in two steps. + +1. **Audit and remove password-based users.** Connected as the Microsoft Entra admin, list the principals that authenticate with a password, that is, contained users with their own password (`DATABASE`) and users mapped to server logins (`INSTANCE`), and drop the ones no longer needed: + + ```sql + SELECT name, type_desc, authentication_type_desc + FROM sys.database_principals + WHERE authentication_type_desc IN ('DATABASE', 'INSTANCE'); + + DROP USER []; + ``` + + Do this only after confirming that everything that used those users has moved to the passwordless connection. + +2. **Enable Microsoft Entra-only authentication on the server.** This turns off SQL authentication for the whole logical server, including the server admin login; existing SQL logins are kept but can no longer connect. In the portal open the server's **Microsoft Entra ID** page under **Settings** and check **Support only Microsoft Entra authentication for this server**, or run: + + ```shell + az sql server ad-only-auth enable --resource-group --name + ``` + + A Microsoft Entra admin must be set first (C.1.1), and the person enabling the feature needs a highly privileged role on the server such as Owner, Contributor or SQL Security Manager. Microsoft describes the feature and its consequences under [Microsoft Entra-only authentication](https://learn.microsoft.com/en-us/azure/azure-sql/database/authentication-azure-ad-only-authentication). + +The counterpart for Blob Storage is disallowing Shared Key authorization on the storage account, described at the end of C.2. ## References - Google Cloud: [Create short-lived credentials for a service account](https://cloud.google.com/iam/docs/create-short-lived-credentials-direct), [Service account credentials and self-impersonation](https://cloud.google.com/iam/docs/service-account-creds), [IAM roles and permissions](https://cloud.google.com/iam/docs/roles-permissions/iam), [`generateIdToken`](https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/generateIdToken), [Application Default Credentials](https://cloud.google.com/docs/authentication/application-default-credentials), [Cloud Run service identity](https://cloud.google.com/run/docs/configuring/services/service-identity), [Cloud Run environment variables](https://cloud.google.com/run/docs/configuring/services/environment-variables) -- Microsoft: [Workload identity federation](https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation), [Configure an app to trust an external identity provider](https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation-create-trust), [Microsoft Entra authentication for Azure SQL](https://learn.microsoft.com/en-us/azure/azure-sql/database/authentication-aad-overview), [Azure SQL with a service principal](https://learn.microsoft.com/en-us/azure/azure-sql/database/authentication-aad-service-principal), [Database-level roles](https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/database-level-roles), [`SqlConnection.AccessToken`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.data.sqlclient.sqlconnection.accesstoken) +- Microsoft Entra ID: [Managed identities for Azure resources](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview), [Manage user-assigned managed identities](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/how-manage-user-assigned-managed-identities), [Workload identity federation](https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation), [Configure an app to trust an external identity provider](https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation-create-trust), [Configure a user-assigned managed identity to trust an external identity provider](https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation-create-trust-user-assigned-managed-identity) +- Azure: [Managed identities in App Service](https://learn.microsoft.com/en-us/azure/app-service/overview-managed-identity), [Connect App Service to Azure SQL without secrets](https://learn.microsoft.com/en-us/azure/app-service/tutorial-connect-msi-sql-database), [App Service settings](https://learn.microsoft.com/en-us/azure/app-service/configure-common), [Microsoft Entra authentication for Azure SQL](https://learn.microsoft.com/en-us/azure/azure-sql/database/authentication-aad-overview), [Azure SQL with a service principal](https://learn.microsoft.com/en-us/azure/azure-sql/database/authentication-aad-service-principal), [Microsoft Entra-only authentication](https://learn.microsoft.com/en-us/azure/azure-sql/database/authentication-azure-ad-only-authentication), [Database-level roles](https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/database-level-roles), [Authorize Blob access with Microsoft Entra ID](https://learn.microsoft.com/en-us/azure/storage/blobs/authorize-access-azure-active-directory), [Assign an Azure role for blob data](https://learn.microsoft.com/en-us/azure/storage/blobs/assign-azure-role-data-access), [Built-in roles for Storage](https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/storage), [`SqlConnection.AccessToken`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.data.sqlclient.sqlconnection.accesstoken), [`BlobServiceClient`](https://learn.microsoft.com/en-us/dotnet/api/azure.storage.blobs.blobserviceclient) From e60e3066cb5e8fb86b646dc08d5c476625cd2f3e Mon Sep 17 00:00:00 2001 From: Timothy Trowbridge Date: Thu, 17 Sep 2026 23:44:21 -0300 Subject: [PATCH 2/6] Docs: state when an unrecognised Provider fails; drop the AKS claim An unrecognised Provider value is rejected by the configuration binder before options validation runs, and AKS pods do not use the resource Identity setting. Co-Authored-By: Claude Fable 5.1 --- Csag.WorkloadIdentity/README.md | 5 +++-- docs/cloud-identity-setup.md | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Csag.WorkloadIdentity/README.md b/Csag.WorkloadIdentity/README.md index 41aaec3..2a4db2c 100644 --- a/Csag.WorkloadIdentity/README.md +++ b/Csag.WorkloadIdentity/README.md @@ -294,7 +294,7 @@ services.AddWorkloadIdentity(options => `WorkloadIdentityOptions`, `WorkloadIdentityResourceOptions`, `WorkloadIdentityProvider`, `GoogleOptions` and `ManagedIdentityOptions` live in `Csag.WorkloadIdentity.Options`; the provider interfaces and `IAccessTokenProvider` in `Csag.WorkloadIdentity.Abstractions`; `AddWorkloadIdentity`, `WorkloadIdentityTokenCredential` and `TokenScope` in `Csag.WorkloadIdentity`. -The options are validated when the host starts: no resource section at all, a missing value in a configured resource's provider section, an unknown `Provider`, or a non-positive `RefreshAheadWindow` throws an `OptionsValidationException` that names every offending value by its path, for example `AzureSql:Google:ServiceAccountEmail must be provided.` Calling `AddWorkloadIdentity` more than once is harmless. Both token providers are always registered; resolving the provider of a resource whose section is absent throws an `InvalidOperationException` that names the missing section. +The options are validated when the host starts: no resource section at all, a missing value in a configured resource's provider section, or a non-positive `RefreshAheadWindow` throws an `OptionsValidationException` that names every offending value by its path, for example `AzureSql:Google:ServiceAccountEmail must be provided.` A `Provider` value other than `ManagedIdentity` or `Google` fails earlier, when the configuration is bound. Calling `AddWorkloadIdentity` more than once is harmless. Both token providers are always registered; resolving the provider of a resource whose section is absent throws an `InvalidOperationException` that names the missing section. ## Migrating from Csag.AzureSqlFederatedIdentity / Neolution.AzureSqlFederatedIdentity @@ -352,7 +352,8 @@ The options are validated when the host starts: no resource section at all, a mi | Symptom | Likely cause | |---|---| -| The host fails to start with `OptionsValidationException` | No resource section, a required key missing in a configured resource, an unknown `Provider`, or a non-positive `RefreshAheadWindow`; the message names the value. | +| The host fails to start with `OptionsValidationException` | No resource section, a required key missing in a configured resource, or a non-positive `RefreshAheadWindow`; the message names the value. | +| The host fails to start with `InvalidOperationException: Failed to convert configuration value … 'Provider'` | The `Provider` value is not `ManagedIdentity` or `Google`. | | `InvalidOperationException`: "The AzureSql resource is not configured" (or `BlobStorage`) | `IAzureSqlTokenProvider` or `IBlobStorageTokenProvider` was resolved, but the configuration has no section for that resource. | | `CredentialUnavailableException`: no managed identity endpoint found | `ManagedIdentity` is selected but the application is not running on an Azure resource with a managed identity, for example on a workstation. | | The managed identity endpoint reports that the identity was not found | The user-assigned identity is not attached to the hosting resource, or `ManagedIdentity:ClientId` is wrong. | diff --git a/docs/cloud-identity-setup.md b/docs/cloud-identity-setup.md index aa437a0..b6f5f14 100644 --- a/docs/cloud-identity-setup.md +++ b/docs/cloud-identity-setup.md @@ -8,7 +8,7 @@ Every access token the library obtains is issued by Microsoft Entra ID to one id | | A. Azure managed identity | B. Google Cloud to Azure federation | |---|---|---| -| The application runs on | Azure: App Service, Container Apps, Functions, a virtual machine, AKS | Google Cloud: Cloud Run, GKE, Compute Engine; or a workstation with Application Default Credentials | +| The application runs on | Azure: App Service, Container Apps, Functions, a virtual machine | Google Cloud: Cloud Run, GKE, Compute Engine; or a workstation with Application Default Credentials | | The identity | The system-assigned managed identity of the hosting resource, or a user-assigned managed identity attached to it | An app registration or a user-assigned managed identity that holds a federated credential trusting the application's Google service account | | `Provider` in the configuration | `ManagedIdentity` | `Google` | | Set up in | [Section A](#a-azure-managed-identity) | [Section B](#b-google-cloud-to-azure-federation) | @@ -27,7 +27,7 @@ Values you collect along the way: ## A. Azure managed identity -A [managed identity](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview) is an identity in Microsoft Entra ID whose credentials Azure creates and rotates; the application obtains tokens from the hosting resource's local identity endpoint and never sees a secret. Nothing is needed at runtime beyond the identity being enabled on, or attached to, the resource the application runs on. The steps below use App Service; Container Apps, Functions, virtual machines and AKS expose the same **Identity** setting. +A [managed identity](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview) is an identity in Microsoft Entra ID whose credentials Azure creates and rotates; the application obtains tokens from the hosting resource's local identity endpoint and never sees a secret. Nothing is needed at runtime beyond the identity being enabled on, or attached to, the resource the application runs on. The steps below use App Service; Container Apps, Functions and virtual machines expose the same **Identity** setting. ### A.1 System-assigned identity From 4046946549a91954f893059cf9b07a0194fb2ebc Mon Sep 17 00:00:00 2001 From: Timothy Trowbridge Date: Fri, 18 Sep 2026 12:42:59 -0300 Subject: [PATCH 3/6] Docs: adapter must wrap the matching provider; local-dev override still needs a resource section; runtime flow per provider Co-Authored-By: Claude Fable 5.1 --- Csag.WorkloadIdentity/README.md | 6 +++--- docs/cloud-identity-setup.md | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Csag.WorkloadIdentity/README.md b/Csag.WorkloadIdentity/README.md index 2a4db2c..65e48b2 100644 --- a/Csag.WorkloadIdentity/README.md +++ b/Csag.WorkloadIdentity/README.md @@ -23,7 +23,7 @@ Either way the result is a Microsoft Entra ID access token for the resource's sc - Holds the current access token of each resource in memory and hands it out until it enters the configured refresh-ahead window. Callers that find no usable token share a single token request instead of each running their own. - Background refresh (on by default): a hosted service refreshes each resource's token ahead of its expiry, so requests are served from a valid token without waiting for a token request. Failed requests are retried with exponential backoff. - Options are validated when the host starts, so a missing or invalid value fails fast with a message that names it. -- Every service is registered with `TryAdd`, so you can replace any part of the pipeline, including a token provider, by registering your own implementation first. +- The token pipeline (the resource token providers, the exchangers, the Google ID token provider and their factories) is registered with `TryAdd`, so you can replace any of them by registering your own implementation first. The background refresh hosted service is always added; turn it off with `EnableBackgroundRefresh` instead. - Targets `net8.0` and `net10.0`. ## Prerequisites @@ -88,7 +88,7 @@ The options reference below shows the other two overloads: binding a given `ICon ### 4. Use the token -Resolve `IAzureSqlTokenProvider` (namespace `Csag.WorkloadIdentity.Abstractions`), call `GetAzureSqlAccessTokenAsync` and assign the result to `SqlConnection.AccessToken` before opening the connection. Do this for every new connection: while the held token is valid the call returns it without any network round trip. +Resolve `IAzureSqlTokenProvider` (namespace `Csag.WorkloadIdentity.Abstractions`), call `GetAzureSqlAccessTokenAsync` and assign the result to `SqlConnection.AccessToken` before opening the connection. Do this for every new connection: until the held token enters the refresh-ahead window the call returns it without any network round trip; inside the window (reachable only when background refresh is off or has been failing) the first caller requests a new token and concurrent callers wait for that one request. ```csharp using Csag.WorkloadIdentity.Abstractions; @@ -237,7 +237,7 @@ public sealed class DocumentStore(BlobServiceClient blobServiceClient) } ``` -`IBlobStorageTokenProvider.GetBlobStorageAccessTokenAsync` returns the raw token string for code that calls the Blob REST API directly and sets the `Authorization: Bearer` header itself. `WorkloadIdentityTokenCredential` accepts any of the library's providers (`IAccessTokenProvider`), so the same adapter can present the Azure SQL provider to an SDK client that authenticates with a `TokenCredential`. +`IBlobStorageTokenProvider.GetBlobStorageAccessTokenAsync` returns the raw token string for code that calls the Blob REST API directly and sets the `Authorization: Bearer` header itself. `WorkloadIdentityTokenCredential` accepts any of the library's providers (`IAccessTokenProvider`), but each provider is bound to one resource and the adapter ignores the scopes an SDK client asks for: wrap the provider that matches the client, `IBlobStorageTokenProvider` for `BlobServiceClient`, never the Azure SQL provider, whose token carries the SQL audience. ## Options reference diff --git a/docs/cloud-identity-setup.md b/docs/cloud-identity-setup.md index b6f5f14..5b978d9 100644 --- a/docs/cloud-identity-setup.md +++ b/docs/cloud-identity-setup.md @@ -64,7 +64,7 @@ az webapp identity assign --resource-group --name -- ### A.3 Local development -A workstation has no managed identity endpoint, so `"Provider": "ManagedIdentity"` only works on Azure. To run the same code locally, either configure the resource with the `Google` provider and Application Default Credentials (section B), or register your own `IAzureSqlTokenProvider` or `IBlobStorageTokenProvider` before calling `AddWorkloadIdentity` (every registration is a `TryAdd`, so yours wins), for example one built on `Azure.Identity`'s `AzureCliCredential`. +A workstation has no managed identity endpoint, so `"Provider": "ManagedIdentity"` only works on Azure. To run the same code locally, either configure the resource with the `Google` provider and Application Default Credentials (section B), or register your own `IAzureSqlTokenProvider` or `IBlobStorageTokenProvider` before calling `AddWorkloadIdentity` (every registration is a `TryAdd`, so yours wins), for example one built on `Azure.Identity`'s `AzureCliCredential`. `AddWorkloadIdentity` still validates the options, so the resource section has to be present and complete even though your provider ignores it; to avoid configuring it, register your provider and do not call `AddWorkloadIdentity` at all. ## B. Google Cloud to Azure federation @@ -352,7 +352,7 @@ gcloud run deploy \ To change only the identity of an existing service, use `gcloud run services update --service-account @.iam.gserviceaccount.com`. In the console, the runtime service account is under the service's **Security** tab and the variables under **Variables & Secrets**. -At startup the application validates its configuration. On the first access to a resource (or right away, with the background refresh on) it requests an ID token for the configured service account through ADC (as the runtime service account), exchanges it at Microsoft Entra ID, and uses the resulting access token. If something fails, the application log names the failing step; the troubleshooting table in the [package README](../Csag.WorkloadIdentity/README.md) maps the usual messages to their cause. +At startup the application validates its configuration. The first token request for each resource happens right after startup with background refresh on (the default), otherwise on the resource's first use. What that request does depends on the resource's provider: with `Google`, the application requests an ID token for the configured service account through ADC (as the runtime service account) and exchanges it at Microsoft Entra ID; with `ManagedIdentity`, it obtains the access token from the host's managed identity endpoint and no Google configuration is involved. If something fails, the application log names the failing step; the troubleshooting table in the [package README](../Csag.WorkloadIdentity/README.md) maps the usual messages to their cause. ## Security best practice: disable SQL authentication From 601ccde190270beea4807c551b34c575d4e538c9 Mon Sep 17 00:00:00 2001 From: Timothy Trowbridge Date: Fri, 18 Sep 2026 13:30:48 -0300 Subject: [PATCH 4/6] Docs: server identity prerequisite for CREATE USER; audit only contained password users automatically Co-Authored-By: Claude Fable 5.1 --- docs/cloud-identity-setup.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/cloud-identity-setup.md b/docs/cloud-identity-setup.md index 5b978d9..e25593c 100644 --- a/docs/cloud-identity-setup.md +++ b/docs/cloud-identity-setup.md @@ -358,17 +358,17 @@ At startup the application validates its configuration. The first token request Once every application and every person reaches the database with a Microsoft Entra identity, password-based access is only an attack surface. Remove it in two steps. -1. **Audit and remove password-based users.** Connected as the Microsoft Entra admin, list the principals that authenticate with a password, that is, contained users with their own password (`DATABASE`) and users mapped to server logins (`INSTANCE`), and drop the ones no longer needed: +1. **Audit and remove password-based users.** Connected as the Microsoft Entra admin, list the contained users that carry their own password (`authentication_type_desc = 'DATABASE'`) and drop the ones no longer needed: ```sql SELECT name, type_desc, authentication_type_desc FROM sys.database_principals - WHERE authentication_type_desc IN ('DATABASE', 'INSTANCE'); + WHERE authentication_type_desc = 'DATABASE'; DROP USER []; ``` - Do this only after confirming that everything that used those users has moved to the passwordless connection. + Users with `authentication_type_desc = 'INSTANCE'` are mapped to server logins, which may be SQL logins with a password or Microsoft Entra logins; check the login behind each one in `master` (`SELECT name, type_desc FROM sys.server_principals`, where `SQL_LOGIN` is password-based and `EXTERNAL_LOGIN` is Microsoft Entra) before dropping anything. Do this only after confirming that everything that used those users has moved to the passwordless connection. 2. **Enable Microsoft Entra-only authentication on the server.** This turns off SQL authentication for the whole logical server, including the server admin login; existing SQL logins are kept but can no longer connect. In the portal open the server's **Microsoft Entra ID** page under **Settings** and check **Support only Microsoft Entra authentication for this server**, or run: From 161cd099b598b9385865b596753683ed3bc4696e Mon Sep 17 00:00:00 2001 From: Timothy Trowbridge Date: Fri, 18 Sep 2026 14:02:34 -0300 Subject: [PATCH 5/6] Docs: limit the substitution claim to the public interfaces; point at AccessTokenCallback for a single pool Co-Authored-By: Claude Fable 5.1 --- Csag.WorkloadIdentity/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Csag.WorkloadIdentity/README.md b/Csag.WorkloadIdentity/README.md index 65e48b2..8137123 100644 --- a/Csag.WorkloadIdentity/README.md +++ b/Csag.WorkloadIdentity/README.md @@ -23,7 +23,7 @@ Either way the result is a Microsoft Entra ID access token for the resource's sc - Holds the current access token of each resource in memory and hands it out until it enters the configured refresh-ahead window. Callers that find no usable token share a single token request instead of each running their own. - Background refresh (on by default): a hosted service refreshes each resource's token ahead of its expiry, so requests are served from a valid token without waiting for a token request. Failed requests are retried with exponential backoff. - Options are validated when the host starts, so a missing or invalid value fails fast with a message that names it. -- The token pipeline (the resource token providers, the exchangers, the Google ID token provider and their factories) is registered with `TryAdd`, so you can replace any of them by registering your own implementation first. The background refresh hosted service is always added; turn it off with `EnableBackgroundRefresh` instead. +- The public services, `IAzureSqlTokenProvider`, `IBlobStorageTokenProvider` and `IGoogleIdTokenProvider`, are registered with `TryAdd`, so you can replace any of them by registering your own implementation first. The background refresh hosted service is always added; turn it off with `EnableBackgroundRefresh` instead. - Targets `net8.0` and `net10.0`. ## Prerequisites @@ -344,7 +344,7 @@ The options are validated when the host starts: no resource section at all, a mi - **Connection string.** The access token is the credential, so the connection string must not contain `User ID`/`Password`, `Integrated Security` or an `Authentication` keyword: `SqlClient` throws an `InvalidOperationException` when `AccessToken` is combined with conflicting authentication settings. Keep `Encrypt=True` so the token and your data travel over TLS. - **Google runtime identity.** The Google ID token is requested through Application Default Credentials (ADC). Whatever identity ADC resolves to (the service account attached to the Cloud Run, GKE or Compute Engine resource, or your own account after `gcloud auth application-default login`) must hold `roles/iam.serviceAccountOpenIdTokenCreator` on the configured `ServiceAccountEmail`. The intended deployment is the simplest one: the application runs *as* that service account, with the role granted to the account on itself. Two resources may name different service accounts; each is exchanged through its own credential. - **Managed identity at runtime.** The token comes from the identity endpoint of the Azure resource the application runs on, so a system-assigned identity must be enabled on that resource and a user-assigned one attached to it. A workstation has no such endpoint; `ManagedIdentity` only works on Azure. A `ClientId` next to `"UseSystemAssignedIdentity": true` is ignored. -- **Token handling.** The provider holds the token; do not cache or persist it yourself. `AccessToken` is part of the `SqlClient` connection pool key, so a refreshed token starts a new pool, which is expected. If your connection string sets `Min Pool Size` above zero, call `SqlConnection.ClearPool` with a connection that carries the old token once it has expired; otherwise the pool keeps that token's physical connections open indefinitely (see the [`AccessToken` remarks](https://learn.microsoft.com/en-us/dotnet/api/microsoft.data.sqlclient.sqlconnection.accesstoken)). +- **Token handling.** The provider holds the token; do not cache or persist it yourself. `AccessToken` is part of the `SqlClient` connection pool key, so a refreshed token starts a new pool, which is expected. If your connection string sets `Min Pool Size` above zero, call `SqlConnection.ClearPool` with a connection that carries the old token once it has expired; otherwise the pool keeps that token's physical connections open indefinitely (see the [`AccessToken` remarks](https://learn.microsoft.com/en-us/dotnet/api/microsoft.data.sqlclient.sqlconnection.accesstoken)). Alternatively, set `SqlConnection.AccessTokenCallback` (Microsoft.Data.SqlClient 5.2 or later) to a delegate that returns `new SqlAuthenticationToken(token.Token, token.ExpiresOn)` from `IAzureSqlTokenProvider.GetAccessTokenAsync`: the callback keeps a single pool across token refreshes. - **Background refresh.** When enabled, the hosted service runs one loop per configured resource, so a slow or failing resource does not delay the other. Each loop requests a token as soon as the host starts and again when the held token enters the refresh-ahead window, or at half the token's remaining lifetime when that is shorter than the window (the wait is kept between 10 seconds and 1 day). A failed request is logged at `Error` and retried, waiting 5 seconds and doubling up to 5 minutes between attempts. When disabled, the first caller to find the token due for refresh performs the request while concurrent callers wait for its result. If you register your own `IAzureSqlTokenProvider` or `IBlobStorageTokenProvider`, the hosted service leaves it alone. - **Logging.** All categories start with `Csag.WorkloadIdentity`. Token requests and refreshes log at `Debug`; per-call reuse of the held token logs at `Trace`. From eddfae112375c59ec626e11d7637b3e363ed8619 Mon Sep 17 00:00:00 2001 From: Timothy Trowbridge Date: Fri, 18 Sep 2026 14:27:07 -0300 Subject: [PATCH 6/6] Docs: describe how the Azure SDK obtains tokens through the credential Co-Authored-By: Claude Fable 5.1 --- Csag.WorkloadIdentity/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Csag.WorkloadIdentity/README.md b/Csag.WorkloadIdentity/README.md index 8137123..191617d 100644 --- a/Csag.WorkloadIdentity/README.md +++ b/Csag.WorkloadIdentity/README.md @@ -221,7 +221,7 @@ Azure SDK clients are thread-safe and meant to be shared, so a singleton is the ### 4. Use the tokens -Azure SQL works exactly as in quickstart A: `IAzureSqlTokenProvider` and `SqlConnection.AccessToken`, with plain ADO.NET or the EF Core factory. For Blob Storage inject the `BlobServiceClient`; the SDK asks the credential for a token on every request and the adapter answers from the held token: +Azure SQL works exactly as in quickstart A: `IAzureSqlTokenProvider` and `SqlConnection.AccessToken`, with plain ADO.NET or the EF Core factory. For Blob Storage inject the `BlobServiceClient`; the SDK obtains a token through the credential as needed (its bearer-token policy caches the token and asks again only when it is missing or near expiry), and the adapter serves each of those requests from the provider's held token: ```csharp using Azure.Storage.Blobs;