diff --git a/blogs/SERIES-NAVIGATION-TOC.md b/blogs/SERIES-NAVIGATION-TOC.md index a37a616..0c6214b 100644 --- a/blogs/SERIES-NAVIGATION-TOC.md +++ b/blogs/SERIES-NAVIGATION-TOC.md @@ -76,6 +76,7 @@ * [5.6 — Deploy .NET API and IdentityServer to Azure App Service](series-5-devops-data/5.6-azure-deploy-dotnet-apps.md) — Deploy .NET Apps * [5.7 — Deploy Angular to Azure Static Web Apps](series-5-devops-data/5.7-azure-deploy-angular-swa.md) — Deploy Angular SWA * [5.8 — Connect the Stack: Post-Deployment Configuration and Validation](series-5-devops-data/5.8-azure-post-deployment-config.md) — Post-Deployment Config +* [5.9 — Zero Secrets in Config: Azure Key Vault for App Service and .NET Code](series-5-devops-data/5.9-azure-key-vault-secrets.md) — Key Vault Secrets Management --- diff --git a/blogs/series-5-devops-data/5.9-azure-key-vault-secrets.md b/blogs/series-5-devops-data/5.9-azure-key-vault-secrets.md new file mode 100644 index 0000000..4574e07 --- /dev/null +++ b/blogs/series-5-devops-data/5.9-azure-key-vault-secrets.md @@ -0,0 +1,496 @@ +# Zero Secrets in Config: Azure Key Vault for App Service and .NET Code + +## Store Connection Strings in Key Vault, Reference Them Without Hardcoding Anything + +Every deployment tutorial eventually gets to the awkward part: where do you put the database password? Environment variables work but are visible in plain text in the Azure Portal. GitHub Secrets keep them out of source control but they still get injected as raw values into App Service settings — visible to anyone with portal access. + +Azure Key Vault solves this end to end. Secrets live in one vault, access is controlled by managed identity (no passwords to rotate), and App Service resolves references at runtime. The .NET app reads `ConnectionStrings:DefaultConnection` exactly as it always has — it never knows Key Vault is involved. + +This article covers two use cases: **App Service environment settings** (no code changes required) and **reading secrets directly in .NET code** using `DefaultAzureCredential`. + +![Azure Key Vault](https://raw.githubusercontent.com/workcontrolgit/AngularNetTutorial/master/docs/images/webapi/swagger-api-endpoints.png) + +📖 **Tutorial Repository:** [AngularNetTutorial on GitHub](https://github.com/workcontrolgit/AngularNetTutorial) + +--- + +This article is part of the **AngularNetTutorial** series covering Angular 20, .NET 10, and OAuth 2.0 with Duende IdentityServer. **Article 5.4 provisioned the Key Vault and managed identities. This article puts them to use.** + +--- + +## 📚 What You'll Learn + +* **Key Vault secret naming** — why hyphens, not double-underscores +* **App Service Key Vault references** — the `@Microsoft.KeyVault(SecretUri=...)` syntax and how to verify it works +* **`DefaultAzureCredential`** — how one credential works for both local development and Azure production with no code changes +* **`AddAzureKeyVault()` in .NET** — how to wire Key Vault into `IConfiguration` so existing code requires zero changes +* **`SecretClient`** — reading a specific secret directly in code when you need it on demand +* **Visual Studio Manage User Secrets** — the local development equivalent of Key Vault, stored in AppData and never committed to git +* **Three secret sources, one IConfiguration key** — how user secrets, GitHub Secrets, and Key Vault work together across environments + +--- + +## 🎯 The Problem + +After deploying the Talent Management stack in Articles 5.6–5.8, the GitHub Actions workflows set App Service configuration like this: + +```bash +az webapp config appsettings set \ + --settings \ + "ConnectionStrings__DefaultConnection=$API_CONN" \ + "Sts__ServerUrl=$IDS_URL" +``` + +This works — but the raw connection string value (including username and password) is now visible in the Azure Portal under **App Service → Configuration → Application settings**. Anyone with Contributor access to the resource group can read it. + +**The better approach:** + +* Store secrets in Key Vault once +* Reference them from App Service settings using a Key Vault URI +* The Portal shows a Key Vault icon — never the raw value +* .NET code reads `IConfiguration` exactly as before — no changes needed + +--- + +## 💡 How It Works + +### The Three-Part Setup + +**Part 1 — Infrastructure (already done in Article 5.4):** +* Key Vault `kv-talent-dev` was provisioned +* Each Web App was given a system-assigned managed identity +* The `Key Vault Secrets User` role was granted to each identity + +**Part 2 — Secrets (covered in this article):** +* Store actual secret values in Key Vault using `az keyvault secret set` +* Update App Service settings to reference Key Vault URIs instead of raw values + +**Part 3 — .NET code (covered in this article):** +* Add `Azure.Extensions.AspNetCore.Configuration.Secrets` NuGet package +* Wire `AddAzureKeyVault()` into `Program.cs` +* Optionally use `SecretClient` for direct on-demand secret access + +--- + +## 🔑 Part 1: App Service Key Vault References + +This approach requires **zero .NET code changes**. App Service resolves Key Vault references before injecting values into the app's environment. + +### Step 1: Understand Secret Naming + +Key Vault secret names use **hyphens** (`-`) only — no colons, no double-underscores. App Service automatically maps: + +* `ConnectionStrings--DefaultConnection` → `ConnectionStrings:DefaultConnection` +* `Sts--ServerUrl` → `Sts:ServerUrl` +* `JWTSettings--Key` → `JWTSettings:Key` + +The double-hyphen (`--`) maps to the section separator (`:`) used in .NET configuration. This is a fixed Azure convention. + +### Step 2: Store Secrets in Key Vault + +```bash +# API database connection string +az keyvault secret set \ + --vault-name kv-talent-dev \ + --name "ConnectionStrings--DefaultConnection" \ + --value "Server=sql-talent-dev.database.windows.net;Database=sqldb-talent-api-dev;User Id=sqladmin;Password=YourPassword;" + +# IdentityServer database connection string +az keyvault secret set \ + --vault-name kv-talent-dev \ + --name "ConnectionStrings--ConfigurationDbConnection" \ + --value "Server=sql-talent-dev.database.windows.net;Database=sqldb-talent-ids-dev;User Id=sqladmin;Password=YourPassword;" + +# JWT signing key +az keyvault secret set \ + --vault-name kv-talent-dev \ + --name "JWTSettings--Key" \ + --value "YourJwtSigningKey" + +# IdentityServer URL (used by both API and STS) +az keyvault secret set \ + --vault-name kv-talent-dev \ + --name "Sts--ServerUrl" \ + --value "https://app-talent-ids-dev.azurewebsites.net" +``` + +Verify the secrets were stored: + +```bash +az keyvault secret list \ + --vault-name kv-talent-dev \ + --query "[].name" \ + --output table +``` + +### Step 3: Build Key Vault Reference URIs + +App Service uses the secret's URI (without version) so it always resolves the latest value: + +```bash +# Get the vault URI +KV_URI=$(az keyvault show \ + --name kv-talent-dev \ + --query properties.vaultUri \ + --output tsv) + +# URI format: {vaultUri}secrets/{secretName} +# Example: https://kv-talent-dev.vault.azure.net/secrets/ConnectionStrings--DefaultConnection +``` + +### Step 4: Update App Service Settings to Use Key Vault References + +Replace raw values with Key Vault references using the `@Microsoft.KeyVault(SecretUri=...)` syntax: + +```bash +KV_URI=$(az keyvault show --name kv-talent-dev --query properties.vaultUri --output tsv) + +az webapp config appsettings set \ + --resource-group rg-talent-dev \ + --name app-talent-api-dev \ + --settings \ + "ConnectionStrings__DefaultConnection=@Microsoft.KeyVault(SecretUri=${KV_URI}secrets/ConnectionStrings--DefaultConnection)" \ + "JWTSettings__Key=@Microsoft.KeyVault(SecretUri=${KV_URI}secrets/JWTSettings--Key)" \ + "Sts__ServerUrl=@Microsoft.KeyVault(SecretUri=${KV_URI}secrets/Sts--ServerUrl)" +``` + +**Note:** App Service setting names still use double-underscore (`__`) — this is how .NET maps flat environment variable names to nested configuration keys. Only Key Vault secret names use double-hyphen (`--`). + +### Step 5: Verify the References Resolve + +In the Azure Portal: + +1. Go to **App Service → app-talent-api-dev → Configuration → Application settings** +2. Each Key Vault reference setting shows a **Key Vault icon** (🔑) +3. The status column shows ✅ **green check** = resolved successfully, or ❌ **red X** = access problem + +If you see a red X, check: + +* The managed identity has the `Key Vault Secrets User` role on the vault (Article 5.4) +* The secret name in the URI exactly matches the secret stored in Key Vault (case-sensitive) +* The vault URI ends with a `/` before `secrets/` + +The .NET app reads `ConnectionStrings:DefaultConnection` via `IConfiguration` as usual — it receives the resolved value, never the `@Microsoft.KeyVault(...)` string. + +### Step 6: Update GitHub Actions to Use Key Vault References + +Update the deploy workflow so future deployments inject Key Vault references instead of raw secret values. In `.github/workflows/deploy-api.yml`, replace the raw `$API_CONN` injection: + +```yaml +- name: Configure App Service settings + env: + IDS_URL: ${{ secrets.IDENTITY_SERVER_URL }} + ANGULAR_URL: ${{ secrets.ANGULAR_APP_URL }} + KV_URI: ${{ secrets.KEY_VAULT_URI }} # e.g. https://kv-talent-dev.vault.azure.net/ + run: | + az webapp config appsettings set \ + --resource-group ${{ env.RESOURCE_GROUP }} \ + --name ${{ env.APP_SERVICE_NAME }} \ + --settings \ + "ConnectionStrings__DefaultConnection=@Microsoft.KeyVault(SecretUri=${KV_URI}secrets/ConnectionStrings--DefaultConnection)" \ + "JWTSettings__Key=@Microsoft.KeyVault(SecretUri=${KV_URI}secrets/JWTSettings--Key)" \ + "Sts__ServerUrl=$IDS_URL" \ + "Sts__ValidIssuer=$IDS_URL" \ + "Cors__AllowedOrigins__0=$ANGULAR_URL" \ + "Cors__AllowedOrigins__1=https://workcontrolgit.github.io" \ + "ASPNETCORE_ENVIRONMENT=Production" +``` + +Add `KEY_VAULT_URI` as a GitHub Secret (value: `https://kv-talent-dev.vault.azure.net/`). Note that `Sts__ServerUrl` can remain a plain value since it is not sensitive — Key Vault is for secrets only, not every configuration value. + +--- + +## 🖥️ Part 2: Reading Secrets Directly in .NET Code + +Sometimes you need a secret on demand in code — not just at startup via configuration. Use `SecretClient` with `DefaultAzureCredential` for this. + +### Step 1: Add NuGet Packages + +In the `TalentManagementAPI.WebApi` project: + +```bash +dotnet add package Azure.Identity +dotnet add package Azure.Security.KeyVault.Secrets +dotnet add package Azure.Extensions.AspNetCore.Configuration.Secrets +``` + +* **`Azure.Identity`** — provides `DefaultAzureCredential`, which tries multiple authentication methods in order +* **`Azure.Security.KeyVault.Secrets`** — the `SecretClient` for on-demand secret access +* **`Azure.Extensions.AspNetCore.Configuration.Secrets`** — wires Key Vault into `IConfiguration` via `AddAzureKeyVault()` + +### Step 2: Wire Key Vault into IConfiguration + +Add `AddAzureKeyVault()` in `Program.cs` before `builder.Build()`. When added as a configuration source, **all Key Vault secrets become available through `IConfiguration`** — including secrets not referenced in App Service settings. + +```csharp +// Program.cs + +using Azure.Identity; + +var builder = WebApplication.CreateBuilder(args); + +// Add Key Vault as a configuration source (production only) +// In development: uses your logged-in Azure CLI identity (az login) +// In production: uses the App Service's system-assigned managed identity +if (!builder.Environment.IsDevelopment()) +{ + var keyVaultUri = new Uri(builder.Configuration["KeyVault:Uri"] + ?? throw new InvalidOperationException("KeyVault:Uri not configured")); + + builder.Configuration.AddAzureKeyVault(keyVaultUri, new DefaultAzureCredential()); +} + +// ... rest of service registration +``` + +Add the Key Vault URI to `appsettings.json` (not a secret — just the vault address): + +```json +{ + "KeyVault": { + "Uri": "https://kv-talent-dev.vault.azure.net/" + } +} +``` + +**After this change, all Key Vault secrets are available via `IConfiguration` using their secret name.** Secret `ConnectionStrings--DefaultConnection` in Key Vault maps to `ConnectionStrings:DefaultConnection` in `IConfiguration` — the same key EF Core already reads. + +**`DefaultAzureCredential` authentication chain:** + +* **Local development** — uses your `az login` session (Visual Studio, Azure CLI, or VS Code Azure extension) +* **Azure App Service** — uses the system-assigned managed identity automatically +* **No code change needed** between environments — the same line works everywhere + +### Step 3: Local Development with User Secrets + +Do not use Key Vault during local development — it requires network access and an Azure login. Instead, keep using `dotnet user-secrets` for local secrets: + +```bash +# Store the connection string locally (never committed to git) +dotnet user-secrets set "ConnectionStrings:DefaultConnection" \ + "Server=localhost;Database=TalentManagementApiDb;Trusted_Connection=True;" \ + --project ApiResources/TalentManagement-API/TalentManagementAPI.WebApi +``` + +User secrets take precedence over `appsettings.json` in development. Key Vault takes precedence over `appsettings.json` in production (when added as a configuration source via `AddAzureKeyVault()`). + +**Configuration precedence (highest to lowest):** + +* Environment variables (App Service settings) +* User secrets (local development only) +* `AddAzureKeyVault()` (production) +* `appsettings.{Environment}.json` +* `appsettings.json` + +### Step 4: Read a Secret On Demand with SecretClient + +For cases where you need a secret at a specific point in code rather than at startup — for example, fetching a third-party API key only when a specific controller action runs: + +```csharp +using Azure.Identity; +using Azure.Security.KeyVault.Secrets; + +public class ExternalIntegrationService +{ + private readonly SecretClient _secretClient; + + public ExternalIntegrationService(IConfiguration configuration) + { + var vaultUri = new Uri(configuration["KeyVault:Uri"]!); + _secretClient = new SecretClient(vaultUri, new DefaultAzureCredential()); + } + + public async Task GetApiKeyAsync() + { + KeyVaultSecret secret = await _secretClient.GetSecretAsync("ExternalApi--ApiKey"); + return secret.Value.Value; + } +} +``` + +Register it in `Program.cs`: + +```csharp +builder.Services.AddScoped(); +``` + +`DefaultAzureCredential` is safe to instantiate multiple times — it is lightweight and stateless. The actual token is cached by the Azure SDK. + +### Step 5: Register SecretClient as a Singleton (Optional) + +If multiple services need on-demand secret access, register `SecretClient` once in DI rather than constructing it in each service: + +```csharp +// Program.cs +builder.Services.AddSingleton(sp => +{ + var vaultUri = new Uri(builder.Configuration["KeyVault:Uri"]!); + return new SecretClient(vaultUri, new DefaultAzureCredential()); +}); +``` + +Inject it directly: + +```csharp +public class MyService(SecretClient secretClient) +{ + public async Task DoSomethingAsync() + { + var secret = await secretClient.GetSecretAsync("MySecret--Name"); + // use secret.Value.Value + } +} +``` + +--- + +## 🖥️ Local Development: Visual Studio Manage User Secrets + +During local development you don't need Key Vault — it requires Azure network access and adds friction for every team member. Visual Studio has a built-in equivalent called **Manage User Secrets** that stores secrets outside the project folder so they're never committed to git. + +### How to Open It + +Right-click the `TalentManagementAPI.WebApi` project in Solution Explorer → **Manage User Secrets** + +This opens a `secrets.json` file stored at: + +``` +C:\Users\{you}\AppData\Roaming\Microsoft\UserSecrets\d7dba4fb-c08e-453d-9e13-d7d0f8ba8ff0\secrets.json +``` + +The GUID is the `` value already in `TalentManagementAPI.WebApi.csproj`. Each developer has their own copy — the file never appears in git. + +### What to Put in secrets.json + +```json +{ + "ConnectionStrings": { + "DefaultConnection": "Server=localhost;Database=TalentManagementApiDb;Trusted_Connection=True;MultipleActiveResultSets=true" + }, + "JWTSettings": { + "Key": "YourLocalJwtSigningKeyMinimum32Characters" + }, + "Sts": { + "ServerUrl": "https://localhost:44310", + "ValidIssuer": "https://localhost:44310" + } +} +``` + +These values override `appsettings.json` automatically in the `Development` environment — no code change required. + +### The Full Picture: Three Secret Sources, One IConfiguration Key + +The same `ConnectionStrings:DefaultConnection` key is read by the .NET app in every environment. What changes is where the value comes from: + +**Local development — Visual Studio Manage User Secrets:** +* Stored in `AppData` on your machine +* Never in git, never in source control +* Overrides `appsettings.json` automatically + +**GitHub Actions deploy — GitHub Secrets:** +* Used to log in to Azure and run Bicep +* Writes `@Microsoft.KeyVault(...)` references to App Service settings +* The actual database password is never in GitHub Secrets at all + +**Azure App Service runtime — Key Vault:** +* App Service resolves `@Microsoft.KeyVault(...)` references via managed identity +* The running app receives the real connection string value +* Never visible in plain text in the Portal + +``` +Local dev → VS Manage User Secrets → AppData on your machine +CI/CD deploy → GitHub Secrets → Azure login + KV reference URIs +Azure runtime → Azure Key Vault → managed identity, resolved at startup +``` + +**The application code never changes across environments.** `builder.Configuration["ConnectionStrings:DefaultConnection"]` works the same way whether the value came from `secrets.json`, an App Service setting, or a Key Vault reference. + +--- + +## 🔄 When to Use Each Approach + +**App Service Key Vault references** (`@Microsoft.KeyVault(...)` in settings): +* Connection strings read by EF Core at startup +* JWT keys read by `AddJwtBearer()` at startup +* Any secret injected via `IConfiguration` that is read once at startup +* ✅ Zero code changes required + +**`AddAzureKeyVault()` in Program.cs:** +* You want all Key Vault secrets available anywhere via `IConfiguration` +* You have secrets that are read after startup (not just at startup) +* You want Key Vault as the single source of truth, removing App Service settings entirely +* ✅ One-time code change, then all secrets work the same way + +**`SecretClient` in code:** +* You need a secret only in specific scenarios (not at startup) +* You want to fetch the latest value dynamically without restarting the app +* You need to list, create, or rotate secrets programmatically +* ✅ Full control over when secrets are fetched + +--- + +## 💻 Try It Yourself + +After storing secrets and updating App Service settings, verify end-to-end: + +```bash +# Check that Key Vault references resolved successfully +az webapp config appsettings list \ + --resource-group rg-talent-dev \ + --name app-talent-api-dev \ + --query "[?contains(value, '@Microsoft.KeyVault')].[name,value]" \ + --output table + +# Test the API health endpoint (confirms the app started successfully) +curl https://app-talent-api-dev.azurewebsites.net/health +``` + +If the app starts and the health endpoint returns `Healthy`, the connection string was resolved from Key Vault and EF Core connected to the database successfully. + +--- + +## 🔑 Key Design Decisions + +**App Service references over `AddAzureKeyVault()` for connection strings.** The App Service reference approach (`@Microsoft.KeyVault(...)`) requires zero code changes and works for any app regardless of framework or language. The .NET `AddAzureKeyVault()` approach is more powerful — all secrets flow through `IConfiguration` — but requires adding packages and modifying `Program.cs`. For a tutorial project, both are demonstrated so you can choose based on your needs. + +**`DefaultAzureCredential` over explicit `ManagedIdentityCredential`.** `DefaultAzureCredential` tries a chain of credential types automatically. In local development it uses the Azure CLI session. In App Service it uses the managed identity. Using `ManagedIdentityCredential` directly would fail locally; using `DefaultAzureCredential` works everywhere with no environment-specific code. + +**User secrets for local development, Key Vault for production.** Never connect local development to a shared Key Vault — it creates a dependency on network access and complicates onboarding. Each developer uses their own local database with `dotnet user-secrets`. Key Vault is a production concern. + +**Versionless secret URIs for App Service references.** Omitting the version from the Key Vault reference URI (`secrets/MySecret` not `secrets/MySecret/abc123`) means the App Service always resolves the latest version. When you rotate a secret in Key Vault, the App Service picks it up on next restart with no configuration change. + +--- + +## 🌟 Why This Matters + +The pattern in this article — managed identity + Key Vault + App Service references — is the production-grade zero-credential approach recommended by Microsoft's Well-Architected Framework. No password in source control, no password in a GitHub Secret that gets injected as plain text, no password visible in the Portal. + +The same `IConfiguration` API that reads from `appsettings.json` in development reads from Key Vault in production. Application code never changes based on where a secret comes from. This separation of configuration source from configuration consumption is the goal. + +**Transferable skills:** + +* **`DefaultAzureCredential`** — applies to any Azure SDK call: Storage, Service Bus, Cosmos DB, all follow the same pattern +* **App Service Key Vault references** — works for any language/framework deployed to App Service (Node.js, Python, Java) — not just .NET +* **`IConfiguration` + `AddAzureKeyVault()`** — the standard .NET pattern for centralized secret management across multiple environments + +--- + +## 🤝 Community & Support + +**Questions or feedback?** The tutorial repository welcomes: + +* ⭐ **GitHub stars** — Help others discover it! +* 🐛 **Issue reports** — Found a bug or have a suggestion? +* 💬 **Discussions** — Ask questions, share your use cases +* 🚀 **Pull requests** — Improvements always appreciated + +**Found this helpful?** Share it with your team and follow for more full-stack development content! + +--- + +📖 **Series:** [AngularNetTutorial Series Navigation](../SERIES-NAVIGATION-TOC.md) + +--- + +**📌 Tags:** #azure #keyvault #dotnet #managedidentity #security #secretsmanagement #appservice #azureidentity #csharp #devops #zerotrust #cloudnative #configuration #githubactions #fullstack