diff --git a/.changeset/docs-usage-and-guides.md b/.changeset/docs-usage-and-guides.md new file mode 100644 index 0000000..aeba23b --- /dev/null +++ b/.changeset/docs-usage-and-guides.md @@ -0,0 +1,5 @@ +--- +"@neolution-ch/csag-azure-sql-federated-identity": patch +--- + +Rewrite the package README as a self-contained quickstart: every configuration key including `RefreshAheadWindow` and `EnableBackgroundRefresh`, all three `AddAzureSqlFederatedIdentity` overloads, obtaining the token from `IAzureSqlTokenProvider` and assigning it to `SqlConnection.AccessToken` with plain ADO.NET and with EF Core, the prerequisites on the Google and Microsoft side, and a troubleshooting table. Links are absolute so they work on nuget.org. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..10ef1bb --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,11 @@ +## Summary + + + +## Checklist + +- [ ] Changeset added (`npx changeset`, or `npx changeset --empty` if the package is unaffected) +- [ ] `dotnet build -c Release` is clean (no warnings) +- [ ] `dotnet test -c Release` passes on `net8.0` and `net10.0` +- [ ] Documentation updated where behaviour or configuration changed (package README, `docs/cloud-identity-setup.md`, Demo README) +- [ ] `packages.lock.json` files updated and committed if package references changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..78bcb54 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,69 @@ +# Contributing + +Thank you for helping improve Csag.AzureSqlFederatedIdentity. Bug reports, questions and pull requests are welcome on [GitHub](https://github.com/neolution-ch/Neolution.AzureSqlFederatedIdentity). For vulnerabilities, please follow [SECURITY.md](./SECURITY.md) instead of opening an issue. + +## Prerequisites + +- The .NET SDK pinned in [global.json](./global.json) (10.0.4xx; a newer 10.0 minor rolls forward), plus the **.NET 8 runtime**, because the tests also run on `net8.0`. `dotnet --list-runtimes` should list `Microsoft.NETCore.App 8.0.x` alongside 10.0.x. +- [Node.js](https://nodejs.org/) 24 for the changesets tooling: run `npm ci` once in the repository root. +- Docker, only if you want to build the Demo container (see the [Demo README](./Csag.AzureSqlFederatedIdentity.Demo/README.md)). + +## Build and test + +```shell +dotnet restore --locked-mode # what CI runs; fails if a packages.lock.json is out of date +dotnet build -c Release # warnings are errors in Release, so this is the gate to pass +dotnet test -c Release # runs the suite on net8.0 and net10.0 +dotnet test -c Release -f net10.0 # a single target framework, for a quicker loop +dotnet pack Csag.AzureSqlFederatedIdentity -c Release -o ./nupkgs +``` + +The library targets `net8.0` and `net10.0`, and CI runs the tests on both; a change is not done until both are green. Build in `Release` before you push: `TreatWarningsAsErrors` is on and the StyleCop rules from `Neolution.CodeAnalysis` are enforced there, so a build that is clean in `Debug` can still fail. Fix every warning rather than suppressing it. + +## Code conventions + +The analyzers enforce most of these; the rest come from the existing code. + +- XML documentation on every member, public or private. +- `this.` prefix for instance members. +- `using` directives inside the namespace, sorted alphabetically with `System` namespaces first. +- Nullable reference types are enabled; `ConfigureAwait(false)` on every `await` in the library. +- Comments explain the non-obvious *why* of the code as it stands; they do not narrate edits or previous states. +- Tests use xunit, Shouldly and NSubstitute, follow the `Given__When__Then_` naming with Arrange/Act/Assert sections, and live in `Csag.AzureSqlFederatedIdentity.UnitTests`. Read an existing test class before adding one. + +## Dependencies + +Package versions are managed centrally with transitive pinning: + +1. Add or change the version in [Directory.Packages.props](./Directory.Packages.props). +2. Reference the package in the project file without a version: ``. +3. Run `dotnet restore` (without `--locked-mode`) so that the `packages.lock.json` files update, and commit them together with the change. + +Dependabot proposes routine updates and generates their changesets automatically. + +## Changesets + +Releases follow the [neolution-ch release playbook](https://github.com/neolution-ch/release-playbook) with [Changesets](https://github.com/changesets/changesets); the [root README](./README.md#release-process) describes the pipeline. What it means for a pull request: + +- Every PR that changes the library needs a changeset file in `.changeset/`. Run `npx changeset`, pick the bump type and describe the change **for consumers of the package**; the text becomes the CHANGELOG entry. +- The package is on a `0.x` version, so the bump convention is: **minor** for a breaking or behaviour-changing change, **patch** for a fix or for documentation that ships inside the package (the package README does). +- A change that does not touch the package, such as CI, repository documentation or the Demo, still needs a changeset so that the check passes: run `npx changeset --empty`, which creates a file with an empty front matter. +- CI's **Changeset Check** fails a PR without a changeset. Do not edit `CHANGELOG.md` or the version in the `.csproj` by hand; the "chore: version packages" PR does that. + +A changeset file looks like this: + +```markdown +--- +"@neolution-ch/csag-azure-sql-federated-identity": patch +--- + +Describe the change from the consumer's point of view. +``` + +## Pull requests + +- Branch from `main` and keep the PR focused on one topic; small, reviewable diffs are merged faster. +- Complete the checklist in the PR template: a changeset, `dotnet build -c Release` clean, `dotnet test -c Release` green on both target frameworks, and documentation updated where behaviour or configuration changed (the package README, `docs/cloud-identity-setup.md` and the Demo README). +- Never commit secrets or real identifiers. The Demo's `appsettings.json` ships with empty values on purpose; use user secrets or environment variables locally. +- Explain *why* in the PR description; the diff shows *what*. +- A maintainer reviews every PR, and CI must be green before it is merged. diff --git a/Csag.AzureSqlFederatedIdentity.Demo/README.md b/Csag.AzureSqlFederatedIdentity.Demo/README.md index ec79246..03ce0a9 100644 --- a/Csag.AzureSqlFederatedIdentity.Demo/README.md +++ b/Csag.AzureSqlFederatedIdentity.Demo/README.md @@ -94,4 +94,4 @@ On Windows the credential file is `%APPDATA%\gcloud\application_default_credenti ## Deploy to Cloud Run -Push the image to Artifact Registry and deploy it with the runtime service account set to the configured Google service account and the four settings supplied as environment variables. Cloud Run sends traffic to port 8080, which is the port the image listens on. Section 4 of the [setup guide](../docs/cloud-identity-setup.md) has the details. +Push the image to Artifact Registry and deploy it with the runtime service account set to the configured Google service account and the four settings supplied as environment variables. Cloud Run sends traffic to port 8080, which is the port the image listens on. The [setup guide](../docs/cloud-identity-setup.md) covers the Cloud Run configuration in detail. diff --git a/Csag.AzureSqlFederatedIdentity/README.md b/Csag.AzureSqlFederatedIdentity/README.md index 564eb80..74bf87c 100644 --- a/Csag.AzureSqlFederatedIdentity/README.md +++ b/Csag.AzureSqlFederatedIdentity/README.md @@ -1,48 +1,202 @@ # Csag.AzureSqlFederatedIdentity [![NuGet](https://img.shields.io/nuget/v/Csag.AzureSqlFederatedIdentity.svg)](https://www.nuget.org/packages/Csag.AzureSqlFederatedIdentity) -[![License: MIT](https://img.shields.io/badge/License-MIT-lightgray.svg)](../LICENSE) +[![License: MIT](https://img.shields.io/badge/License-MIT-lightgray.svg)](https://github.com/neolution-ch/Neolution.AzureSqlFederatedIdentity/blob/main/LICENSE) -Federated identity integration for Azure SQL using Google Cloud IAM Credentials and Azure AD. +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. + +How it works: + +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. + +- Repository: +- Cloud setup guide (Google Cloud, Microsoft Entra ID, Azure SQL, Cloud Run): ## Features -- Obtain Azure SQL access tokens using Google service accounts -- Automatic token refresh and caching -- Easy integration with ASP.NET Core and .NET worker services +- Exchanges a Google-signed ID token for a Microsoft Entra ID access token for Azure SQL; there are no secrets to store or rotate. +- 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. +- 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. +- 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`. + +The setup guide linked above walks through each of these step by step. + +## Quickstart + +### 1. Install + +```shell +dotnet add package Csag.AzureSqlFederatedIdentity +``` + +The library returns a token string and does not depend on `Microsoft.Data.SqlClient`. Add that package, or an EF Core provider such as `Microsoft.EntityFrameworkCore.SqlServer`, to the project that opens the connections. + +### 2. Configure + +The options bind from the `Csag.AzureSqlFederatedIdentity` section of the configuration: + +```json +{ + "Csag.AzureSqlFederatedIdentity": { + "TenantId": "", + "ClientId": "", + "Google": { + "ServiceAccountEmail": "@.iam.gserviceaccount.com" + }, + "RefreshAheadWindow": "00:05:00", + "EnableBackgroundRefresh": true + }, + "ConnectionStrings": { + "AzureSql": "Server=tcp:.database.windows.net,1433;Initial Catalog=;Encrypt=True" + } +} +``` + +| Key | Required | Default | Description | +|---|---|---|---| +| `TenantId` | yes | – | Directory (tenant) ID of the Microsoft Entra tenant. | +| `ClientId` | yes | – | Application (client) ID of the app registration that holds the federated credential. | +| `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 the access token expires it is treated as due for refresh. Must be positive. | +| `EnableBackgroundRefresh` | no | `true` | Whether a hosted service keeps the token refreshed ahead of its expiry. | + +Environment variables follow the usual .NET mapping, with `__` as the section separator: `Csag.AzureSqlFederatedIdentity__TenantId`, `Csag.AzureSqlFederatedIdentity__ClientId`, `Csag.AzureSqlFederatedIdentity__Google__ServiceAccountEmail`, and so on. + +The connection string deliberately carries no credentials; see the notes below. + +### 3. Register + +```csharp +using Csag.AzureSqlFederatedIdentity; + +var builder = WebApplication.CreateBuilder(args); + +// Binds the "Csag.AzureSqlFederatedIdentity" section of the host configuration. +builder.Services.AddAzureSqlFederatedIdentity(); + +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.AzureSqlFederatedIdentity; +using Csag.AzureSqlFederatedIdentity.Options; + +// Binds the "Csag.AzureSqlFederatedIdentity" section of the given configuration. +services.AddAzureSqlFederatedIdentity(configuration); + +// Sets the options in code; the section name is available as AzureSqlFederatedIdentityOptions.ConfigurationSectionName. +services.AddAzureSqlFederatedIdentity(options => +{ + options.TenantId = ""; + options.ClientId = ""; + options.Google = new GoogleOptions { ServiceAccountEmail = "@.iam.gserviceaccount.com" }; + options.RefreshAheadWindow = TimeSpan.FromMinutes(10); + options.EnableBackgroundRefresh = true; +}); +``` + +`GoogleOptions` and `AzureSqlFederatedIdentityOptions` live in `Csag.AzureSqlFederatedIdentity.Options`. The options are validated when the host starts: a missing `TenantId`, `ClientId` or `Google:ServiceAccountEmail`, or a non-positive `RefreshAheadWindow`, throws an `OptionsValidationException` that names the offending value. Calling `AddAzureSqlFederatedIdentity` more than once is harmless. + +### 4. Use the token + +Resolve `IAzureSqlTokenProvider` (namespace `Csag.AzureSqlFederatedIdentity.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. + +```csharp +using Csag.AzureSqlFederatedIdentity.Abstractions; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Configuration; + +public sealed class OrderRepository(IConfiguration configuration, IAzureSqlTokenProvider tokenProvider) +{ + private readonly string connectionString = configuration.GetConnectionString("AzureSql") + ?? throw new InvalidOperationException("Connection string 'AzureSql' is not configured."); + + public async Task CountOrdersAsync(CancellationToken cancellationToken) + { + await using var connection = new SqlConnection(this.connectionString); + connection.AccessToken = await tokenProvider.GetAzureSqlAccessTokenAsync(cancellationToken); + await connection.OpenAsync(cancellationToken); + + await using var command = new SqlCommand("SELECT COUNT(*) FROM dbo.Orders", connection); + return (int)(await command.ExecuteScalarAsync(cancellationToken) ?? 0); + } +} +``` + +Register the class as usual, for example `builder.Services.AddScoped();`. + +#### With Entity Framework Core + +EF Core has no option for the access token, so set it on the underlying `SqlConnection` when you create the context. This factory follows the pattern the repository's Demo project uses (`AppDbContext` is your `DbContext`): + +```csharp +using Csag.AzureSqlFederatedIdentity.Abstractions; +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; + +public sealed class AppDbContextFactory(IConfiguration configuration, IAzureSqlTokenProvider tokenProvider) +{ + private readonly DbContextOptions options = new DbContextOptionsBuilder() + .UseSqlServer(configuration.GetConnectionString("AzureSql") + ?? throw new InvalidOperationException("Connection string 'AzureSql' is not configured.")) + .Options; + + public async Task CreateDbContextAsync(CancellationToken cancellationToken = default) + { + // The connection string carries no credentials; the federated access token authenticates the connection. + var accessToken = await tokenProvider.GetAzureSqlAccessTokenAsync(cancellationToken); -## Getting Started + var context = new AppDbContext(this.options); + if (context.Database.GetDbConnection() is SqlConnection sqlConnection) + { + sqlConnection.AccessToken = accessToken; + } -1. Install the NuGet package: + return context; + } +} +``` - ```shell - dotnet add package Csag.AzureSqlFederatedIdentity - ``` +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`. -2. Configure your `appsettings.json`: +## Notes - ```json - { - "Csag.AzureSqlFederatedIdentity": { - "TenantId": "", - "ClientId": "", - "Google": { - "ServiceAccountEmail": "" - } - } - } - ``` +- **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. +- **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.AzureSqlFederatedIdentity`. Exchanges and refreshes log at `Debug`; per-call reuse of the held token logs at `Trace`. -3. Register the services in your `Program.cs`: +## Troubleshooting - ```csharp - builder.Services.AddAzureSqlFederatedIdentity(builder.Configuration); - ``` +| 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. | +| `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. | ## License -MIT +MIT. See . -## Contributing +## Contributing and security -Contributions are welcome! Please open issues or pull requests. +Issues and pull requests are welcome at ; the repository's `CONTRIBUTING.md` explains the build, the tests and the release process. Please report vulnerabilities privately through GitHub's private vulnerability reporting: . diff --git a/README.md b/README.md index 5ae3c6c..3936cd2 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,29 @@ -# Csag.AzureSqlFederatedIdentity Solution +# Csag.AzureSqlFederatedIdentity [![Build Status](https://github.com/neolution-ch/Neolution.AzureSqlFederatedIdentity/actions/workflows/ci.yml/badge.svg)](https://github.com/neolution-ch/Neolution.AzureSqlFederatedIdentity/actions) [![NuGet](https://img.shields.io/nuget/v/Csag.AzureSqlFederatedIdentity.svg)](https://www.nuget.org/packages/Csag.AzureSqlFederatedIdentity) -[![License: MIT](https://img.shields.io/badge/License-MIT-lightgray.svg)](LICENSE) +[![License: MIT](https://img.shields.io/badge/License-MIT-lightgray.svg)](./LICENSE) -This repository provides federated identity integration for Azure SQL using Google Cloud IAM Credentials and Microsoft Entra ID (Azure AD). +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`. ## Projects -- **Csag.AzureSqlFederatedIdentity**: The main library, distributed as a [NuGet package](https://www.nuget.org/packages/Csag.AzureSqlFederatedIdentity). -- **Csag.AzureSqlFederatedIdentity.Demo**: Example ASP.NET Core application demonstrating usage. -- **Csag.AzureSqlFederatedIdentity.UnitTests**: Unit tests for the library. +| Project | Description | +|---|---| +| [`Csag.AzureSqlFederatedIdentity`](./Csag.AzureSqlFederatedIdentity) | The library, published as the [Csag.AzureSqlFederatedIdentity](https://www.nuget.org/packages/Csag.AzureSqlFederatedIdentity) NuGet package for `net8.0` and `net10.0`. Its [README](./Csag.AzureSqlFederatedIdentity/README.md) is the package documentation; the [CHANGELOG](./Csag.AzureSqlFederatedIdentity/CHANGELOG.md) is generated from changesets. | +| [`Csag.AzureSqlFederatedIdentity.Demo`](./Csag.AzureSqlFederatedIdentity.Demo) | An ASP.NET Core application for Cloud Run that reads from Azure SQL through the library. Its [README](./Csag.AzureSqlFederatedIdentity.Demo/README.md) explains how to run it from source, in Docker and on Cloud Run. | +| [`Csag.AzureSqlFederatedIdentity.UnitTests`](./Csag.AzureSqlFederatedIdentity.UnitTests) | xunit tests for the library, run on both target frameworks. | -## Quick Start +## Quick start -1. Install the NuGet package in your project: +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.AzureSqlFederatedIdentity/README.md) has the complete quickstart: configuration keys, service registration and attaching the token to `SqlConnection`, with plain ADO.NET and with EF Core. ```shell dotnet add package Csag.AzureSqlFederatedIdentity ``` -2. For detailed cloud and identity setup instructions (Azure AD, Azure SQL, GCP, Cloud Run), see [docs/cloud-identity-setup.md](./docs/cloud-identity-setup.md). +3. To see it end to end, run the [Demo](./Csag.AzureSqlFederatedIdentity.Demo/README.md) against your own database. ## Release process @@ -32,10 +35,10 @@ This repository follows the [neolution-ch release playbook](https://github.com/n The package is on a `0.x` version: breaking changes are declared as **minor** changesets, fixes as **patch**. Dependabot PRs get their changesets generated automatically. -## License +## Contributing -This project is licensed under the MIT License. See the [LICENSE](./LICENSE) file for details. +Issues and pull requests are welcome. [CONTRIBUTING.md](./CONTRIBUTING.md) describes the prerequisites, how to build and test on both target frameworks, the code conventions and what a pull request needs. Please report vulnerabilities privately as described in [SECURITY.md](./SECURITY.md). -## Contributing +## License -Contributions are welcome! Please open issues or pull requests. +This project is licensed under the MIT License. See the [LICENSE](./LICENSE) file for details. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..36bd393 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,32 @@ +# Security policy + +Csag.AzureSqlFederatedIdentity 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. + +## Supported versions + +| Version | Supported | +|---|---| +| Latest `0.x` release on nuget.org | Yes | +| Earlier `0.x` releases | No, please upgrade to the latest release | + +While the package is on a `0.x` version, security fixes are released as a new version rather than backported. + +## Reporting a vulnerability + +Please **do not** open a public issue or pull request for a vulnerability. Report it privately through GitHub's private vulnerability reporting for this repository: + + + +GitHub's [documentation](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability) explains how the form works. Please include: + +- the package version and target framework you tested; +- a description of the issue and its impact; +- steps or code to reproduce it, if you have them. + +We acknowledge every report, keep you informed while we investigate, and coordinate the disclosure and the release of a fix with you. Credit is given in the advisory unless you prefer to stay anonymous. + +## Scope + +- In scope: the `Csag.AzureSqlFederatedIdentity` library. +- The Demo project is a sample and is not meant for production use; reports about it are still welcome if they point to a problem in the library or its documentation. +- Vulnerabilities in dependencies such as `Azure.Identity` or the Google Cloud client libraries should be reported to their maintainers; let us know as well if the library needs to update in response. diff --git a/docs/cloud-identity-setup.md b/docs/cloud-identity-setup.md index b9079d5..ab9cf81 100644 --- a/docs/cloud-identity-setup.md +++ b/docs/cloud-identity-setup.md @@ -1,69 +1,206 @@ -# Cloud and Identity Setup Guide - -This guide explains how to register your application in Microsoft Entra (Azure AD), configure Azure SQL, set up Google Cloud Platform (GCP) service accounts, and configure Cloud Run for federated identity scenarios. - -## 1. Setting Up Google Cloud Platform (GCP) - -1. Go to the [Google Cloud Console](https://console.cloud.google.com/). -2. Navigate to **IAM & Admin** > **Service Accounts**. -3. Create a new Service Account or select an existing one. -4. Note the **Email** of the service account; this will be used in your application's configuration (e.g., `ServiceAccountEmail` setting for the `Csag.AzureSqlFederatedIdentity` library). -5. Find the **Unique ID** of the service account. You can find this by: - * Clicking on the service account in the list. - * It's often displayed on the details page, or you can get it using the gcloud CLI: `gcloud iam service-accounts describe --format='value(uniqueId)'`. - This **Unique ID** is what you will need for the "Subject identifier" when setting up the federated credential in Azure AD (described in Section 2). -6. Grant this service account the **Service Account Token Creator** role (`roles/iam.serviceAccountTokenCreator`). This allows it to generate ID tokens for itself. -7. (Optional) Restrict the service account's other permissions as needed for security following the principle of least privilege. -8. When running your application on Google Cloud (e.g., Cloud Run, GKE, Compute Engine), ensure Application Default Credentials (ADC) are configured. Typically, this means the environment is set up to allow the application to acquire credentials automatically without needing a service account JSON key file. For local development, you might need to configure ADC using `gcloud auth application-default login`. - -## 2. Registering an Application in Microsoft Entra (Azure AD) - -1. Go to the [Azure Portal](https://portal.azure.com/). -2. Navigate to **Microsoft Entra ID** > **Manage** > **App registrations** > **New registration**. -3. Enter a name, select supported account types, and register. -4. Copy the **Application (client) ID** and **Directory (tenant) ID** for configuration. -5. Under the registered application, navigate to **Manage** > **Certificates & secrets** > **Federated credentials**. -6. Add a new federated credential. For Google Cloud: - * Select **Other issuer** for the federated credential scenario. - * **Issuer**: `https://accounts.google.com` - * **Subject identifier**: Enter the **Unique ID** of your Google Cloud Service Account (obtained in Section 1, Step 5). - * **Audience**: `api://AzureADTokenExchange` (Should be the default). - * Provide a name and description for the credential. - -## 3. Configuring Azure SQL for Federated Identity - -1. In the Azure Portal, go to your Azure SQL server. -2. Under **Settings** > **Microsoft Entra ID** (previously Active Directory admin), set an Entra ID admin for the server (if not already set). -3. In your database, create an external user corresponding to the **Azure AD App Registration** (not the GCP service account directly): - - ```sql - CREATE USER [] FROM EXTERNAL PROVIDER; - -- Or using Client ID: - -- CREATE USER [] FROM EXTERNAL PROVIDER; - ALTER ROLE db_datareader ADD MEMBER []; - ALTER ROLE db_datawriter ADD MEMBER []; - ``` - - Replace `` with the display name of your Azure AD App Registration, or `` with its Application (client) ID. - -## 4. Configuring Cloud Run - -1. Deploy your application to Cloud Run. -2. In the Cloud Run service settings (under the "Security" tab or similar when revising a deployment), ensure the **Service account** is set to the Google Cloud Service Account you configured in Section 1. - * The application code (like the `Csag.AzureSqlFederatedIdentity` library) will use this runtime service account identity via Application Default Credentials to request a Google ID token. - -3. Configure your application's settings (e.g., via environment variables in Cloud Run) with: - * Azure AD **Tenant ID**. - * Azure AD **Application (client) ID** of the App Registration you set up in Section 2. - * The Google Cloud **Service Account Email** (from Section 1, Step 4). - * The connection string for your Azure SQL database. - -The application will then: - -* Request an ID token from Google for the configured service account, specifying the Azure AD application as the audience (e.g., `api://AzureADTokenExchange`). -* Use this Google ID token to request an Azure AD access token for Azure SQL, presenting the Google ID token as a federated credential. -* Use the Azure AD access token to connect to Azure SQL. - ---- - -For more details, see the official documentation for [Microsoft Entra ID](https://learn.microsoft.com/en-us/azure/active-directory/), [Azure SQL](https://learn.microsoft.com/en-us/azure/azure-sql/), [Google Cloud IAM](https://cloud.google.com/iam/docs/), and [Cloud Run](https://cloud.google.com/run/docs/). +# 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.AzureSqlFederatedIdentity`: 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.AzureSqlFederatedIdentity/README.md). + +## How the pieces fit + +```text +Your application, running as a Google service account (Application Default Credentials) + │ + │ 1. generateIdToken(audience = "api://AzureADTokenExchange") IAM Service Account Credentials API + ▼ +Google-signed ID token iss = https://accounts.google.com, sub = + │ + │ 2. client assertion for app registration in tenant Microsoft Entra ID + ▼ +Access token for https://database.windows.net/.default + │ + │ 3. SqlConnection.AccessToken + ▼ +Azure SQL, as the database user created for the app registration +``` + +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 + +### 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`: + +```shell +gcloud services enable iamcredentials.googleapis.com --project +``` + +### 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: + +```shell +gcloud iam service-accounts describe @.iam.gserviceaccount.com --format 'value(uniqueId)' +``` + +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 + +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**: + +```shell +gcloud iam service-accounts add-iam-policy-binding @.iam.gserviceaccount.com \ + --member "serviceAccount:@.iam.gserviceaccount.com" \ + --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: + +```shell +gcloud iam service-accounts add-iam-policy-binding @.iam.gserviceaccount.com \ + --member "user:@" \ + --role roles/iam.serviceAccountOpenIdTokenCreator +``` + +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 + +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. +- Do not create a service account key; nothing in this setup requires one. + +## 2. Microsoft Entra ID + +### 2.1 Register an application + +1. In the [Azure portal](https://portal.azure.com/) 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. + +### 2.2 Add a federated credential + +Under the registration go to **Certificates & secrets** > **Federated credentials** > **Add credential** and choose the **Other issuer** scenario: + +| 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) | +| 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. + +## 3. Azure SQL + +### 3.1 Set a Microsoft Entra admin for the server + +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. + +### 3.2 Create a database user for the app registration + +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: + +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; +``` + +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 []; +``` + +`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 []; +``` + +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 + +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. + +## 4. Configure the application + +The library binds its options from the `Csag.AzureSqlFederatedIdentity` 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): + +| Configuration key | Environment variable | Value | +|---|---|---| +| `Csag.AzureSqlFederatedIdentity:TenantId` | `Csag.AzureSqlFederatedIdentity__TenantId` | Directory (tenant) ID (step 2.1) | +| `Csag.AzureSqlFederatedIdentity:ClientId` | `Csag.AzureSqlFederatedIdentity__ClientId` | Application (client) ID (step 2.1) | +| `Csag.AzureSqlFederatedIdentity:Google:ServiceAccountEmail` | `Csag.AzureSqlFederatedIdentity__Google__ServiceAccountEmail` | Service account email (step 1.2) | +| `Csag.AzureSqlFederatedIdentity:RefreshAheadWindow` | `Csag.AzureSqlFederatedIdentity__RefreshAheadWindow` | Optional; how long before expiry the token is refreshed. Default `00:05:00`, must be positive | +| `Csag.AzureSqlFederatedIdentity:EnableBackgroundRefresh` | `Csag.AzureSqlFederatedIdentity__EnableBackgroundRefresh` | Optional; default `true` | + +The first three are required; the application refuses to start if any of them is missing. In `appsettings.json`: + +```json +{ + "Csag.AzureSqlFederatedIdentity": { + "TenantId": "", + "ClientId": "", + "Google": { + "ServiceAccountEmail": "@.iam.gserviceaccount.com" + } + }, + "ConnectionStrings": { + "DefaultConnection": "Server=tcp:.database.windows.net,1433;Initial Catalog=;Encrypt=True" + } +} +``` + +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.AzureSqlFederatedIdentity/README.md) shows how to register the library and attach the token to a `SqlConnection`. + +## 5. 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: + +```yaml +# env.yaml +Csag.AzureSqlFederatedIdentity__TenantId: "" +Csag.AzureSqlFederatedIdentity__ClientId: "" +Csag.AzureSqlFederatedIdentity__Google__ServiceAccountEmail: "@.iam.gserviceaccount.com" +ConnectionStrings__DefaultConnection: "Server=tcp:.database.windows.net,1433;Initial Catalog=;Encrypt=True" +``` + +```shell +gcloud run deploy \ + --project \ + --region \ + --image -docker.pkg.dev///: \ + --service-account @.iam.gserviceaccount.com \ + --env-vars-file env.yaml +``` + +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.AzureSqlFederatedIdentity/README.md) maps the usual messages to their cause. + +## 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)