Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .changeset/demo-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
---

Fix the Demo's Dockerfile and container, stop its `/test` endpoint from leaking exception details, and document how to run it.
20 changes: 20 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Context for Csag.AzureSqlFederatedIdentity.Demo/Dockerfile, which builds from the repository root.
**/bin/
**/obj/
**/TestResults/
.git
.github/
.vs/
.idea/
node_modules/
nupkgs/
artifacts/
.changeset/
docs/
scripts/
**/package.json
package-lock.json
**/*.user
**/*.md
# The library's csproj packs its README into the NuGet package.
!Csag.AzureSqlFederatedIdentity/README.md
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,29 @@ jobs:
name: nupkgs
path: ./nupkgs
retention-days: 7

docker:
name: Docker (Demo image)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7.0.1

- name: Build Demo image
run: docker build -f Csag.AzureSqlFederatedIdentity.Demo/Dockerfile -t csag-demo .

# The library validates its settings at startup, so the container only reaches "/" with placeholder values.
- name: Smoke test Demo image
run: |
docker run -d -p 8080:8080 --name csag-demo \
-e Csag.AzureSqlFederatedIdentity__TenantId=placeholder \
-e Csag.AzureSqlFederatedIdentity__ClientId=placeholder \
-e Csag.AzureSqlFederatedIdentity__Google__ServiceAccountEmail=placeholder@example.invalid \
csag-demo
curl --fail --silent --show-error --retry 10 --retry-connrefused --retry-all-errors --retry-delay 1 http://localhost:8080/
test "$(docker exec csag-demo id -u)" != "0"
docker stop csag-demo

- name: Demo container logs
if: failure()
run: docker logs csag-demo || true
29 changes: 16 additions & 13 deletions Csag.AzureSqlFederatedIdentity.Demo/Database/AppDbContextFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,31 +5,34 @@
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;

public class AppDbContextFactory : IAppDbContextFactory, IDbContextFactory<AppDbContext>
public class AppDbContextFactory : IAppDbContextFactory
{
private readonly DbContextOptionsBuilder<AppDbContext> optionsBuilder;
private readonly string? connectionString;
private readonly IAzureSqlTokenProvider tokenProvider;

public AppDbContextFactory(IConfiguration configuration, IAzureSqlTokenProvider tokenProvider)
{
this.connectionString = configuration.GetConnectionString("DefaultConnection");
this.tokenProvider = tokenProvider;
this.optionsBuilder = new DbContextOptionsBuilder<AppDbContext>();

var connectionString = configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not set.");
this.optionsBuilder.UseSqlServer(connectionString);
}

public AppDbContext CreateDbContext()
{
return this.CreateDbContextAsync(CancellationToken.None).GetAwaiter().GetResult();
}

public async Task<AppDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default)
{
var context = new AppDbContext(this.optionsBuilder.Options);
// Checked on use: the factory itself is constructed while the endpoint's parameters are bound, which is
// outside the handler's error handling.
if (string.IsNullOrWhiteSpace(this.connectionString))
{
throw new InvalidOperationException("Connection string 'DefaultConnection' not set.");
}

// The connection string carries no credentials; the federated access token authenticates the connection.
var accessToken = await this.tokenProvider.GetAzureSqlAccessTokenAsync(cancellationToken);

var options = new DbContextOptionsBuilder<AppDbContext>().UseSqlServer(this.connectionString).Options;
var context = new AppDbContext(options);
if (context.Database.GetDbConnection() is SqlConnection sqlConnection)
{
sqlConnection.AccessToken = await this.tokenProvider.GetAzureSqlAccessTokenAsync(cancellationToken);
sqlConnection.AccessToken = accessToken;
}

return context;
Expand Down
32 changes: 17 additions & 15 deletions Csag.AzureSqlFederatedIdentity.Demo/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,24 +1,26 @@
# Use official .NET SDK image for build
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
# Build context is the repository root:
# docker build -f Csag.AzureSqlFederatedIdentity.Demo/Dockerfile -t csag-demo .
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src

# Copy csproj and restore as distinct layers
COPY *.csproj ./
# If you have subfolders, adjust copy commands accordingly:
# e.g., COPY Models/*.csproj or simply copy entire project
RUN dotnet restore
# Project and lock files first, so the restore layer is reused until a dependency changes.
COPY global.json nuget.config Directory.Build.props Directory.Packages.props ./
COPY Csag.AzureSqlFederatedIdentity/Csag.AzureSqlFederatedIdentity.csproj Csag.AzureSqlFederatedIdentity/packages.lock.json Csag.AzureSqlFederatedIdentity/
COPY Csag.AzureSqlFederatedIdentity.Demo/Csag.AzureSqlFederatedIdentity.Demo.csproj Csag.AzureSqlFederatedIdentity.Demo/packages.lock.json Csag.AzureSqlFederatedIdentity.Demo/
RUN dotnet restore Csag.AzureSqlFederatedIdentity.Demo --locked-mode

# Copy everything else and build
COPY . .
RUN dotnet publish -c Release -o /app/publish
RUN dotnet publish Csag.AzureSqlFederatedIdentity.Demo -c Release --no-restore -o /app/publish

# Runtime image
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app

# Ensure ASP.NET listens on port 8080 (Cloud Run default)
ENV ASPNETCORE_URLS=http://+:8080
# Cloud Run sends requests to the container on $PORT, which defaults to 8080.
ENV ASPNETCORE_HTTP_PORTS=8080
EXPOSE 8080

COPY --from=build /app/publish ./
# Non-root user shipped with the aspnet image.
USER app

ENTRYPOINT ["dotnet", "AspNetEfAzureAdTest.dll"]
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "Csag.AzureSqlFederatedIdentity.Demo.dll"]
18 changes: 12 additions & 6 deletions Csag.AzureSqlFederatedIdentity.Demo/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,25 +15,31 @@

app.MapGet("/", () => Results.Ok("Cloud Run to Azure SQL via Workload Identity Federation."));

app.MapGet("/test", async ([FromServices] IAppDbContextFactory dbFactory) =>
app.MapGet("/test", async ([FromServices] IAppDbContextFactory dbFactory, [FromServices] ILogger<Program> logger, CancellationToken cancellationToken) =>
{
try
{
await using var context = await dbFactory.CreateDbContextAsync();
await using var context = await dbFactory.CreateDbContextAsync(cancellationToken);

var count = await context.TestTable.CountAsync();
var count = await context.TestTable.CountAsync(cancellationToken);
if (count == 0)
{
return Results.NotFound("No rows found in TestTable.");
}

var rows = await context.TestTable.OrderBy(e => e.Id).ToListAsync();
var rows = await context.TestTable.OrderBy(e => e.Id).ToListAsync(cancellationToken);
return Results.Ok(new { count, rows, });
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// The client went away; there is nobody to answer and nothing worth logging.
throw;
}
catch (Exception ex)
{
// In production, avoid exposing details; here for debugging:
return Results.Problem(detail: ex.Message);
// The endpoint is unauthenticated, so token-exchange and SQL failures go to the log, not the response.
logger.LogError(ex, "Querying TestTable failed.");
return Results.Problem();
Comment thread
neotrow marked this conversation as resolved.
}
});

Expand Down
97 changes: 97 additions & 0 deletions Csag.AzureSqlFederatedIdentity.Demo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Csag.AzureSqlFederatedIdentity.Demo

A minimal ASP.NET Core app that runs on Google Cloud Run and reads from Azure SQL without a database password. The `Csag.AzureSqlFederatedIdentity` library turns the app's Google identity into an Azure AD access token, and [`Database/AppDbContextFactory.cs`](Database/AppDbContextFactory.cs) attaches that token to each `SqlConnection`.

| Route | Behaviour |
|---|---|
| `GET /` | Greeting; proves the container is up. |
| `GET /test` | Counts and lists the rows of `dbo.TestTable`: `200` with `{ "count": n, "rows": [...] }`, `404` if the table is empty, `500` (generic problem response) if the token exchange or the SQL connection fails. The reason is in the application log. |

## Prerequisites

1. The cloud side described in [docs/cloud-identity-setup.md](../docs/cloud-identity-setup.md): a Google service account, an Azure AD app registration with a federated credential for it, and a database user for that app registration.
2. Google Application Default Credentials (ADC), which the library uses to ask Google for an ID token for the service account:
- **On Cloud Run** there is nothing to configure: set the service's runtime service account to the configured service account (it needs `roles/iam.serviceAccountOpenIdTokenCreator` on itself).
- **On a workstation** run `gcloud auth application-default login`. Your Google account needs `roles/iam.serviceAccountOpenIdTokenCreator` on the service account (granted on the service account, not on the project), and the IAM Service Account Credentials API must be enabled in the project (`gcloud services enable iamcredentials.googleapis.com`).
Comment thread
Copilot marked this conversation as resolved.
3. The .NET SDK pinned in [global.json](../global.json), and Docker if you want to run the container.

## Settings

`appsettings.json` ships with the four values empty. Provide them through user secrets on a workstation or environment variables in a container:

| Setting | User-secrets / JSON key | Environment variable |
|---|---|---|
| Azure AD tenant ID | `Csag.AzureSqlFederatedIdentity:TenantId` | `Csag.AzureSqlFederatedIdentity__TenantId` |
| Azure AD application (client) ID | `Csag.AzureSqlFederatedIdentity:ClientId` | `Csag.AzureSqlFederatedIdentity__ClientId` |
| Google service account email | `Csag.AzureSqlFederatedIdentity:Google:ServiceAccountEmail` | `Csag.AzureSqlFederatedIdentity__Google__ServiceAccountEmail` |
| Azure SQL connection string | `ConnectionStrings:DefaultConnection` | `ConnectionStrings__DefaultConnection` |

The first three are validated at startup, and the app refuses to start if any of them is missing. The connection string is checked on the first request to `/test`.

```shell
dotnet user-secrets set "Csag.AzureSqlFederatedIdentity:TenantId" "<tenant-id>" --project Csag.AzureSqlFederatedIdentity.Demo
dotnet user-secrets set "Csag.AzureSqlFederatedIdentity:ClientId" "<client-id>" --project Csag.AzureSqlFederatedIdentity.Demo
dotnet user-secrets set "Csag.AzureSqlFederatedIdentity:Google:ServiceAccountEmail" "<name>@<project>.iam.gserviceaccount.com" --project Csag.AzureSqlFederatedIdentity.Demo
dotnet user-secrets set "ConnectionStrings:DefaultConnection" "Server=tcp:<server>.database.windows.net,1433;Initial Catalog=<database>;Encrypt=True" --project Csag.AzureSqlFederatedIdentity.Demo
```

The connection string must not contain `User ID`, `Password`, `Integrated Security` or `Authentication`: the access token is the credential, and `SqlClient` rejects a connection string that also carries one of those.

## Schema

The app never creates schema. The identity it runs as only has `db_datareader` and `db_datawriter` (see the setup guide), so create the table once as the server's Microsoft Entra admin or another login with DDL rights:

```sql
CREATE TABLE dbo.TestTable
(
Id INT IDENTITY(1, 1) NOT NULL CONSTRAINT PK_TestTable PRIMARY KEY,
Value NVARCHAR(MAX) NOT NULL
);

INSERT INTO dbo.TestTable (Value) VALUES (N'hello'), (N'world');
```

## Run from source

```shell
dotnet run --project Csag.AzureSqlFederatedIdentity.Demo
```

The application stays in the foreground; request the endpoint from a second terminal:

```shell
curl http://localhost:5172/test
```

## Run in Docker

The image is built from the **repository root**, because the Demo references the library project:

```shell
docker build -f Csag.AzureSqlFederatedIdentity.Demo/Dockerfile -t csag-demo .
```

The container listens on port 8080 and runs as the non-root `app` user (uid 1654). Outside Cloud Run it has no ADC of its own, so mount your workstation's credential file and point the Google SDK at it. `gcloud` creates that file readable by your user only, so for this local test run the container as your own user (`--user`), which overrides the image's `app` user; on Windows the mounted file is readable regardless and `--user` can be left out. Cloud Run needs none of this:

```shell
docker run --rm -p 8080:8080 --user "$(id -u):$(id -g)" \
-e Csag.AzureSqlFederatedIdentity__TenantId="<tenant-id>" \
-e Csag.AzureSqlFederatedIdentity__ClientId="<client-id>" \
-e Csag.AzureSqlFederatedIdentity__Google__ServiceAccountEmail="<name>@<project>.iam.gserviceaccount.com" \
-e ConnectionStrings__DefaultConnection="Server=tcp:<server>.database.windows.net,1433;Initial Catalog=<database>;Encrypt=True" \
-v "$HOME/.config/gcloud/application_default_credentials.json:/adc.json:ro" \
-e GOOGLE_APPLICATION_CREDENTIALS=/adc.json \
csag-demo
```

The container stays in the foreground as well; from a second terminal:

```shell
curl http://localhost:8080/test
```

On Windows the credential file is `%APPDATA%\gcloud\application_default_credentials.json`.

## 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.
Loading