diff --git a/.github/workflows/deploy-angular.yml b/.github/workflows/deploy-angular.yml new file mode 100644 index 0000000..9695cf0 --- /dev/null +++ b/.github/workflows/deploy-angular.yml @@ -0,0 +1,80 @@ +name: Deploy Angular to Azure Static Web Apps + +on: + push: + branches: + - main + paths: + - 'Clients/TalentManagement-Angular-Material/**' + - '.github/workflows/deploy-angular.yml' + workflow_dispatch: + +permissions: + id-token: write # Required for OIDC token request + contents: read + +env: + ANGULAR_APP_DIR: 'Clients/TalentManagement-Angular-Material/talent-management' + STATIC_WEB_APP_NAME: 'swa-talent-ui-dev' + RESOURCE_GROUP: 'rg-talent-dev' + +jobs: + build-and-deploy: + name: Build and Deploy Angular + runs-on: ubuntu-latest + + steps: + - name: Checkout repository (with submodules) + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22.x' + cache: 'npm' + cache-dependency-path: '${{ env.ANGULAR_APP_DIR }}/package-lock.json' + + - name: Install dependencies + working-directory: ${{ env.ANGULAR_APP_DIR }} + run: npm ci + + - name: Inject production environment variables + working-directory: ${{ env.ANGULAR_APP_DIR }} + env: + API_URL: ${{ secrets.API_APP_URL }} + IDENTITY_SERVER_URL: ${{ secrets.IDENTITY_SERVER_URL }} + run: | + sed -i "s|https://your-production-api.com/api/v1|${API_URL}/api/v1|g" src/environments/environment.prod.ts + sed -i "s|https://localhost:44310|${IDENTITY_SERVER_URL}|g" src/environments/environment.prod.ts + + - name: Build Angular (production) + working-directory: ${{ env.ANGULAR_APP_DIR }} + run: npm run build -- --configuration production + + - name: Log in to Azure + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Get Static Web App deployment token + id: swa-token + run: | + SWA_TOKEN=$(az staticwebapp secrets list \ + --name ${{ env.STATIC_WEB_APP_NAME }} \ + --resource-group ${{ env.RESOURCE_GROUP }} \ + --query properties.apiKey \ + --output tsv) + echo "token=${SWA_TOKEN}" >> $GITHUB_OUTPUT + + - name: Deploy to Azure Static Web Apps + uses: Azure/static-web-apps-deploy@v1 + with: + azure_static_web_apps_api_token: ${{ steps.swa-token.outputs.token }} + action: upload + app_location: 'Clients/TalentManagement-Angular-Material/talent-management' + output_location: 'dist/talent-management/browser' + skip_app_build: true diff --git a/.github/workflows/deploy-api.yml b/.github/workflows/deploy-api.yml new file mode 100644 index 0000000..b5d2dae --- /dev/null +++ b/.github/workflows/deploy-api.yml @@ -0,0 +1,84 @@ +name: Deploy .NET API to Azure App Service + +on: + push: + branches: + - main + paths: + - 'ApiResources/TalentManagement-API/**' + - '.github/workflows/deploy-api.yml' + workflow_dispatch: + +permissions: + id-token: write # Required for OIDC token request + contents: read + +env: + DOTNET_VERSION: '10.x' + PROJECT_PATH: 'ApiResources/TalentManagement-API/TalentManagementAPI.WebApi/TalentManagementAPI.WebApi.csproj' + SOLUTION_PATH: 'ApiResources/TalentManagement-API/TalentManagementAPI.slnx' + PUBLISH_DIR: '${{ github.workspace }}/publish/api' + APP_SERVICE_NAME: 'app-talent-api-dev' + RESOURCE_GROUP: 'rg-talent-dev' + +jobs: + build-and-deploy: + name: Build, Test, and Deploy API + runs-on: ubuntu-latest + + steps: + - name: Checkout repository (with submodules) + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Set up .NET ${{ env.DOTNET_VERSION }} + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Restore dependencies + run: dotnet restore ${{ env.SOLUTION_PATH }} + + - name: Build + run: dotnet build ${{ env.SOLUTION_PATH }} --configuration Release --no-restore + + - name: Run unit tests + run: dotnet test ${{ env.SOLUTION_PATH }} --configuration Release --no-build --verbosity normal + + - name: Publish + run: | + dotnet publish ${{ env.PROJECT_PATH }} \ + --configuration Release \ + --no-build \ + --output ${{ env.PUBLISH_DIR }} + + - name: Log in to Azure + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Configure App Service settings + run: | + az webapp config appsettings set \ + --resource-group ${{ env.RESOURCE_GROUP }} \ + --name ${{ env.APP_SERVICE_NAME }} \ + --settings \ + "ConnectionStrings__DefaultConnection=${{ secrets.API_DB_CONNECTION_STRING }}" \ + "Sts__ServerUrl=${{ secrets.IDENTITY_SERVER_URL }}" \ + "Sts__ValidIssuer=${{ secrets.IDENTITY_SERVER_URL }}" \ + "Sts__Audience=app.api.talentmanagement" \ + "JWTSettings__Key=${{ secrets.JWT_KEY }}" \ + "JWTSettings__Issuer=CoreIdentity" \ + "JWTSettings__Audience=CoreIdentityUser" \ + "JWTSettings__DurationInMinutes=60" \ + "FeatureManagement__AuthEnabled=true" \ + "ASPNETCORE_ENVIRONMENT=Production" + + - name: Deploy to Azure App Service + uses: azure/webapps-deploy@v3 + with: + app-name: ${{ env.APP_SERVICE_NAME }} + package: ${{ env.PUBLISH_DIR }} diff --git a/.github/workflows/deploy-identityserver.yml b/.github/workflows/deploy-identityserver.yml new file mode 100644 index 0000000..53ae406 --- /dev/null +++ b/.github/workflows/deploy-identityserver.yml @@ -0,0 +1,76 @@ +name: Deploy IdentityServer to Azure App Service + +on: + push: + branches: + - main + paths: + - 'TokenService/Duende-IdentityServer/**' + - '.github/workflows/deploy-identityserver.yml' + workflow_dispatch: + +permissions: + id-token: write # Required for OIDC token request + contents: read + +env: + DOTNET_VERSION: '8.x' + STS_PROJECT_PATH: 'TokenService/Duende-IdentityServer/src/DuendeIdentityServer.STS.Identity/DuendeIdentityServer.STS.Identity.csproj' + ADMIN_PROJECT_PATH: 'TokenService/Duende-IdentityServer/src/DuendeIdentityServer.Admin/DuendeIdentityServer.Admin.csproj' + PUBLISH_DIR: '${{ github.workspace }}/publish/ids' + APP_SERVICE_NAME: 'app-talent-ids-dev' + RESOURCE_GROUP: 'rg-talent-dev' + +jobs: + build-and-deploy: + name: Build, Test, and Deploy IdentityServer + runs-on: ubuntu-latest + + steps: + - name: Checkout repository (with submodules) + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Set up .NET ${{ env.DOTNET_VERSION }} + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Restore dependencies + run: dotnet restore ${{ env.STS_PROJECT_PATH }} + + - name: Build + run: dotnet build ${{ env.STS_PROJECT_PATH }} --configuration Release --no-restore + + - name: Publish + run: | + dotnet publish ${{ env.STS_PROJECT_PATH }} \ + --configuration Release \ + --no-build \ + --output ${{ env.PUBLISH_DIR }} + + - name: Log in to Azure + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Configure App Service settings + run: | + az webapp config appsettings set \ + --resource-group ${{ env.RESOURCE_GROUP }} \ + --name ${{ env.APP_SERVICE_NAME }} \ + --settings \ + "ConnectionStrings__ConfigurationDbConnection=${{ secrets.IDS_DB_CONNECTION_STRING }}" \ + "ConnectionStrings__PersistedGrantDbConnection=${{ secrets.IDS_DB_CONNECTION_STRING }}" \ + "ConnectionStrings__IdentityDbConnection=${{ secrets.IDS_DB_CONNECTION_STRING }}" \ + "AdminConfiguration__IdentityServerBaseUrl=${{ secrets.IDENTITY_SERVER_URL }}" \ + "ASPNETCORE_ENVIRONMENT=Production" + + - name: Deploy to Azure App Service + uses: azure/webapps-deploy@v3 + with: + app-name: ${{ env.APP_SERVICE_NAME }} + package: ${{ env.PUBLISH_DIR }} diff --git a/ApiResources/TalentManagement-API b/ApiResources/TalentManagement-API index a100791..1645a74 160000 --- a/ApiResources/TalentManagement-API +++ b/ApiResources/TalentManagement-API @@ -1 +1 @@ -Subproject commit a10079105016eb078c8a4fffb3063c48de0e0b00 +Subproject commit 1645a747659e4e4ec1f9f802074ee127e441a437 diff --git a/Clients/TalentManagement-Angular-Material b/Clients/TalentManagement-Angular-Material index 36a1716..aac32b2 160000 --- a/Clients/TalentManagement-Angular-Material +++ b/Clients/TalentManagement-Angular-Material @@ -1 +1 @@ -Subproject commit 36a1716745d21df6629e61d3b96eaec5c9ff2c06 +Subproject commit aac32b2ef5af358eb64d60b9c1a5e7a4760f184a diff --git a/blogs/AI-ENHANCEMENT-SERIES-PLAN.md b/blogs/AI-ENHANCEMENT-SERIES-PLAN.md index bae7d7b..13b6646 100644 --- a/blogs/AI-ENHANCEMENT-SERIES-PLAN.md +++ b/blogs/AI-ENHANCEMENT-SERIES-PLAN.md @@ -132,29 +132,32 @@ git push --set-upstream origin feature/[N.N]-[slug] - [x] Ollama running at `http://localhost:11434` ✅ - [x] `blogs/AI-ENHANCEMENT-SERIES-PLAN.md` created ✅ -- [ ] Create `blogs/series-6-ai-app-features/` folder -- [ ] Create `blogs/series-7-developer-productivity-ai/` folder -- [ ] Create `docs/images/ai/` folder for screenshots -- [ ] Update `blogs/BLOG-SERIES-PLAN.md` with Series 6 & 7 entries -- [ ] Update `blogs/SERIES-NAVIGATION-TOC.md` to include new series +- [x] Create `blogs/series-6-ai-app-features/` folder ✅ +- [x] Create `blogs/series-7-developer-productivity-ai/` folder ✅ +- [x] Create `docs/images/ai/` folder for screenshots ✅ +- [x] Update `blogs/BLOG-SERIES-PLAN.md` with Series 6 & 7 entries ✅ +- [x] Update `blogs/SERIES-NAVIGATION-TOC.md` to include new series ✅ ### Phase 1: Series 6 — Backend Foundation -- [ ] **6.1 — .NET AI Foundation** - - [ ] `git checkout -b feature/6.1-dotnet-ai-foundation` in ApiResources submodule - - [ ] `git checkout -b feature/6.1-dotnet-ai-foundation` in parent repo - - [ ] Write article draft (`6.1-dotnet-ai-foundation.md`) - - [ ] Add to WebApi.csproj: `Microsoft.Extensions.AI`, `Microsoft.Extensions.AI.Ollama` - - [ ] Add `"FeatureManagement": { "AiEnabled": false }` to `appsettings.json` - - [ ] Add `aiEnabled: false` to `environment.ts` - - [ ] Create `Application/Interfaces/IAiChatService.cs` - - [ ] Create `Infrastructure.Shared/Services/OllamaAiService.cs` - - [ ] Create `WebApi/Controllers/v1/AiController.cs` with `[FeatureGate("AiEnabled")]` - - [ ] Screenshot: Swagger AI endpoint → `docs/images/ai/` - - [ ] Commit + push submodule feature branch - - [ ] Commit + push parent feature branch (blog + submodule ref) - - [ ] Open PR: ApiResources `feature/6.1-dotnet-ai-foundation` → `develop` - - [ ] Open PR: Parent `feature/6.1-dotnet-ai-foundation` → `develop` +- [x] **6.1 — .NET AI Foundation** ✅ + - [x] `git checkout -b feature/6.1-dotnet-ai-foundation` in ApiResources submodule ✅ + - [x] `git checkout -b feature/6.1-dotnet-ai-foundation` in parent repo ✅ + - [x] Write article draft (`6.1-dotnet-ai-foundation.md`) ✅ + - [x] Add to WebApi.csproj: `Microsoft.Extensions.AI.Ollama` ✅ + - [x] Add to Infrastructure.Shared.csproj: `Microsoft.Extensions.AI` ✅ + - [x] Add `"AiEnabled": false` to `FeatureManagement` in `appsettings.json` ✅ + - [x] Add `"Ollama"` config block to `appsettings.json` ✅ + - [x] Create `Application/Interfaces/IAiChatService.cs` ✅ + - [x] Create `Infrastructure.Shared/Services/OllamaAiService.cs` ✅ + - [x] Create `WebApi/Controllers/v1/AiController.cs` with `[FeatureGate("AiEnabled")]` ✅ + - [x] Register `AddOllamaChatClient()` in `Program.cs` ✅ + - [x] Register `IAiChatService` → `OllamaAiService` in `ServiceRegistration.cs` ✅ + - [ ] Screenshot: Swagger AI endpoint → `docs/images/ai/` *(manual step)* + - [x] Commit + push ApiResources `feature/6.1-dotnet-ai-foundation` ✅ + - [x] Commit + push parent `feature/6.1-dotnet-ai-foundation` ✅ + - [ ] Open PR: ApiResources `feature/6.1-dotnet-ai-foundation` → `develop` — https://github.com/workcontrolgit/TalentManagement-API/pull/new/feature/6.1-dotnet-ai-foundation + - [ ] Open PR: Parent `feature/6.1-dotnet-ai-foundation` → `develop` — https://github.com/workcontrolgit/AngularNetTutorial/pull/new/feature/6.1-dotnet-ai-foundation - [ ] **6.2 — HR AI Assistant (data-aware)** - [ ] `git checkout -b feature/6.2-dotnet-ai-hr-assistant` in ApiResources submodule diff --git a/blogs/BLOG-SERIES-PLAN.md b/blogs/BLOG-SERIES-PLAN.md index 55fe677..a9261a6 100644 --- a/blogs/BLOG-SERIES-PLAN.md +++ b/blogs/BLOG-SERIES-PLAN.md @@ -175,6 +175,56 @@ - **File:** `blogs/series-5-devops-data/5.2-cicd-github-actions.md` - **Notes:** Draft complete. Ready for review. +### ☁️ Series 5 Azure Deployment Sub-Series + +*Target audience: developers new to Azure with a Visual Studio Professional subscription ($50/month credit). Each article ships with real, runnable Bicep templates and GitHub Actions workflows.* + +- [ ] **Article 5.3 — Azure Subscription Setup** + - **Title:** Your First Azure Deployment: Setting Up a Visual Studio Subscription + - **Subtitle:** Activate Your $50 Monthly Credit, Install the Azure CLI, and Understand What Fits in Your Budget + - **File:** `blogs/series-5-devops-data/5.3-azure-subscription-setup.md` + - **Branch:** `feature/5.3-5.8-azure-deployment-series` + - **Notes:** Blog only — no code files. Setup guide for new Azure users. + +- [ ] **Article 5.4 — Bicep Infrastructure** + - **Title:** Infrastructure as Code: Provision All Azure Resources with One Bicep Command + - **Subtitle:** From Empty Subscription to a Running App Service Plan, SQL Server, and Static Web App in Minutes + - **File:** `blogs/series-5-devops-data/5.4-azure-bicep-infrastructure.md` + - **Code:** `infra/main.bicep`, `infra/modules/*.bicep`, `infra/parameters/dev.bicepparam` + - **Branch:** `feature/5.3-5.8-azure-deployment-series` + - **Notes:** Writes real Bicep templates that can be run against an actual Azure subscription. + +- [ ] **Article 5.5 — OIDC GitHub Actions Setup** + - **Title:** Secure CI/CD: Connect GitHub Actions to Azure Without Storing Passwords + - **Subtitle:** How Federated Identity Credentials Replace Long-Lived Secrets With Short-Lived OIDC Tokens + - **File:** `blogs/series-5-devops-data/5.5-azure-oidc-github-actions.md` + - **Code:** Walkthrough of `infra/scripts/setup-oidc.sh` (already written) + - **Branch:** `feature/5.3-5.8-azure-deployment-series` + - **Notes:** Explains the existing setup-oidc.sh script step by step; covers 4 GitHub Secrets. + +- [ ] **Article 5.6 — Deploy .NET Apps** + - **Title:** Deploy .NET API and IdentityServer to Azure App Service with GitHub Actions + - **Subtitle:** Restore, Build, Test, Publish, and Deploy — Automatically on Every Push to Main + - **File:** `blogs/series-5-devops-data/5.6-azure-deploy-dotnet-apps.md` + - **Code:** `.github/workflows/deploy-api.yml`, `.github/workflows/deploy-identityserver.yml` + - **Branch:** `feature/5.3-5.8-azure-deployment-series` + - **Notes:** Covers deployment order (IdentityServer first), App Service config, EF Core migrations. + +- [ ] **Article 5.7 — Deploy Angular** + - **Title:** Deploy Angular to Azure Static Web Apps: Zero Cost, Global CDN, Auto PR Previews + - **Subtitle:** Inject Environment URLs at Build Time and Let GitHub Actions Handle the Rest + - **File:** `blogs/series-5-devops-data/5.7-azure-deploy-angular-swa.md` + - **Code:** `.github/workflows/deploy-angular.yml`, `staticwebapp.config.json` + - **Branch:** `feature/5.3-5.8-azure-deployment-series` + - **Notes:** Covers environment URL injection at build time and SPA fallback routing config. + +- [ ] **Article 5.8 — Post-Deployment Configuration and Validation** + - **Title:** Connect the Stack: Post-Deployment Configuration and Validation + - **Subtitle:** Wire Up IdentityServer Redirect URIs, CORS, and Validate the Full Login Flow on Azure + - **File:** `blogs/series-5-devops-data/5.8-azure-post-deployment-config.md` + - **Branch:** `feature/5.3-5.8-azure-deployment-series` + - **Notes:** Blog only — configuration checklist, common failure patterns, end-to-end validation steps. + --- ## 🤖 Series 6: AI App Features @@ -257,10 +307,10 @@ ## 📊 Publication Tracker -**Total articles planned:** 31 +**Total articles planned:** 37 **Published:** 1 **Draft ready:** 22 -**Not started:** 10 (Series 6 & 7) +**Not started:** 16 (Series 5 Azure sub-series × 6, Series 6 × 6, Series 7 × 4) --- diff --git a/blogs/series-4-playwright-testing/4.2-playwright-page-object-model.md b/blogs/series-4-playwright-testing/4.2-playwright-page-object-model.md index 38f1108..93e8b6f 100644 --- a/blogs/series-4-playwright-testing/4.2-playwright-page-object-model.md +++ b/blogs/series-4-playwright-testing/4.2-playwright-page-object-model.md @@ -16,7 +16,7 @@ The **Page Object Model** (POM) solves this. Instead of putting selectors in tes This article walks through the POM implementation in the **AngularNetTutorial** project: two abstract base classes that handle everything common to all list pages and all form pages, and two thin entity classes that add only what's specific to employees. -![Employee Form Page](https://raw.githubusercontent.com/workcontrolgit/AngularNetTutorial/master/docs/images/angular/employee-form-page.png) +![Employee Form Page](https://raw.githubusercontent.com/workcontrolgit/AngularNetTutorial/master/docs/images/angular/employee-form.png) 📖 **Tutorial Repository:** [AngularNetTutorial on GitHub](https://github.com/workcontrolgit/AngularNetTutorial) @@ -616,19 +616,6 @@ All pagination, search, permission checks, form submission, and success verifica --- -## 🎓 Summary - -The Page Object Model prevents selector duplication by centralizing locators and interactions in classes rather than scattering them across test files. - -**The two-level hierarchy in AngularNetTutorial:** - -* **`BaseListPage`** — navigation, row access (with header-skip), search, pagination, CRUD clicks, permission checks -* **`BaseFormPage`** — form waiting, submit, cancel, validation detection, dropdown helper, three-fallback success verification -* **`EmployeeListPage`** — extends `BaseListPage`, adds readable aliases like `getEmployeeCount()` and `clickEmployee()` -* **`EmployeeFormPage`** — extends `BaseFormPage`, adds `formControlName`-based locators and `fillForm()` - -The result: tests read like user stories. Selectors live in one place. New entities get full test infrastructure by extending two classes. - ## 🌟 Why This Matters The Page Object Model is the most impactful investment you can make in a test suite's long-term maintainability. A selector change in a component template is a one-file update in the Page Object — not a search-and-replace across twenty test files. The `BaseListPage` / `BaseFormPage` hierarchy means common actions like "click edit", "verify table has rows", and "submit form" are written once and inherited everywhere. diff --git a/blogs/series-4-playwright-testing/4.3-playwright-role-based-testing.md b/blogs/series-4-playwright-testing/4.3-playwright-role-based-testing.md index 0b0c941..a8f32b1 100644 --- a/blogs/series-4-playwright-testing/4.3-playwright-role-based-testing.md +++ b/blogs/series-4-playwright-testing/4.3-playwright-role-based-testing.md @@ -544,7 +544,7 @@ This workflow test also validates **data relationships** — the employee is cre --- -## 💡 Key Patterns +## 🔑 Key Design Decisions **Test RBAC at two layers independently.** Template directives hide buttons. Route guards block navigation. A bug in either layer is a security issue. Test both: "is the button visible?" and "does direct URL navigation get blocked?" @@ -558,17 +558,6 @@ This workflow test also validates **data relationships** — the employee is cre --- -## 🎓 Summary - -Testing RBAC with Playwright means going beyond "can the user log in?" and verifying the entire permission surface: - -* **What buttons are visible** — using `isVisible()` to assert presence and absence -* **What routes are accessible** — using direct URL navigation to bypass the UI -* **That switching users clears the previous role** — using the cross-role test -* **That workflow tasks work end-to-end** — using multi-step workflow tests - -The three-role structure in AngularNetTutorial (Employee, Manager, HRAdmin) maps cleanly to three `test.describe` blocks, each with its own `beforeEach` login. The cross-role test ties them together by asserting the permission hierarchy in a single browser session. - ## 🌟 Why This Matters Role-based access control is one of the hardest things to test with confidence. Unit tests can verify that a guard function returns `false` — but only E2E tests can verify that the right buttons appear for the right users, that direct URL navigation is blocked, and that switching users mid-session actually clears the previous role. The three-`describe`-block pattern makes this systematic. diff --git a/blogs/series-4-playwright-testing/4.4-playwright-jwt-token-testing.md b/blogs/series-4-playwright-testing/4.4-playwright-jwt-token-testing.md index 6ec9dac..42dd73a 100644 --- a/blogs/series-4-playwright-testing/4.4-playwright-jwt-token-testing.md +++ b/blogs/series-4-playwright-testing/4.4-playwright-jwt-token-testing.md @@ -520,7 +520,7 @@ Before doing so, consider these design implications: --- -## 💡 Key Patterns +## 🔑 Key Design Decisions **Decode without a library.** `Buffer.from(parts[1], 'base64').toString()` gives you the raw JSON. `JSON.parse()` gives you the claims object. No `jsonwebtoken`, `jwt-decode`, or other dependencies required. @@ -534,19 +534,6 @@ Before doing so, consider these design implications: --- -## 🎓 Summary - -JWT token testing with Playwright fills the gap between "login works" and "the API will accept this token with the right permissions": - -* **Two extraction methods** — `getStoredToken()` (browser storage, fast) and `getTokenFromProfile()` (Profile page, robust) -* **One-liner decode** — `JSON.parse(Buffer.from(parts[1], 'base64').toString())` -* **Claims to verify** — `sub`, `exp > now`, `iss`, `aud`, `role`, `scope` -* **Role verification** — each role gets different claims; assert all three -* **Tamper detection** — swap the signature, expect 401 from the API -* **Direct API calls** — use the extracted token with `request.get()` for API-layer testing - -When IdentityServer configuration changes, these tests tell you before the UI does. - ## 🌟 Why This Matters JWT token testing fills the gap between "login works" and "the API will accept this token with the right permissions." When IdentityServer configuration changes — a missing `role` claim, a wrong audience, an expired token lifetime — these tests catch it before the Angular UI does. The `payload.exp > now` assertion alone prevents an entire class of silent authentication failures. diff --git a/blogs/series-4-playwright-testing/4.5-playwright-api-testing.md b/blogs/series-4-playwright-testing/4.5-playwright-api-testing.md index 73377ba..8b251ff 100644 --- a/blogs/series-4-playwright-testing/4.5-playwright-api-testing.md +++ b/blogs/series-4-playwright-testing/4.5-playwright-api-testing.md @@ -8,7 +8,7 @@ Playwright's `request` fixture gives you a bare HTTP client that shares nothing The challenge in an OAuth 2.0 app is getting the token programmatically. This article shows how the **AngularNetTutorial** project solves it — by launching a temporary headless browser, completing the real OIDC flow, extracting the token, and then using it for direct API calls with full CRUD coverage. -![API Testing](https://raw.githubusercontent.com/workcontrolgit/AngularNetTutorial/master/docs/images/angular/employee-list-page.png) +![API Testing](https://raw.githubusercontent.com/workcontrolgit/AngularNetTutorial/master/docs/images/webapi/swagger-api-endpoints.png) 📖 **Tutorial Repository:** [AngularNetTutorial on GitHub](https://github.com/workcontrolgit/AngularNetTutorial) @@ -655,7 +655,7 @@ These tests verify that the API accepts pagination and search parameters without --- -## 💡 Key Patterns +## 🔑 Key Design Decisions **`test.beforeAll` for token, `test.afterEach` for cleanup.** Token acquisition is slow — do it once per suite. Record cleanup is per-test — do it after every test, even failing ones. @@ -681,21 +681,6 @@ This simple test catches a common misconfiguration: the API returning HTML error --- -## 🎓 Summary - -Playwright's `request` fixture turns E2E tests into a full API test suite: - -* **`getTokenForRole()`** — spins up a temporary headless browser, completes the OIDC flow, returns a real token -* **`test.beforeAll`** — acquires the token once per suite with a timeout safety net -* **`test.afterEach`** — deletes records created during the test -* **Full CRUD** — GET list, GET by ID, POST create, PUT update, DELETE with verify-by-404 -* **Error cases** — 400 invalid data, 404 not found, 401/403 wrong role -* **Response shape flexibility** — `data.data || data.items || data` normalization -* **Cache behavior** — `X-Cache-Status` headers, cache invalidation on write, concurrent request consistency -* **Pagination and search** — query parameter pass-through - -The `request` fixture completes the test pyramid: unit tests at the bottom, API tests in the middle, and full E2E UI tests at the top — all within the same Playwright project. - ## 🌟 Why This Matters Playwright's `request` fixture turns an E2E test suite into a full API test suite — without a second testing framework. The `getTokenForRole()` pattern (headless browser for OIDC token acquisition, then bare HTTP client for API calls) solves the hardest problem in OAuth-protected API testing: getting a real token programmatically when PKCE is the only allowed grant. diff --git a/blogs/series-5-devops-data/5.1-database-seeding.md b/blogs/series-5-devops-data/5.1-database-seeding.md index 6f9b350..682c660 100644 --- a/blogs/series-5-devops-data/5.1-database-seeding.md +++ b/blogs/series-5-devops-data/5.1-database-seeding.md @@ -424,7 +424,7 @@ This is useful for: * Seeding specific row counts in different environments * Integration test setup — seed exactly the records you need via an API call -The command is dispatched via an API endpoint that sends a POST to `api/v1/positions/seed`. This can be called from Playwright tests, CI scripts, or Swagger UI. +The command is dispatched via `POST /api/v1/positions/AddMock`. It requires a valid Bearer token (`[Authorize]`) and accepts `{ "RowCount": N }` as a JSON body. It can be called from Playwright tests, CI scripts, or Swagger UI. --- @@ -444,32 +444,182 @@ The seed value makes generation **deterministic**. Given the same seed, Bogus al --- -## 🧪 Playwright Seeding Placeholder +## 🧪 Playwright Seeding via the API -`Tests/AngularNetTutorial-Playwright/tests/seed.spec.ts` exists as a placeholder for E2E-driven seeding: +`Tests/AngularNetTutorial-Playwright/tests/seed.spec.ts` is a placeholder for E2E-driven seeding. The startup seeder gives every developer a consistent base of 1,000 employees — but sometimes an E2E test needs something more specific: a known number of positions, a particular department, or a clean slate before a destructive test run. That's what this file is for. + +### Why E2E Tests Need Their Own Seeding + +The startup seeder runs once on first launch. After that, tests that create, update, or delete records permanently change the database. If three tests run in sequence and test 2 deletes a record that test 3 expects to find, test 3 fails intermittently depending on run order. E2E-driven seeding solves this by resetting or expanding the dataset to a known state before the tests that depend on it. + +Three common patterns: + +* **Add before** — seed extra records before a test that needs more data than startup seeding provides (e.g., pagination tests that need 200+ positions) +* **Reset between** — call the seed endpoint in `beforeEach` to restore a known state before each destructive test +* **Seed in global setup** — run once before the entire test suite to guarantee a baseline without relying on startup seeding + +### The Playwright `request` Fixture + +Playwright provides a `request` fixture — an `APIRequestContext` that makes HTTP calls without launching a browser. It is the right tool for calling the seed API in tests because: + +* No browser overhead — seed calls complete in milliseconds +* Available in `beforeAll`, `beforeEach`, and in `globalSetup` +* Uses the same `baseURL` from `playwright.config.ts` +* Returns a full `APIResponse` with status, headers, and body + +### Calling the Seed Endpoint + +The `InsertMockPositionCommand` is exposed as `POST /api/v1/positions/AddMock`. It requires a Bearer token (`[Authorize]`) and accepts a JSON body. Here is a complete implementation of `seed.spec.ts`: + +```typescript +// Tests/AngularNetTutorial-Playwright/tests/seed.spec.ts +import { test, expect } from '@playwright/test'; + +const API_BASE = process.env.API_URL ?? 'https://localhost:44378'; + +test.describe('Database seeding via API', () => { + + test('seed positions', async ({ request }) => { + // Acquire a Bearer token first (use your test credentials) + const tokenResponse = await request.post(`${API_BASE}/api/v1/account/authenticate`, { + data: { userName: 'ashtyn1', password: 'Pa$$word123' }, + }); + const { jwToken } = await tokenResponse.json(); + + // POST /api/v1/positions/AddMock with JSON body + // InsertMockPositionCommand generates positions with valid FK references + const response = await request.post(`${API_BASE}/api/v1/positions/AddMock`, { + headers: { Authorization: `Bearer ${jwToken}` }, + data: { RowCount: 25 }, + }); + + expect(response.status()).toBe(200); + + const result = await response.json(); + console.log(`Seeded ${result.data} positions`); + expect(result.succeeded).toBe(true); + }); + +}); +``` + +**`POST /api/v1/positions/AddMock`** — the actual route registered by the `[Route("AddMock")]` attribute on `PositionsController`. The endpoint is protected with `[Authorize]`, so a valid Bearer token is required. + +**`data: { RowCount: 25 }`** — Playwright serializes this as a JSON body. `InsertMockPositionCommand` binds from the request body (POST default). Property name matches the C# property: `RowCount` (Pascal case). + +**`result.data`** — the endpoint returns a `Result` wrapper with `{ succeeded: true, data: 25, ... }`, not a plain integer. Access the row count via `result.data`. + +### Seeding in `beforeAll` for a Test Suite + +When multiple tests in the same file need the seeded data, call the seed endpoint in `beforeAll` so it runs once before the suite rather than repeating it in every test: ```typescript -test.describe('Test group', () => { - test('seed', async ({ page }) => { - // generate code here. +// Tests/AngularNetTutorial-Playwright/tests/positions.spec.ts +import { test, expect } from '@playwright/test'; + +const API_BASE = process.env.API_URL ?? 'https://localhost:44378'; + +test.describe('Positions list', () => { + + test.beforeAll(async ({ request }) => { + // Acquire a Bearer token + const tokenResponse = await request.post(`${API_BASE}/api/v1/account/authenticate`, { + data: { userName: 'ashtyn1', password: 'Pa$$word123' }, + }); + const { jwToken } = await tokenResponse.json(); + + // Ensure at least 50 positions exist before any test in this suite runs + await request.post(`${API_BASE}/api/v1/positions/AddMock`, { + headers: { Authorization: `Bearer ${jwToken}` }, + data: { RowCount: 50 }, + }); }); + + test('shows positions in the table', async ({ page }) => { + await page.goto('/positions'); + await expect(page.getByRole('row')).toHaveCount.greaterThan(10); + }); + + test('pagination works with enough rows', async ({ page }) => { + await page.goto('/positions'); + await expect(page.getByTestId('next-page-btn')).toBeEnabled(); + }); + }); ``` -When an E2E test suite needs a specific dataset before running, this is where you'd implement it — call the `InsertMockPositionCommand` endpoint via the `request` fixture, or trigger a specific seed via the API. The file exists to remind you this pattern is available. +### Seeding in Global Setup + +For seeding that should run once before the **entire Playwright suite** — not just one file — use a `globalSetup` script. `playwright.config.ts` already has the hook point: + +```typescript +// playwright.config.ts +export default defineConfig({ + globalSetup: './global-setup.ts', + // ... +}); +``` + +```typescript +// Tests/AngularNetTutorial-Playwright/global-setup.ts +import { request } from '@playwright/test'; + +const API_BASE = process.env.API_URL ?? 'https://localhost:44378'; + +async function globalSetup() { + const apiContext = await request.newContext({ baseURL: API_BASE }); + + // Acquire a Bearer token using test credentials + const tokenResponse = await apiContext.post('/api/v1/account/authenticate', { + data: { userName: 'ashtyn1', password: 'Pa$$word123' }, + }); + if (!tokenResponse.ok()) { + throw new Error(`Auth failed: ${tokenResponse.status()} ${await tokenResponse.text()}`); + } + const { jwToken } = await tokenResponse.json(); + + // POST /api/v1/positions/AddMock with Bearer token and JSON body + const response = await apiContext.post('/api/v1/positions/AddMock', { + headers: { Authorization: `Bearer ${jwToken}` }, + data: { RowCount: 100 }, + }); + + if (!response.ok()) { + throw new Error(`Seed failed: ${response.status()} ${await response.text()}`); + } + + console.log('Global seed complete'); + await apiContext.dispose(); +} + +export default globalSetup; +``` + +`request.newContext()` in `globalSetup` creates a standalone API context outside of any test. The auth call acquires a short-lived JWT using the same test credentials used in browser-based tests. `await apiContext.dispose()` releases it when done. Throwing on either failure (auth or seed) fails the entire run immediately with a clear message rather than silently running tests against an unseeded database. + +### When to Use Each Approach + +* **`seed.spec.ts` standalone test** — run manually from Swagger or CI when you need to top up data in a shared environment +* **`beforeAll` in a spec file** — guarantee a data baseline for one specific test suite without affecting others +* **`globalSetup`** — guarantee the baseline for the entire run; appropriate when all tests depend on the same starting state --- -## 🎓 Summary +## 🔑 Key Design Decisions + +**`Faker` for seeding, `AutoFaker` for unit tests.** `DatabaseSeeder` uses `Faker` with explicit rules for every field — giving precise control over FK assignment and field-to-field dependencies. `EmployeeBogusConfig` uses `AutoFaker` which auto-fills unspecified fields — faster to write for unit test fixtures where exact values don't matter. + +**`.UseSeed()` makes generation deterministic.** Every developer who runs `dotnet run` gets the exact same 1,000 employees with the same names, emails, and FK assignments. Seed value `1969` is just a memorable constant — any integer works. Determinism is what makes automated tests reliable: if a test checks for a specific employee record, it can rely on that record always being there. -The AngularNetTutorial seeding system has four layers: +**FK generation order mirrors the dependency graph.** `Departments` and `SalaryRanges` are generated first because `Positions` references them, and `Employees` reference both `Positions` and `Departments`. Reversing this order would produce FK values pointing to records that don't exist yet. The constructor enforces the correct sequence. -* **Bogus `Faker`** — fluent rule definitions with `.UseSeed()` for reproducibility, `PickRandom()` for FK references, and two-parameter lambdas for field-to-field dependencies -* **`DatabaseSeeder`** — generates all four collections in dependency order (Departments → SalaryRanges → Positions → Employees) -* **`DbInitializer.SeedData()`** — one-liner that adds all collections and saves -* **`Program.cs`** — gates seeding to development only, respects `SkipDbSeed` flag, skips if data already exists +**`SkipDbSeed` flag enables clean-state testing.** Setting `"SkipDbSeed": true` in `appsettings.Development.json` starts the API with an empty database. This is useful for integration tests that need to control exactly what data exists, or for testing the "empty state" UI experience without deleting and recreating the database. -The result: `dotnet run` produces a fully populated application in three seconds. Every developer starts with the same 1,000 employees, and automated tests can rely on consistent seed data. +**Development-only seeding with an idempotency check.** The entire seed block is inside `if (app.Environment.IsDevelopment())` — production never auto-seeds. The `needsSeed` check (`!dbContext.Departments.Any() || !dbContext.Employees.Any()`) ensures the seeder only runs on an empty database, so restarting the API doesn't duplicate records. + +**`InsertMockPositionCommand` for runtime seeding.** The CQRS `POST /api/v1/positions/AddMock` endpoint lets you add more positions without restarting the API. It fetches existing departments and salary ranges from the database and passes them to `PositionInsertBogusConfig` — so every generated position has valid FK references to real rows. This pattern extends to any entity that has FK dependencies. + +--- ## 🌟 Why This Matters diff --git a/blogs/series-5-devops-data/5.2-cicd-github-actions.md b/blogs/series-5-devops-data/5.2-cicd-github-actions.md index e2ee93a..da24fc1 100644 --- a/blogs/series-5-devops-data/5.2-cicd-github-actions.md +++ b/blogs/series-5-devops-data/5.2-cicd-github-actions.md @@ -478,7 +478,7 @@ After a successful run, the Actions page shows: --- -## 💡 Key Patterns +## 🔑 Key Design Decisions **`fail-fast: false` with browser matrix.** Cross-browser bugs only surface when all browsers run. Cancelling Firefox because Chromium failed hides real issues. @@ -492,18 +492,6 @@ After a successful run, the Actions page shows: --- -## 🎓 Summary - -The GitHub Actions workflow provides three jobs for a complete CI pipeline: - -* **`test`** — matrix over Chromium/Firefox/WebKit, `npm ci`, browser install, `CI=true` Playwright run, artifact upload -* **`report`** — `dorny/test-reporter` publishes JUnit results as a GitHub Check -* **`comment-pr`** — `github-script` posts a browser status table to the PR - -`playwright.config.ts` adapts to CI automatically via `process.env.CI`: strict `forbidOnly`, two retries, single worker, and `open: 'never'` on the HTML reporter. - -The gap to close is service startup — adding `webServer` configuration or Docker Compose steps to start IdentityServer, the .NET API, and Angular before the test run. Once those services are live, the workflow runs the full OIDC-authenticated Playwright suite across all three browsers on every push and pull request. - ## 🌟 Why This Matters A Playwright test suite that only runs locally isn't a CI/CD test suite — it's a manual checklist. GitHub Actions with browser matrix, artifact upload, JUnit reporting, and PR comments turns the same tests into an automated quality gate that runs on every push. The `process.env.CI` adaptations — `forbidOnly`, `retries: 2`, `workers: 1` — make the same test suite stable in CI without changing a single test. diff --git a/blogs/series-5-devops-data/5.3-azure-subscription-setup.md b/blogs/series-5-devops-data/5.3-azure-subscription-setup.md new file mode 100644 index 0000000..ce104c0 --- /dev/null +++ b/blogs/series-5-devops-data/5.3-azure-subscription-setup.md @@ -0,0 +1,298 @@ +# Your First Azure Deployment: Setting Up a Visual Studio Subscription + +## Activate Your $50 Monthly Credit, Install the Azure CLI, and Understand What Fits in Your Budget + +Most Azure tutorials assume you already have an account, a subscription, and money to spend. If you have a Visual Studio Professional or Enterprise subscription, you already have a monthly Azure credit — and it's enough to run this entire three-tier application. You just have to activate it. + +This article walks through activating the benefit, setting a spending limit so you can never accidentally overspend, installing the Azure CLI and Bicep, and understanding exactly which Azure resources fit within a $50/month budget. + +![Azure Portal Dashboard](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. The full-stack tutorial — covering Angular 20, .NET 10 Web API, and OAuth 2.0 with Duende IdentityServer — has been published at [Building Modern Web Applications with Angular, .NET, and OAuth 2.0](https://medium.com/scrum-and-coke/building-modern-web-applications-with-angular-net-and-oauth-2-0-complete-tutorial-series-7ea97ed3fc56). **This article is the first in the Azure Deployment sub-series — it gets your Azure environment ready before any infrastructure is provisioned.** + +--- + +## 📚 What You'll Learn + +* **Visual Studio Azure benefit** — where the credit comes from and how to activate it +* **Spending limit** — the one setting that prevents accidental charges beyond your credit +* **Azure CLI** — how to install it and verify you're targeting the right subscription +* **Bicep CLI** — the infrastructure-as-code tool used in the next article +* **Cost profile** — what the full three-tier stack costs per month and why it fits in $50 + +--- + +## 📋 Prerequisites + +**Before following this article, you should have:** + +* **Visual Studio Professional or Enterprise subscription** — includes Azure monthly credit +* **Windows 10/11 or macOS** — Azure CLI runs on both +* **winget (Windows) or Homebrew (macOS)** — for CLI installation + +**Not sure if you have a Visual Studio subscription?** Check at [my.visualstudio.com](https://my.visualstudio.com). If your employer or school provides it, your subscription is listed there. + +--- + +## 🎯 The Problem + +Azure has a free trial, but it expires after 30 days and caps some services. If you have a Visual Studio Professional subscription, you have a better option: a monthly Azure credit that renews every month and never expires as long as your subscription is active. + +**Common pain points for first-time Azure users:** + +* **Credit vs. billing confusion** — the benefit doesn't automatically prevent charges above the credit limit unless you set a spending limit +* **Too many subscription types** — Pay-As-You-Go, Free Trial, and Visual Studio Dev Essentials all look similar in the portal but behave differently +* **Azure CLI not installed** — the remaining articles in this sub-series use `az` commands; the portal won't be enough +* **Wrong subscription selected** — if you have multiple subscriptions, CLI commands may target the wrong one + +--- + +## 💡 The Solution + +Activate the Visual Studio Azure benefit, set a $0 spending limit above the credit, install the tools, and verify you're pointing at the right subscription. This takes about 15 minutes and you only do it once. + +**What you get:** + +* ✅ **Visual Studio Professional** — $50/month Azure credit +* ✅ **Visual Studio Enterprise** — $150/month Azure credit +* ✅ **Credit renews monthly** — never expires while subscription is active +* ✅ **Spending limit prevents overruns** — services pause when credit runs out, no bill + +--- + +## 🚀 How It Works + +### Step 1: Check Your Visual Studio Subscription + +Go to [my.visualstudio.com/benefits](https://my.visualstudio.com/benefits) and log in with the account associated with your Visual Studio subscription. + +Look for the **Azure** tile under the "Tools" section. It shows your monthly credit amount: + +* **Professional** — $50/month +* **Enterprise** — $150/month +* **Dev Essentials** — $200 one-time free trial (not a monthly credit) + +If you do not see an Azure tile, your subscription level may not include the Azure benefit. Check [Visual Studio subscription comparison](https://visualstudio.microsoft.com/vs/compare/) to confirm. + +### Step 2: Activate the Azure Benefit + +Click **Activate** on the Azure tile at my.visualstudio.com/benefits. + +This redirects you to the Azure sign-up page. Use the **same Microsoft account** that holds your Visual Studio subscription — mixing accounts creates a separate, unlinked subscription. + +After activation, you land in the Azure Portal at [portal.azure.com](https://portal.azure.com). Your subscription appears in **Subscriptions** and has a name like `Visual Studio Professional`. + +**Wait for:** The subscription status to show **Active** before proceeding. + +### Step 3: Set a Spending Limit + +This is the most important step. By default, Azure pauses services when the monthly credit runs out — but the setting must be confirmed. + +In the Azure Portal: + +``` +Search bar → "Subscriptions" → select your Visual Studio subscription +→ "Cost Management" section → "Spending limit" +→ Confirm spending limit is ON (set to $0 above credit) +``` + +**What happens when credit runs out:** +* Services are suspended (not deleted) — you get an email warning first +* Everything restarts automatically when the new monthly credit is applied +* No credit card is charged unless you explicitly remove the spending limit + +> **Never remove the spending limit** unless you intend to pay beyond the credit. For this tutorial, the full stack costs approximately $23/month — well within the $50 credit. + +### Step 4: Install the Azure CLI + +The Azure CLI (`az`) is the command-line tool used throughout this sub-series to deploy resources, run Bicep templates, and configure app settings. + +**Windows (winget):** + +```bash +winget install Microsoft.AzureCLI +``` + +**macOS (Homebrew):** + +```bash +brew install azure-cli +``` + +**Verify the installation:** + +```bash +az version +``` + +**Expected output:** a JSON block showing the CLI version (2.60 or higher recommended). + +### Step 5: Log In to Azure + +```bash +az login +``` + +A browser window opens to the Microsoft login page. Sign in with the same account used to activate the Azure benefit. After login, the terminal lists your available subscriptions. + +**Expected output:** + +```json +[ + { + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "name": "Visual Studio Professional", + "state": "Enabled", + "isDefault": true + } +] +``` + +If `isDefault` is `true` for your Visual Studio subscription, you're ready. If you have multiple subscriptions and the wrong one is selected, set the correct one explicitly. + +### Step 6: Set the Target Subscription + +If you have more than one subscription, ensure every `az` command targets the Visual Studio subscription: + +```bash +# List all subscriptions +az account list --output table + +# Set the correct subscription by name +az account set --subscription "Visual Studio Professional" + +# Confirm the active subscription +az account show --query "{name:name, id:id, state:state}" --output table +``` + +**Why this matters:** Every Bicep deployment and role assignment in subsequent articles uses the active subscription. Targeting the wrong one wastes credit and may not have the right permissions. + +### Step 7: Install the Bicep CLI + +Bicep is the infrastructure-as-code language used in Article 5.4 to provision all Azure resources. It is included with Azure CLI 2.20 and later, but installing or updating it explicitly ensures you have the latest version: + +```bash +az bicep install + +# Verify +az bicep version +``` + +**Expected output:** `Bicep CLI version 0.28.x` or higher. + +### Step 8: Verify the Complete Setup + +Run these three commands to confirm everything is in place: + +```bash +# Azure CLI version +az version --query '"azure-cli"' --output tsv + +# Active subscription +az account show --query "{name:name, id:id}" --output table + +# Bicep version +az bicep version +``` + +All three should return values without errors. If `az account show` shows the wrong subscription, re-run Step 6. + +--- + +## 💻 Try It Yourself + +After completing the setup, explore your subscription from the terminal: + +```bash +# List resource groups (empty at this point — that's expected) +az group list --output table + +# Check available regions (useful for choosing where to deploy) +az account list-locations --query "[].{Name:name, DisplayName:displayName}" --output table | head -20 +``` + +No resources are provisioned yet. Article 5.4 writes the Bicep templates and runs the deployment command that creates all seven Azure resources at once. + +--- + +## 📊 Budget: What Fits in $50/Month + +The full three-tier stack uses these Azure resources. Here is the approximate monthly cost for the dev environment: + +**Compute:** + +* **App Service Plan B1** — ~$13.14/month (hosts both the .NET API and IdentityServer) +* Note: Azure charges at the App Service Plan level, not per Web App. Both apps share the plan at no additional cost. + +**Databases:** + +* **Azure SQL Basic tier (5 DTUs)** — ~$4.90/month per database +* Two databases (API + IdentityServer) — ~$9.80/month total + +**Frontend:** + +* **Azure Static Web Apps Free tier** — $0/month (built-in CDN and global distribution included) + +**Total estimate:** approximately **$23/month** — comfortably within the $50 Visual Studio Professional credit. + +**What would exceed the budget:** + +* Upgrading to B2 or B3 App Service Plan (doubles or quadruples the compute cost) +* Using Standard SQL tier instead of Basic (10× more expensive) +* Adding a second App Service Plan instead of sharing one +* Premium networking features (Private Link, VNet integration) + +The architecture in this sub-series is deliberately designed to stay within $23/month. Upgrading to a larger SQL tier or higher compute is straightforward later if the application grows. + +--- + +## 🔑 Key Design Decisions + +**Visual Studio subscription, not Pay-As-You-Go.** The VS subscription benefit provides a monthly credit with a spending limit guardrail. Pay-As-You-Go has no spending limit by default, which makes accidental overruns possible. For a development environment, the VS benefit is the safest starting point. + +**One subscription for dev.** Use a single Azure subscription for the dev environment. Do not mix dev and production resources in the same subscription — they share the credit and the spending limit. When a production environment is needed, a separate subscription (or a separate resource group under a paid subscription) keeps billing isolated. + +**Set the spending limit before deploying anything.** Provisioning resources before confirming the spending limit means any misconfiguration (leaving a resource running, choosing the wrong SKU) immediately starts consuming credit without a safety net. Confirm the limit first. + +**`az account set` before every session.** The active subscription persists between terminal sessions, but if you switch between subscriptions during the day, always confirm with `az account show` before running deployment commands. + +**eastus as the default region.** Azure pricing is consistent across major US regions (eastus, westus2, centralus). The articles in this sub-series use `eastus` as the default. Choose the region closest to your users for production. + +--- + +## 🌟 Why This Matters + +Getting the Azure environment right before writing a single line of infrastructure code prevents the most common source of frustration for first-time deployers: targeting the wrong subscription, hitting unexpected charges, or discovering a missing tool in the middle of a deployment. + +The Visual Studio Azure benefit is one of the most underused perks of a VS subscription. It provides a real Azure environment — not a sandbox with restricted services — that renews every month. The $50/month credit for Professional subscribers is enough to run a complete three-tier application stack indefinitely for development and demo purposes. + +**Transferable skills:** + +* **`az account set` + `az account show`** — the pattern for managing multiple subscriptions applies to any Azure project, not just this one +* **Spending limit strategy** — applicable to any team Azure account where you want cost guardrails during development +* **Bicep CLI installation** — a one-time prerequisite for any project that uses infrastructure-as-code + +--- + +## 🤝 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 #dotnet #angular #visualstudio #devops #clouddeployment #azurecli #bicep #infrastructure #webdevelopment #fullstack #csharp #typescript #oauth2 #identityserver diff --git a/blogs/series-5-devops-data/5.4-azure-bicep-infrastructure.md b/blogs/series-5-devops-data/5.4-azure-bicep-infrastructure.md new file mode 100644 index 0000000..ae3c308 --- /dev/null +++ b/blogs/series-5-devops-data/5.4-azure-bicep-infrastructure.md @@ -0,0 +1,385 @@ +# Infrastructure as Code: Provision All Azure Resources with One Bicep Command + +## From Empty Subscription to a Running App Service Plan, SQL Server, and Static Web App in Minutes + +Clicking through the Azure Portal to create seven resources manually is slow, error-prone, and impossible to reproduce. Miss a setting on the SQL Server firewall and the API can't connect to the database. Choose the wrong SKU on the App Service Plan and costs double. Do it again for a new environment and you do it from memory. + +Azure Bicep solves this. One file describes the desired state. One command creates everything. Run it again and Azure updates only what changed. The same template creates both a dev environment and a production environment identically. + +This article walks through the Bicep templates that provision the full Talent Management stack — App Service Plan, two Web Apps, a Static Web App, a shared SQL Server, and two databases — and runs the deployment against a real Azure subscription. + +![Azure Resources Created](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. The full-stack tutorial — covering Angular 20, .NET 10 Web API, and OAuth 2.0 with Duende IdentityServer — has been published at [Building Modern Web Applications with Angular, .NET, and OAuth 2.0](https://medium.com/scrum-and-coke/building-modern-web-applications-with-angular-net-and-oauth-2-0-complete-tutorial-series-7ea97ed3fc56). **This article provisions the Azure infrastructure for deploying the three-tier stack. Article 5.3 covers the subscription setup prerequisite.** + +--- + +## 📚 What You'll Learn + +* **What Bicep is** — declarative IaC that compiles to ARM JSON and deploys via Azure CLI +* **Module composition** — how `main.bicep` references four reusable modules +* **`@secure()` parameters** — how to pass SQL passwords without storing them in files +* **CAF naming convention** — why resources are named `app-talent-api-dev` and not `myapp1` +* **Bicep outputs** — how deployed URLs flow into GitHub Actions workflows +* **`az deployment group create`** — the one command that provisions all seven resources + +--- + +## 📋 Prerequisites + +**Before following this article, you should have:** + +* **Article 5.3 complete** — Azure CLI installed, logged in, correct subscription active +* **Resource group created** — `rg-talent-dev` in your target region +* **`SQL_ADMIN_PASSWORD` chosen** — a strong password, stored as an environment variable locally and as a GitHub Secret + +**Create the resource group now:** + +```bash +az group create \ + --name rg-talent-dev \ + --location eastus +``` + +--- + +## 🎯 The Problem + +The Azure Portal is a point-and-click interface. Every decision you make — which SKU, which region, which firewall rule — exists only in Azure, not in source control. If a teammate needs to set up the same environment, they repeat every click from memory. If you need a second environment, you start over. If a resource is accidentally deleted, there is no record of its original configuration. + +**Pain points with manual Portal deployment:** + +* **No repeatability** — no way to recreate an environment identically +* **No history** — changes to resources aren't tracked in git +* **No review** — no pull request for a resource configuration change +* **Slow** — creating seven resources manually takes 30+ minutes + +--- + +## 💡 The Solution + +Azure Bicep is a declarative language for defining Azure resources. You write what you want, not how to create it. Azure figures out the creation order, handles dependencies, and only updates what changed on subsequent runs. + +**Key benefits:** + +* ✅ **Reproducible** — same template creates identical environments every time +* ✅ **Version-controlled** — infrastructure changes go through pull requests +* ✅ **Idempotent** — running the same template twice is safe +* ✅ **Fast** — one command replaces 30 minutes of Portal clicking + +--- + +## 🚀 How It Works + +### The File Structure + +All infrastructure code lives in the `infra/` folder at the root of the repository: + +``` +infra/ +├── main.bicep ← entry point, composes all modules +├── modules/ +│ ├── appServicePlan.bicep ← B1 App Service Plan +│ ├── webApp.bicep ← reusable Web App (used for API and IdentityServer) +│ ├── staticWebApp.bicep ← Angular client, Free tier +│ └── sqlServer.bicep ← logical server + 2 databases +└── parameters/ + └── dev.bicepparam ← dev environment parameter values +``` + +### Step 1: Understand main.bicep + +`main.bicep` is the entry point. It declares all parameters, calls each module, and exposes outputs that downstream workflows use. + +```bicep +// infra/main.bicep + +@description('SQL administrator password — pass at deploy time, never in parameters file') +@secure() +param sqlAdminPassword string + +module appServicePlan 'modules/appServicePlan.bicep' = { + name: 'appServicePlan' + params: { + appServicePlanName: appServicePlanName + location: location + } +} + +module apiApp 'modules/webApp.bicep' = { + name: 'apiApp' + params: { + webAppName: apiAppName + location: location + appServicePlanId: appServicePlan.outputs.id + } +} + +// ... identityApp, angularSwa, sqlServer modules ... + +output apiAppUrl string = apiApp.outputs.url +output identityAppUrl string = identityApp.outputs.url +output angularAppUrl string = angularSwa.outputs.url +``` + +**`@secure()` on `sqlAdminPassword`** — Bicep marks this parameter as sensitive. The value is never written to deployment logs, never stored in the parameters file, and never appears in `az deployment group show` output. It is passed at deploy time from an environment variable. + +**Module calls reference each other's outputs.** `apiApp` receives `appServicePlan.outputs.id` as its `appServicePlanId`. Bicep resolves the dependency automatically — the App Service Plan is always created before the Web Apps. + +### Step 2: The App Service Plan Module + +```bicep +// infra/modules/appServicePlan.bicep + +resource appServicePlan 'Microsoft.Web/serverfarms@2023-01-01' = { + name: appServicePlanName + location: location + sku: { + name: 'B1' + tier: 'Basic' + } + properties: { + reserved: false // Windows (not Linux) + } +} + +output id string = appServicePlan.id +``` + +**B1 SKU** — the Basic B1 tier is the lowest dedicated (non-shared) App Service tier. It costs approximately $13/month and supports custom domains, always-on, and connection slots. It is the minimum appropriate for a real hosted application. The Free (F1) and Shared (D1) tiers are not suitable for production use. + +**One plan, two apps** — Azure charges at the App Service Plan level, not per Web App. Both the .NET API and IdentityServer run on this same plan with no additional cost. + +### Step 3: The Web App Module + +```bicep +// infra/modules/webApp.bicep + +resource webApp 'Microsoft.Web/sites@2023-01-01' = { + name: webAppName + location: location + properties: { + serverFarmId: appServicePlanId + httpsOnly: true + siteConfig: { + netFrameworkVersion: 'v10.0' + http20Enabled: true + minTlsVersion: '1.2' + ftpsState: 'Disabled' + } + } +} + +output url string = 'https://${webApp.properties.defaultHostName}' +``` + +**`httpsOnly: true`** — redirects all HTTP traffic to HTTPS. Non-negotiable for an authentication-aware application. + +**`ftpsState: 'Disabled'`** — disables FTP/FTPS deployment. GitHub Actions is the only deployment path — no FTP credentials to manage or leak. + +**`minTlsVersion: '1.2'`** — rejects TLS 1.0 and 1.1 connections. + +The same module is called twice — once for `apiAppName` and once for `identityAppName`. Reusable modules eliminate duplicated resource definitions. + +### Step 4: The Static Web App Module + +```bicep +// infra/modules/staticWebApp.bicep + +resource staticWebApp 'Microsoft.Web/staticSites@2023-01-01' = { + name: staticWebAppName + location: location + sku: { + name: 'Free' + tier: 'Free' + } + properties: { + buildProperties: { + skipGithubActionWorkflowGeneration: true + } + } +} + +output url string = 'https://${staticWebApp.properties.defaultHostname}' +``` + +**Free tier** — $0/month with built-in CDN, global distribution, custom domains, and managed TLS certificates. Purpose-built for SPAs like Angular. + +**`skipGithubActionWorkflowGeneration: true`** — prevents Azure from auto-generating a GitHub Actions workflow file. Article 5.7 writes a custom workflow that injects environment variables at build time. + +### Step 5: The SQL Server Module + +```bicep +// infra/modules/sqlServer.bicep + +resource sqlServer 'Microsoft.Sql/servers@2023-05-01-preview' = { + name: sqlServerName + location: location + properties: { + administratorLogin: sqlAdminLogin + administratorLoginPassword: sqlAdminPassword + minimalTlsVersion: '1.2' + } +} + +// Allow Azure services (App Service) to connect +resource allowAzureServices 'Microsoft.Sql/servers/firewallRules@2023-05-01-preview' = { + parent: sqlServer + name: 'AllowAllWindowsAzureIps' + properties: { + startIpAddress: '0.0.0.0' + endIpAddress: '0.0.0.0' + } +} + +resource apiDatabase 'Microsoft.Sql/servers/databases@2023-05-01-preview' = { + parent: sqlServer + name: apiDbName + sku: { + name: 'Basic' + tier: 'Basic' + capacity: 5 // 5 DTUs + } +} +``` + +**`AllowAllWindowsAzureIps`** — the special Azure firewall rule (`0.0.0.0` to `0.0.0.0`) allows connections from all Azure services, including App Service. This is required for the Web Apps to reach the SQL Server. + +**Basic 5-DTU tier** — approximately $4.90/month per database. Sufficient for a development and demo workload. EF Core migrations, seed data loads, and typical HR application queries run within this tier. Upgrade to Standard S1 or higher for production load. + +**Separate databases, shared server** — the API and IdentityServer each get their own database. EF Core migrations run independently. A problem in one database does not affect the other. The shared logical server keeps the cost of two databases at approximately $10/month combined rather than paying for two separate SQL Server instances. + +### Step 6: The Parameters File + +```bicep +// infra/parameters/dev.bicepparam + +using '../main.bicep' + +param appServicePlanName = 'asp-talent-b1-dev' +param apiAppName = 'app-talent-api-dev' +param identityAppName = 'app-talent-ids-dev' +param staticWebAppName = 'swa-talent-ui-dev' +param sqlServerName = 'sql-talent-dev' +param apiDbName = 'sqldb-talent-api-dev' +param identityDbName = 'sqldb-talent-ids-dev' +param sqlAdminLogin = 'sqladmin' + +// sqlAdminPassword is NOT here — pass it at deploy time +``` + +**CAF naming convention** — Cloud Adoption Framework abbreviations prefix each resource name: `asp-` for App Service Plan, `app-` for Web App, `swa-` for Static Web App, `sql-` for SQL Server, `sqldb-` for SQL database. The pattern is `{type}-{workload}-{qualifier}-{env}`. This makes resource names self-documenting in the Portal and in cost reports. + +**`sqlAdminPassword` omitted** — the `@secure()` parameter has no value in the file. It is passed separately at deploy time. + +### Step 7: Deploy + +Set your SQL admin password as an environment variable (never hardcode it): + +```bash +export SQL_ADMIN_PASSWORD="YourStr0ngP@ssw0rd!" +``` + +Create the resource group if not done yet: + +```bash +az group create \ + --name rg-talent-dev \ + --location eastus +``` + +Deploy all seven resources: + +```bash +az deployment group create \ + --resource-group rg-talent-dev \ + --template-file infra/main.bicep \ + --parameters infra/parameters/dev.bicepparam \ + --parameters sqlAdminPassword="$SQL_ADMIN_PASSWORD" +``` + +**Wait for:** `"provisioningState": "Succeeded"` in the output. The deployment typically completes in 3–5 minutes. + +### Step 8: Verify the Outputs + +After deployment, retrieve the URLs of all provisioned resources: + +```bash +az deployment group show \ + --resource-group rg-talent-dev \ + --name main \ + --query properties.outputs \ + --output table +``` + +The outputs show the URLs for the API, IdentityServer, and Angular apps. Save these — they are needed in Article 5.8 when configuring IdentityServer redirect URIs and API CORS settings. + +--- + +## 💻 Try It Yourself + +After deployment, verify each resource exists in the Portal: + +```bash +# List all resources in the dev resource group +az resource list \ + --resource-group rg-talent-dev \ + --output table +``` + +You should see seven resources: one App Service Plan, two Web Apps, one Static Site, one SQL Server, and two SQL Databases. + +Navigate to each Web App in the Portal (`portal.azure.com → App Services → app-talent-api-dev`) and confirm the URL opens — it will show a default "Your web app is running" page until the .NET application is deployed in Article 5.6. + +--- + +## 🔑 Key Design Decisions + +**One shared App Service Plan for both .NET apps.** Azure charges at the App Service Plan level, not per Web App. Running two Web Apps on one B1 plan costs the same as running one Web App on that plan. Separate plans would double the compute cost for no benefit at this scale. + +**`@secure()` for SQL password — never in the parameters file.** Bicep's `@secure()` decorator marks a parameter as sensitive. The value is excluded from deployment logs and history. Passing it via `--parameters sqlAdminPassword="$SQL_ADMIN_PASSWORD"` at deploy time, sourced from an environment variable or GitHub Secret, ensures the password never touches source control. + +**Bicep outputs over hardcoded URLs.** `main.bicep` outputs `apiAppUrl`, `identityAppUrl`, and `angularAppUrl`. GitHub Actions workflows read these outputs instead of hardcoding URLs — if the resource name changes, the URLs update automatically. + +**`skipGithubActionWorkflowGeneration: true` on Static Web App.** Azure would auto-generate a GitHub Actions file and commit it to the repository. The custom workflow in Article 5.7 handles environment variable injection that the auto-generated workflow cannot. Skipping auto-generation prevents a generated workflow from conflicting with the custom one. + +**Basic 5-DTU SQL tier for dev.** The cheapest SQL tier that runs real workloads without the connection limits of the free tier. Approximately $4.90/month per database. EF Core migrations, seed data, and the IdentityServer persisted grant table all fit within this tier for development usage. + +--- + +## 🌟 Why This Matters + +Infrastructure as Code is not just a DevOps practice — it is documentation. Every resource configuration, every SKU choice, every firewall rule is in source control and can be reviewed, questioned, and approved through a pull request. The Bicep files in this article are the complete specification of what is running in Azure. + +The module pattern — one reusable `webApp.bicep` used for both the API and IdentityServer — demonstrates a core Bicep principle: parameterize what varies, fix what doesn't. Adding a third Web App to this infrastructure requires one additional module call and one additional parameter. The module itself does not change. + +**Transferable skills:** + +* **Bicep module composition** — applicable to any Azure project; the same pattern of `main.bicep` + modules scales to 50-resource enterprise deployments +* **`@secure()` parameters** — the correct way to handle any secret in any Bicep template +* **CAF naming convention** — the standard naming pattern across every Azure project at every company that follows Microsoft's well-architected framework + +--- + +## 🤝 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 #bicep #infrastructureascode #dotnet #angular #devops #azurecli #appservice #azuresql #staticwebapps #clouddeployment #github #webdevelopment #fullstack #csharp diff --git a/blogs/series-5-devops-data/5.5-azure-oidc-github-actions.md b/blogs/series-5-devops-data/5.5-azure-oidc-github-actions.md new file mode 100644 index 0000000..dccb096 --- /dev/null +++ b/blogs/series-5-devops-data/5.5-azure-oidc-github-actions.md @@ -0,0 +1,336 @@ +# Secure CI/CD: Connect GitHub Actions to Azure Without Passwords + +## One Script Wires Up Passwordless Deployment Using OIDC Federated Identity + +Every CI/CD tutorial eventually tells you to paste an Azure credential into GitHub Secrets. It works — until you forget to rotate it, until someone with access to the secret leaves the team, until it leaks in a log file, or until it simply expires at the worst moment. Stored secrets are a liability. + +GitHub Actions and Azure both support a better model: OIDC (OpenID Connect) federated identity. GitHub issues a short-lived cryptographic token for each job. Azure trusts that token if it comes from the right repository and branch. No password is ever created. Nothing expires. Nothing leaks. + +This article walks through the `infra/scripts/setup-oidc.sh` script that wires up this trust relationship in one run, then explains exactly how GitHub Actions uses the resulting secrets in every subsequent workflow. + +![Azure OIDC GitHub Actions](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. The full-stack tutorial — covering Angular 20, .NET 10 Web API, and OAuth 2.0 with Duende IdentityServer — has been published at [Building Modern Web Applications with Angular, .NET, and OAuth 2.0](https://medium.com/scrum-and-coke/building-modern-web-applications-with-angular-net-and-oauth-2-0-complete-tutorial-series-7ea97ed3fc56). **This article sets up the Azure-GitHub trust relationship used by every deployment workflow in Articles 5.6 and 5.7.** + +--- + +## 📚 What You'll Learn + +* **Why OIDC is better than stored secrets** — short-lived tokens, no rotation, no leakage risk +* **The four Azure objects the script creates** — App Registration, Service Principal, Federated Credential, role assignment +* **What each line of `setup-oidc.sh` does** — step-by-step walkthrough +* **The four GitHub Secrets** — three written automatically, one added manually +* **How workflows use the secrets** — the `azure/login` action pattern + +--- + +## 📋 Prerequisites + +**Before running this script, you should have:** + +* **Article 5.3 complete** — Azure CLI installed, logged in, correct subscription active +* **Article 5.4 complete** — `rg-talent-dev` resource group exists +* **GitHub CLI installed** — the script uses `gh secret set` to write secrets + +**Install the GitHub CLI:** + +```bash +# Windows (winget) +winget install GitHub.cli + +# macOS (Homebrew) +brew install gh +``` + +**Log in to the GitHub CLI:** + +```bash +gh auth login +``` + +Follow the interactive prompts — choose GitHub.com, HTTPS, and authenticate via browser. After login, verify: + +```bash +gh auth status +``` + +**Expected output:** `Logged in to github.com as ` + +--- + +## 🎯 The Problem + +The traditional approach to authenticating GitHub Actions with Azure is to create a Service Principal, generate a client secret, and paste that secret into GitHub repository settings as `AZURE_CREDENTIALS`. This works but has serious drawbacks: + +* **Secrets expire** — Azure client secrets have a maximum lifetime of 2 years; workflows break silently when they expire +* **Secrets can leak** — anyone with repository admin access can read the secret; it can appear in workflow logs if accidentally echoed +* **Secrets require rotation** — rotating a secret means updating it in both Azure and GitHub; easy to forget +* **Audit trail is weak** — Azure logs show which app made a call but not which workflow or job + +--- + +## 💡 The Solution + +OIDC federated identity replaces the stored secret with a trust relationship. Instead of "here is a password that proves who I am," GitHub Actions says "here is a signed token from GitHub's identity provider — Azure, you already agreed to trust tokens that look like this." Azure validates the token signature and the claims inside it (which repository, which branch, which job), then issues a short-lived access token for that job only. + +**What this means in practice:** + +* ✅ **No password ever created** — nothing to rotate, nothing to leak, nothing to expire +* ✅ **Scoped to exact repo and branch** — a token from a fork or a different branch is rejected +* ✅ **Job-scoped access** — the access token expires when the job finishes (typically 5 minutes) +* ✅ **Full audit trail** — Azure logs show the exact GitHub Actions workflow run that made each API call + +--- + +## 🚀 How It Works + +### Understanding the Trust Chain + +Before running the script, it helps to understand the three entities involved: + +**GitHub:** Issues a signed JWT (JSON Web Token) to each workflow job. The token includes claims: the repository name, the branch, the workflow name, the run ID. GitHub signs it with a private key. + +**Azure Entra ID (formerly Azure Active Directory):** The identity provider for Azure. It stores the trust configuration — "I will accept tokens from GitHub, but only for this specific repository and branch." + +**The Federated Identity Credential:** The entry in Azure Entra ID that says "GitHub's identity provider at `https://token.actions.githubusercontent.com` is trusted for tokens with subject `repo:org/repo:ref:refs/heads/main`." + +When a GitHub Actions job runs, it requests a token from GitHub, presents it to Azure, Azure validates the claims, and returns a short-lived access token scoped to the resources the App Registration is authorized to manage. + +### Step 1: Edit the Configuration Block + +Open `infra/scripts/setup-oidc.sh` and update the six variables at the top: + +```bash +APP_NAME="github-actions-talent-dev" # Display name in Azure Entra ID +RESOURCE_GROUP="rg-talent-dev" # Resource group to manage +LOCATION="eastus" # Azure region +GITHUB_ORG="workcontrolgit" # Your GitHub username or org +GITHUB_REPO="AngularNetTutorial" # Your repository name +BRANCH="main" # Branch that triggers deployments +``` + +**`APP_NAME`** — this is the display name of the App Registration in Azure Entra ID. It appears in the Azure Portal under "App registrations" and in audit logs. Use a name that makes the purpose clear. + +**`BRANCH`** — the federated credential is scoped to this exact branch. Tokens from `develop`, pull request branches, or forks will not be trusted. For this tutorial, `main` is the deployment branch. Article 5.6 shows how to trigger on pushes to `main`. + +### Step 2: Run the Script + +```bash +chmod +x infra/scripts/setup-oidc.sh +./infra/scripts/setup-oidc.sh +``` + +The script prints its progress as it runs. The full execution takes approximately 30 seconds. + +### Step 3: What the Script Does — Line by Line + +**Step 1: Create the App Registration** + +```bash +APP_ID=$(az ad app create \ + --display-name "$APP_NAME" \ + --query appId \ + --output tsv) +``` + +An App Registration is the identity in Azure Entra ID. It is not a user account — it represents the application (GitHub Actions) that will act on behalf of a deployment pipeline. The `appId` is the client ID that GitHub Actions will use to identify itself to Azure. + +**Step 2: Create the Service Principal** + +```bash +az ad sp create --id "$APP_ID" --output none +``` + +The App Registration is the identity definition. The Service Principal is the instance of that identity in your Azure tenant. Role assignments (permissions) are attached to the Service Principal. An App Registration without a Service Principal cannot be granted access to any Azure resource. + +**Step 3: Add the Federated Identity Credential** + +```bash +az ad app federated-credential create \ + --id "$APP_ID" \ + --parameters '{ + "name": "github-actions-branch-main", + "issuer": "https://token.actions.githubusercontent.com", + "subject": "repo:workcontrolgit/AngularNetTutorial:ref:refs/heads/main", + "audiences": ["api://AzureADTokenExchange"] + }' +``` + +This is the trust configuration. It tells Azure Entra ID: + +* **`issuer`** — tokens must be signed by GitHub's OIDC identity provider +* **`subject`** — tokens must claim to be from exactly `repo:workcontrolgit/AngularNetTutorial:ref:refs/heads/main` — no other repo, no other branch +* **`audiences`** — the intended audience must be `api://AzureADTokenExchange` (Azure's OIDC exchange endpoint) + +A token that matches all three conditions will be accepted. A token from a fork, a pull request branch, or a different repository is rejected by subject mismatch — even if it was legitimately signed by GitHub. + +**Step 4a: Create the Resource Group** + +```bash +az group create \ + --name "$RESOURCE_GROUP" \ + --location "$LOCATION" +``` + +This is idempotent — running it when the group already exists is safe. It ensures the resource group exists before the role assignment is scoped to it. + +**Step 4b: Grant the Contributor Role** + +```bash +SUBSCRIPTION_ID=$(az account show --query id --output tsv) +SCOPE="/subscriptions/${SUBSCRIPTION_ID}/resourceGroups/${RESOURCE_GROUP}" + +az role assignment create \ + --assignee "$APP_ID" \ + --role "Contributor" \ + --scope "$SCOPE" +``` + +**Scoped to the resource group, not the subscription.** The Contributor role allows creating, updating, and deleting resources — but only within `rg-talent-dev`. The deployment identity cannot touch any other resource group, cannot create new subscriptions, and cannot modify Azure Entra ID. This is the principle of least privilege. + +**Step 6: Save Secrets to GitHub** + +```bash +gh secret set AZURE_CLIENT_ID --body "$APP_ID" +gh secret set AZURE_TENANT_ID --body "$TENANT_ID" +gh secret set AZURE_SUBSCRIPTION_ID --body "$SUBSCRIPTION_ID" +``` + +The three values needed by the `azure/login` action are written directly to the repository's GitHub Secrets using the GitHub CLI. No manual copy-pasting. These values are not sensitive in themselves — they are not passwords — but storing them as secrets keeps workflows clean and avoids hardcoding tenant-specific values. + +### Step 4: Add the SQL Password Secret Manually + +The script ends with a reminder: + +```bash +gh secret set SQL_ADMIN_PASSWORD --repo workcontrolgit/AngularNetTutorial +``` + +Run this command and type the SQL admin password when prompted. This is the one secret that must remain a secret — it is the password for the Azure SQL Server administrator account. It is intentionally not written to the parameters file (as shown in Article 5.4) and not passed on the command line in any log-visible way. + +### Step 5: Verify All Four Secrets + +Navigate to your repository on GitHub: + +``` +GitHub → Settings → Secrets and variables → Actions +``` + +You should see four repository secrets: + +* **`AZURE_CLIENT_ID`** — the App Registration's application ID +* **`AZURE_TENANT_ID`** — your Azure Entra ID tenant ID +* **`AZURE_SUBSCRIPTION_ID`** — your Azure subscription ID +* **`SQL_ADMIN_PASSWORD`** — the SQL Server administrator password + +None of these values are visible after they are saved — GitHub shows only the secret name, not the value. This is expected behavior. + +### Step 6: How Workflows Use These Secrets + +Every deployment workflow in Articles 5.6 and 5.7 starts with this block: + +```yaml +permissions: + id-token: write # Required: allows the job to request an OIDC token from GitHub + contents: read + +steps: + - name: Log in to Azure + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} +``` + +**`permissions: id-token: write`** — this permission is required. Without it, GitHub will not issue an OIDC token to the job. It is scoped to the job level — only jobs that declare this permission can use OIDC. + +**`azure/login@v2`** — the official Microsoft action that: +1. Requests an OIDC token from GitHub (short-lived JWT, expires when job ends) +2. Presents the token to Azure Entra ID along with the client ID and tenant ID +3. Azure validates the token against the Federated Identity Credential (checks issuer, subject, audience) +4. Azure returns a short-lived access token (valid for ~5 minutes) +5. The action configures the Azure CLI in subsequent steps with that access token + +After `azure/login`, any `az` command in subsequent steps runs with the Contributor permissions scoped to `rg-talent-dev` — exactly as configured by the setup script. + +--- + +## 💻 Try It Yourself + +After running the script, verify the App Registration exists in the Azure Portal: + +``` +portal.azure.com → Microsoft Entra ID → App registrations → All applications +``` + +Find `github-actions-talent-dev`. Click it and navigate to: + +``` +Certificates & secrets → Federated credentials +``` + +You should see `github-actions-branch-main` with issuer `token.actions.githubusercontent.com`. + +To verify the role assignment: + +```bash +az role assignment list \ + --resource-group rg-talent-dev \ + --output table +``` + +The output should include a row with `Contributor` assigned to `github-actions-talent-dev`. + +--- + +## 🔑 Key Design Decisions + +**OIDC over stored client secrets.** A client secret is a long-lived password — it must be rotated before it expires, stored securely, and distributed to every system that uses it. An OIDC federated credential is a trust configuration — it has no expiry, nothing to rotate, and nothing to distribute. The trade-off is setup complexity (one-time script vs. copy-pasting a secret), but the ongoing operational cost is zero. + +**Resource group scope, not subscription scope.** Granting `Contributor` at the subscription level would allow the deployment identity to create, modify, or delete any resource in the subscription — including the spending-limit configuration. Scoping to `rg-talent-dev` limits the blast radius: even a compromised token can only affect resources in that resource group. + +**Branch-specific federated credential.** The `subject` claim in the federated credential locks trust to `refs/heads/main`. A pull request branch, a feature branch, or a fork cannot obtain an Azure access token using this credential. This prevents a malicious pull request from running arbitrary Azure CLI commands against the subscription. + +**GitHub CLI (`gh`) for secret management.** Using `gh secret set` in the script eliminates manual copy-pasting. Manual steps introduce transcription errors and require the person running the script to have browser access to GitHub repository settings. The script runs end-to-end from the terminal with no manual steps except the SQL password. + +**`SQL_ADMIN_PASSWORD` added manually.** The SQL admin password is a genuine secret — a value that must remain confidential. The three OIDC values (`AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, `AZURE_SUBSCRIPTION_ID`) are not sensitive in the same way: knowing them without the federated credential is harmless. The SQL password is kept separate and typed interactively, never written to a file or echoed in a terminal. + +--- + +## 🌟 Why This Matters + +The OIDC pattern used here is not Azure-specific. The same approach works for AWS (OIDC with IAM), Google Cloud (Workload Identity Federation), and any other cloud provider that supports OpenID Connect. The underlying concept — replace long-lived credentials with short-lived, cryptographically verifiable tokens — is the direction the industry is moving for all non-human identities. + +For this tutorial, the immediate benefit is that Articles 5.6 and 5.7 can deploy to Azure using `azure/login` without managing any passwords. The longer-term benefit is that new developers joining the project do not need to be handed any credentials — the deployment identity is entirely self-contained in Azure and GitHub configuration. + +**Transferable skills:** + +* **Federated Identity Credentials** — the same Azure Entra ID concept applies to any workload that needs passwordless Azure access (GitHub Actions, Azure Pipelines, Kubernetes pods, on-premises servers) +* **`az role assignment create --scope`** — the pattern for least-privilege role grants applies to every Azure deployment, not just this one +* **`permissions: id-token: write`** — required for any GitHub Actions OIDC integration, regardless of cloud provider + +--- + +## 🤝 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 #github #githubactions #oidc #cicd #devops #dotnet #angular #security #azurecli #federatedidentity #secretsmanagement #clouddeployment #webdevelopment #fullstack diff --git a/blogs/series-5-devops-data/5.6-azure-deploy-dotnet-apps.md b/blogs/series-5-devops-data/5.6-azure-deploy-dotnet-apps.md new file mode 100644 index 0000000..46b37e4 --- /dev/null +++ b/blogs/series-5-devops-data/5.6-azure-deploy-dotnet-apps.md @@ -0,0 +1,353 @@ +# Deploy .NET API and IdentityServer to Azure App Service + +## Automated Build, Test, and Deploy on Every Push to Main + +A deployment that requires a developer to open a terminal, remember the right commands, and hope nothing changed since last time is a deployment waiting to fail. GitHub Actions replaces the manual process with a repeatable workflow: push to `main`, the pipeline runs, and the application lands in Azure. + +This article writes two GitHub Actions workflows — one for the .NET API, one for IdentityServer — that restore, build, test, publish, and deploy automatically. It also covers the App Service application settings that connect each app to the Azure SQL database and to each other. + +![Deploy .NET Apps to Azure](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. The full-stack tutorial — covering Angular 20, .NET 10 Web API, and OAuth 2.0 with Duende IdentityServer — has been published at [Building Modern Web Applications with Angular, .NET, and OAuth 2.0](https://medium.com/scrum-and-coke/building-modern-web-applications-with-angular-net-and-oauth-2-0-complete-tutorial-series-7ea97ed3fc56). **This article deploys the backend. Article 5.7 deploys the Angular frontend. Article 5.5 set up the Azure authentication used here.** + +--- + +## 📚 What You'll Learn + +* **Workflow structure** — how `on: push: paths:` triggers only the relevant workflow +* **`submodules: recursive`** — why checkout must include submodules for a monorepo +* **`dotnet publish`** — what the publish output contains and why it differs from the build output +* **`azure/webapps-deploy`** — the action that uploads and hot-swaps the deployment +* **App Service settings** — how connection strings and URLs reach the running app without touching `appsettings.json` +* **Deployment order** — why IdentityServer must be deployed and running before the API + +--- + +## 📋 Prerequisites + +**Before following this article, you should have:** + +* **Article 5.4 complete** — Azure resources provisioned (`app-talent-api-dev`, `app-talent-ids-dev`) +* **Article 5.5 complete** — Four GitHub Secrets set: `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, `AZURE_SUBSCRIPTION_ID`, `SQL_ADMIN_PASSWORD` +* **Additional secrets to add** — listed in Step 2 below + +--- + +## 🎯 The Problem + +The .NET API and IdentityServer each need several environment-specific values at runtime: the database connection string, the URL of IdentityServer (which the API uses to validate tokens), and the URL of the API itself (which IdentityServer uses to register the audience). These values differ between local development and Azure. Hardcoding them in `appsettings.json` would commit environment-specific secrets to source control and break the principle that the same build artifact runs in every environment. + +Manual deployment — `dotnet publish`, zip the output, upload via the Portal — is error-prone and produces no audit trail. Running it inconsistently across developers produces inconsistent results. + +--- + +## 💡 The Solution + +GitHub Actions workflows are triggered by pushes to specific paths. When code changes in `ApiResources/TalentManagement-API/`, the API workflow runs. When code changes in `TokenService/Duende-IdentityServer/`, the IdentityServer workflow runs. Each workflow builds the app, runs tests, publishes the binary, logs into Azure using OIDC (no stored password), injects configuration as App Service settings, and deploys. + +App Service application settings are the Azure equivalent of environment variables. They override values in `appsettings.json` at runtime without touching the committed file. The same published binary runs locally (using `appsettings.json`) and in Azure (using App Service settings that override the file). + +--- + +## 🚀 How It Works + +### Step 1: Add the Remaining GitHub Secrets + +Articles 5.5 set up four secrets. Two workflows need several more: + +**Navigate to:** GitHub → Repository → Settings → Secrets and variables → Actions → New repository secret + +**Secrets to add:** + +* **`API_DB_CONNECTION_STRING`** — the Azure SQL connection string for the API database + +``` +Server=tcp:sql-talent-dev.database.windows.net,1433;Initial Catalog=sqldb-talent-api-dev;Persist Security Info=False;User ID=sqladmin;Password=;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30; +``` + +* **`IDS_DB_CONNECTION_STRING`** — the Azure SQL connection string for the IdentityServer database + +``` +Server=tcp:sql-talent-dev.database.windows.net,1433;Initial Catalog=sqldb-talent-ids-dev;Persist Security Info=False;User ID=sqladmin;Password=;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30; +``` + +* **`IDENTITY_SERVER_URL`** — the HTTPS URL of the deployed IdentityServer App Service + +``` +https://app-talent-ids-dev.azurewebsites.net +``` + +* **`JWT_KEY`** — the symmetric key used by the API's local JWT authentication (copy from `appsettings.json` → `JWTSettings.Key`) + +**Retrieve the connection strings from the Bicep outputs:** + +```bash +az deployment group show \ + --resource-group rg-talent-dev \ + --name main \ + --query properties.outputs \ + --output table +``` + +### Step 2: Understand the Workflow File Structure + +Both workflows live in `.github/workflows/` in the parent repository. They follow the same pattern: + +``` +.github/workflows/ +├── deploy-api.yml ← triggers on ApiResources/ changes +└── deploy-identityserver.yml ← triggers on TokenService/ changes +``` + +### Step 3: The API Workflow + +The complete workflow is at `.github/workflows/deploy-api.yml`. Walk through its key sections: + +**Trigger:** + +```yaml +on: + push: + branches: + - main + paths: + - 'ApiResources/TalentManagement-API/**' + - '.github/workflows/deploy-api.yml' + workflow_dispatch: +``` + +**`paths:`** — the workflow only runs when a push to `main` changes files under `ApiResources/TalentManagement-API/` or the workflow file itself. Pushing only Angular changes does not trigger the API deployment. This is critical for a monorepo where multiple components share one repository. + +**`workflow_dispatch:`** — allows manually triggering the workflow from the GitHub UI or CLI. Useful for redeploying without making a code change. + +**OIDC permissions:** + +```yaml +permissions: + id-token: write # allows the job to request an OIDC token from GitHub + contents: read +``` + +Without `id-token: write`, GitHub will not issue an OIDC token and `azure/login` will fail. This permission is declared at the workflow level and applies to all jobs. + +**Checkout with submodules:** + +```yaml +- name: Checkout repository (with submodules) + uses: actions/checkout@v4 + with: + submodules: recursive +``` + +The API code lives in the `ApiResources/TalentManagement-API` submodule. Without `submodules: recursive`, the checkout produces an empty directory and `dotnet restore` fails immediately. The `recursive` flag initializes all nested submodules. + +**Build and test:** + +```yaml +- name: Restore dependencies + run: dotnet restore ${{ env.SOLUTION_PATH }} + +- name: Build + run: dotnet build ${{ env.SOLUTION_PATH }} --configuration Release --no-restore + +- name: Run unit tests + run: dotnet test ${{ env.SOLUTION_PATH }} --configuration Release --no-build --verbosity normal +``` + +Tests run against the Release build. `--no-build` and `--no-restore` reuse the artifacts from the previous steps — the build does not run twice. + +**Publish:** + +```yaml +- name: Publish + run: | + dotnet publish ${{ env.PROJECT_PATH }} \ + --configuration Release \ + --no-build \ + --output ${{ env.PUBLISH_DIR }} +``` + +`dotnet publish` produces a self-contained deployable directory: compiled DLLs, static files, `appsettings.json`, and the runtime host. This is what gets deployed to App Service — not the source code, not the intermediate build output. + +**OIDC login:** + +```yaml +- name: Log in to Azure + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} +``` + +This is the OIDC exchange described in Article 5.5. After this step, all subsequent `az` commands run with the Contributor role scoped to `rg-talent-dev`. + +**Configure App Service settings:** + +```yaml +- name: Configure App Service settings + run: | + az webapp config appsettings set \ + --resource-group ${{ env.RESOURCE_GROUP }} \ + --name ${{ env.APP_SERVICE_NAME }} \ + --settings \ + "ConnectionStrings__DefaultConnection=${{ secrets.API_DB_CONNECTION_STRING }}" \ + "Sts__ServerUrl=${{ secrets.IDENTITY_SERVER_URL }}" \ + "Sts__ValidIssuer=${{ secrets.IDENTITY_SERVER_URL }}" \ + "Sts__Audience=app.api.talentmanagement" \ + "JWTSettings__Key=${{ secrets.JWT_KEY }}" \ + "ASPNETCORE_ENVIRONMENT=Production" +``` + +App Service uses double underscores (`__`) to represent the `:` separator in .NET configuration keys. `ConnectionStrings__DefaultConnection` maps to `ConnectionStrings:DefaultConnection` in the .NET configuration system, which in turn maps to `appsettings.json`'s `ConnectionStrings.DefaultConnection`. This is the standard pattern for hierarchical configuration in Azure App Service. + +`ASPNETCORE_ENVIRONMENT=Production` causes ASP.NET Core to merge `appsettings.Production.json` (if it exists) on top of `appsettings.json`, then apply App Service settings on top of that. App Service settings always win — they cannot be overridden by a file in the deployment package. + +**Deploy:** + +```yaml +- name: Deploy to Azure App Service + uses: azure/webapps-deploy@v3 + with: + app-name: ${{ env.APP_SERVICE_NAME }} + package: ${{ env.PUBLISH_DIR }} +``` + +`azure/webapps-deploy` compresses the publish directory, uploads it to App Service, and performs a hot-swap deployment. The app restarts with the new code. The action waits until the deployment completes and the health check passes before reporting success. + +### Step 4: The IdentityServer Workflow + +The IdentityServer workflow at `.github/workflows/deploy-identityserver.yml` follows the same pattern. Key differences: + +**Trigger paths:** + +```yaml +paths: + - 'TokenService/Duende-IdentityServer/**' + - '.github/workflows/deploy-identityserver.yml' +``` + +IdentityServer changes trigger this workflow; API changes do not. + +**App Service settings for IdentityServer:** + +```yaml +"ConnectionStrings__ConfigurationDbConnection=${{ secrets.IDS_DB_CONNECTION_STRING }}" +"ConnectionStrings__PersistedGrantDbConnection=${{ secrets.IDS_DB_CONNECTION_STRING }}" +"ConnectionStrings__IdentityDbConnection=${{ secrets.IDS_DB_CONNECTION_STRING }}" +"AdminConfiguration__IdentityServerBaseUrl=${{ secrets.IDENTITY_SERVER_URL }}" +``` + +IdentityServer uses three different connection string keys — `ConfigurationDbConnection` (clients, scopes), `PersistedGrantDbConnection` (tokens, sessions), and `IdentityDbConnection` (user accounts). All three point to the same Azure SQL database (`sqldb-talent-ids-dev`) in this tutorial. In production, separating them is common. + +### Step 5: Deployment Order + +**IdentityServer must be deployed and running before the API.** + +The API validates every incoming request bearer token against IdentityServer's discovery endpoint (`/.well-known/openid-configuration`). If IdentityServer is not reachable, the API fails to start or fails all authenticated requests. + +**Correct deployment order:** + +1. Deploy IdentityServer → wait for it to start → verify `https://app-talent-ids-dev.azurewebsites.net/.well-known/openid-configuration` returns JSON +2. Deploy the API → the `Sts__ServerUrl` setting points to the running IdentityServer + +In GitHub Actions, you can enforce this with workflow dependencies if both deploy in the same workflow run. For the path-filtered workflows in this article, the safest approach is to deploy IdentityServer first by pushing those changes first, then push API changes. + +### Step 6: Verify the Deployments + +After both workflows complete: + +**Verify IdentityServer:** + +```bash +curl https://app-talent-ids-dev.azurewebsites.net/.well-known/openid-configuration +``` + +Expected: a JSON object with `issuer`, `authorization_endpoint`, `token_endpoint`, and other discovery metadata. + +**Verify the API:** + +```bash +curl https://app-talent-api-dev.azurewebsites.net/api/v1/health +``` + +Or open the Swagger UI: + +``` +https://app-talent-api-dev.azurewebsites.net/swagger +``` + +The Swagger UI loads if the app started correctly. Click "Authorize", enter a Bearer token obtained from IdentityServer, and call a protected endpoint to verify the full auth flow. + +**Check deployment logs in GitHub:** + +Navigate to GitHub → Actions → the workflow run → click the job → expand each step to see output. Failed steps show the exact error — a misconfigured connection string, a missing secret, or a test failure. + +--- + +## 💻 Try It Yourself + +Make a trivial change to the API (add a comment to any file) and push to `main`: + +```bash +# In the ApiResources submodule +git checkout main +echo "# deployment test" >> TalentManagementAPI.WebApi/appsettings.json +git add . && git commit -m "Test API deployment workflow" +git push +``` + +Then go to GitHub → Actions and watch the `Deploy .NET API to Azure App Service` workflow run. The full pipeline — restore, build, test, publish, deploy — should complete in approximately 3–4 minutes. + +--- + +## 🔑 Key Design Decisions + +**Path-filtered triggers, not a single monorepo workflow.** A single workflow that deploys everything on every push would deploy IdentityServer when only the API changed, and vice versa. Path filters give each component its own deployment pipeline while keeping all workflows in one repository. This is the recommended pattern for monorepos with multiple independently deployable components. + +**App Service settings over `appsettings.Production.json`.** Committing a `appsettings.Production.json` with real connection strings would put secrets in source control. App Service settings inject configuration at runtime without touching the build artifact. The same published ZIP can be deployed to any environment — the environment-specific values come from outside the artifact. + +**`submodules: recursive` is non-negotiable.** Without it, `dotnet restore` fails on the first run because the source files are absent. The `actions/checkout@v4` action does not fetch submodules by default. This is a common gotcha for developers new to GitHub Actions with submodule-based monorepos. + +**Tests run in CI before deploy.** The `dotnet test` step runs the full test suite before any deployment artifact is produced. A failing test aborts the workflow before `dotnet publish` runs — the current production deployment is never replaced by a broken build. This is the guarantee that makes CI/CD trustworthy. + +**IdentityServer before API.** The API attempts to fetch IdentityServer's discovery document at startup to configure token validation. Deploying the API before IdentityServer is running causes the API to start in a degraded state where all authenticated endpoints return 401. Deploying IdentityServer first and verifying its health endpoint before deploying the API ensures a clean startup sequence. + +--- + +## 🌟 Why This Matters + +The two workflows in this article automate what would otherwise be a 20-step manual process prone to missed settings and inconsistent results. More importantly, they make the deployment process auditable: every GitHub Actions run shows who triggered it, which commit was deployed, whether tests passed, and exactly which `az` commands ran. + +For a team, this means any developer can deploy by merging a pull request. No special deployment credentials are needed. No deployment runbook to follow. No risk of deploying an untested build. + +**Transferable skills:** + +* **`paths:` triggers** — the correct approach for any GitHub Actions monorepo; prevents unnecessary deployments and keeps pipeline minutes within free tier limits +* **`__` separator for App Service settings** — applies to any .NET application on App Service, regardless of the complexity of the settings hierarchy +* **`azure/webapps-deploy@v3`** — the standard action for all .NET App Service deployments; the same action works for Node.js, Python, and Java apps with different build steps + +--- + +## 🤝 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 #githubactions #dotnet #cicd #devops #appservice #azuresql #oidc #azurecli #continuousdeployment #identityserver #webdevelopment #fullstack #csharp #cleanarchitecture diff --git a/blogs/series-5-devops-data/5.7-azure-deploy-angular-swa.md b/blogs/series-5-devops-data/5.7-azure-deploy-angular-swa.md new file mode 100644 index 0000000..238f38e --- /dev/null +++ b/blogs/series-5-devops-data/5.7-azure-deploy-angular-swa.md @@ -0,0 +1,350 @@ +# Deploy Angular to Azure Static Web Apps + +## Inject Environment Variables at Build Time and Deploy with One GitHub Actions Workflow + +Angular applications compiled for production are static files: HTML, JavaScript bundles, CSS, and images. They have no runtime server to read environment variables from — the API URL and IdentityServer URL must be baked into the JavaScript at build time. Getting the right URLs into the right build requires a step between `npm ci` and `ng build` that injects the production values. + +This article writes the GitHub Actions workflow that installs, injects environment values, builds, and deploys the Angular application to Azure Static Web Apps — and explains the `staticwebapp.config.json` file that makes client-side routing work correctly after deployment. + +![Deploy Angular to Azure Static Web Apps](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. The full-stack tutorial — covering Angular 20, .NET 10 Web API, and OAuth 2.0 with Duende IdentityServer — has been published at [Building Modern Web Applications with Angular, .NET, and OAuth 2.0](https://medium.com/scrum-and-coke/building-modern-web-applications-with-angular-net-and-oauth-2-0-complete-tutorial-series-7ea97ed3fc56). **This article deploys the Angular frontend. Articles 5.4–5.6 covered infrastructure provisioning and backend deployment. Article 5.8 covers post-deployment configuration.** + +--- + +## 📚 What You'll Learn + +* **Why Angular needs build-time injection** — there is no runtime server to provide environment variables to a compiled SPA +* **`sed` for variable injection** — how to replace placeholder URLs in `environment.prod.ts` before the build runs +* **`npm ci` vs `npm install`** — why `ci` is correct for workflows +* **Static Web Apps deployment token** — how the workflow retrieves it from Azure using OIDC +* **`staticwebapp.config.json`** — the file that prevents 404 on page refresh for Angular routes +* **`skip_app_build: true`** — why the workflow builds Angular manually instead of letting the SWA action do it + +--- + +## 📋 Prerequisites + +**Before following this article, you should have:** + +* **Article 5.4 complete** — `swa-talent-ui-dev` Static Web App provisioned +* **Article 5.5 complete** — OIDC secrets (`AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, `AZURE_SUBSCRIPTION_ID`) in GitHub +* **Article 5.6 complete** — `app-talent-api-dev` and `app-talent-ids-dev` deployed and running + +**Additional secrets to add:** + +Navigate to GitHub → Settings → Secrets and variables → Actions → New repository secret: + +* **`API_APP_URL`** — the base URL of the deployed API App Service (without `/api/v1`) + +``` +https://app-talent-api-dev.azurewebsites.net +``` + +* **`IDENTITY_SERVER_URL`** — already added in Article 5.6 + +``` +https://app-talent-ids-dev.azurewebsites.net +``` + +--- + +## 🎯 The Problem + +The Angular production environment file (`src/environments/environment.prod.ts`) ships with placeholder URLs: + +```typescript +apiUrl: 'https://your-production-api.com/api/v1', +identityServerUrl: 'https://localhost:44310', +``` + +These values are compiled into the JavaScript bundle by `ng build --configuration production`. After compilation, there is no way to change them at runtime — they are inside minified, hashed JavaScript files. If the wrong URL is baked in, the app cannot reach the API or authenticate. + +Two naive solutions both have problems: + +* **Committing production URLs directly** — ties the code to a specific deployment, breaks for staging environments, and may expose URLs in public repositories +* **Using runtime injection** (reading from `window.__env`) — requires a server-side render or a meta-tag injection step and adds significant complexity + +--- + +## 💡 The Solution + +Inject the production URLs as environment variables in the GitHub Actions workflow, use `sed` to replace the placeholder strings in `environment.prod.ts` before the build runs, then build normally. The build picks up the real URLs. The binary contains the correct values. The same source file ships to every environment — what changes is only the injection step in the workflow. + +Additionally, `staticwebapp.config.json` tells Azure Static Web Apps to serve `index.html` for any route that doesn't match a static file. Without this, navigating directly to `https://your-swa.azurestaticapps.net/employees` returns a 404 because there is no `employees` file on the server — only the Angular router knows what to render at that path. + +--- + +## 🚀 How It Works + +### Step 1: Add the `staticwebapp.config.json` File + +Create `public/staticwebapp.config.json` in the Angular application directory: + +```json +{ + "navigationFallback": { + "rewrite": "/index.html", + "exclude": ["/api/*", "/*.{css,js,png,jpg,svg,ico,woff,woff2,ttf,eot}"] + }, + "mimeTypes": { + ".json": "application/json" + }, + "globalHeaders": { + "X-Frame-Options": "SAMEORIGIN", + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "strict-origin-when-cross-origin" + } +} +``` + +This file lives in `public/` so Angular's build copies it verbatim to `dist/talent-management/browser/`. The Angular build configuration in `angular.json` already includes `"input": "public"` as an asset glob, so no configuration change is needed. + +**`navigationFallback`** — the core SPA fix. All requests that don't match a static file or the `/api/*` path are rewritten to serve `index.html`. Angular's router then handles the URL client-side. + +**`exclude`** — static assets (JavaScript bundles, images, fonts) must not be rewritten to `index.html`. The pattern matches known static file extensions so they are served directly. + +**`globalHeaders`** — basic security headers applied to every response. These are set at the CDN edge, before responses reach the browser. + +### Step 2: Understand the Workflow + +The complete workflow is at `.github/workflows/deploy-angular.yml`. Its key sections: + +**Trigger:** + +```yaml +on: + push: + branches: + - main + paths: + - 'Clients/TalentManagement-Angular-Material/**' + - '.github/workflows/deploy-angular.yml' + workflow_dispatch: +``` + +Only Angular changes (or changes to the workflow itself) trigger this deployment. Backend changes do not re-deploy the Angular app. + +**Checkout with submodules:** + +```yaml +- name: Checkout repository (with submodules) + uses: actions/checkout@v4 + with: + submodules: recursive +``` + +The Angular source lives in the `Clients/TalentManagement-Angular-Material` submodule. Without `submodules: recursive`, the `Clients/` directory is empty and `npm ci` fails. + +**Node.js setup with caching:** + +```yaml +- name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22.x' + cache: 'npm' + cache-dependency-path: '${{ env.ANGULAR_APP_DIR }}/package-lock.json' +``` + +`cache: 'npm'` caches the npm dependency cache keyed on the `package-lock.json` hash. When `package-lock.json` has not changed, `npm ci` restores from the cache instead of downloading from the npm registry — cutting install time from 60 seconds to under 10 seconds on cache hits. + +**Install with `npm ci`:** + +```yaml +- name: Install dependencies + working-directory: ${{ env.ANGULAR_APP_DIR }} + run: npm ci +``` + +`npm ci` (clean install) is the correct command for CI/CD: + +* Installs exactly what is in `package-lock.json` — no version resolution, no surprises +* Deletes `node_modules` before installing — no leftover artifacts from previous runs +* Fails if `package-lock.json` is out of sync with `package.json` — catches a common developer mistake + +`npm install` is for local development where you want version resolution and lock file updates. Never use `npm install` in a workflow. + +**Inject environment variables:** + +```yaml +- name: Inject production environment variables + working-directory: ${{ env.ANGULAR_APP_DIR }} + env: + API_URL: ${{ secrets.API_APP_URL }} + IDENTITY_SERVER_URL: ${{ secrets.IDENTITY_SERVER_URL }} + run: | + sed -i "s|https://your-production-api.com/api/v1|${API_URL}/api/v1|g" \ + src/environments/environment.prod.ts + sed -i "s|https://localhost:44310|${IDENTITY_SERVER_URL}|g" \ + src/environments/environment.prod.ts +``` + +`sed -i` performs an in-place string replacement on the file. The placeholder strings in `environment.prod.ts` are replaced with the real Azure URLs before `ng build` runs. After the build, the modified `environment.prod.ts` is discarded — the runner's workspace is a fresh checkout every time. The committed source file always contains the safe placeholder values. + +**Why `sed` over a templating tool:** It requires no dependencies, works on any Linux runner, and is auditable — the exact replacement strings are visible in the workflow log (the URLs, not the secrets themselves, which are masked by GitHub Actions). + +**Build Angular:** + +```yaml +- name: Build Angular (production) + working-directory: ${{ env.ANGULAR_APP_DIR }} + run: npm run build -- --configuration production +``` + +`npm run build` calls `ng build` as defined in `package.json`. The `-- --configuration production` passes the flag through npm's argument separator. The production configuration uses `environment.prod.ts` (with the injected URLs) and enables full optimization: tree shaking, minification, and output hashing. + +The build output lands in `dist/talent-management/browser/` — the directory that the deploy action uploads. + +**OIDC login:** + +```yaml +- name: Log in to Azure + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} +``` + +The same OIDC pattern as the backend workflows. After this step, `az` commands run with Contributor access to `rg-talent-dev`. + +**Retrieve the Static Web App deployment token:** + +```yaml +- name: Get Static Web App deployment token + id: swa-token + run: | + SWA_TOKEN=$(az staticwebapp secrets list \ + --name ${{ env.STATIC_WEB_APP_NAME }} \ + --resource-group ${{ env.RESOURCE_GROUP }} \ + --query properties.apiKey \ + --output tsv) + echo "token=${SWA_TOKEN}" >> $GITHUB_OUTPUT +``` + +Azure Static Web Apps uses a separate deployment token — not an Azure RBAC credential — to authorize uploads. The token is retrieved from the resource itself using the OIDC-authenticated Azure CLI session. This avoids storing the SWA deployment token as a separate GitHub Secret. + +**Deploy:** + +```yaml +- name: Deploy to Azure Static Web Apps + uses: Azure/static-web-apps-deploy@v1 + with: + azure_static_web_apps_api_token: ${{ steps.swa-token.outputs.token }} + action: upload + app_location: 'Clients/TalentManagement-Angular-Material/talent-management' + output_location: 'dist/talent-management/browser' + skip_app_build: true +``` + +**`skip_app_build: true`** — by default, `Azure/static-web-apps-deploy` tries to build the app itself using Oryx (Azure's build system). Oryx does not know about the `sed` injection step and cannot inject GitHub Secrets into the build environment the same way. Setting `skip_app_build: true` tells the action that the build is already done — just upload the output directory. + +**`output_location`** — points to the built browser bundle. This must match Angular's output directory. For the `@angular/build:application` builder used in Angular 17+, the output is `dist//browser`. + +### Step 3: Push the `staticwebapp.config.json` and Trigger a Deployment + +The `staticwebapp.config.json` was added to the Angular submodule. Commit and push it: + +```bash +# In the Clients submodule +cd Clients/TalentManagement-Angular-Material +git checkout develop +git add talent-management/public/staticwebapp.config.json +git commit -m "Add Static Web Apps routing config for SPA fallback" +git push + +# Back in the parent repo, update submodule reference and merge to main +cd ../.. +git add Clients/TalentManagement-Angular-Material +git commit -m "Update Angular client submodule with SWA routing config" +``` + +When this commit reaches `main`, the Angular workflow triggers. + +### Step 4: Verify the Deployment + +After the workflow completes, retrieve the Static Web App URL: + +```bash +az staticwebapp show \ + --name swa-talent-ui-dev \ + --resource-group rg-talent-dev \ + --query defaultHostname \ + --output tsv +``` + +Open that URL in a browser. The Angular application should load. Test the routing fix by: + +1. Navigating to any route (e.g., `/dashboard/employees`) +2. Pressing F5 to hard-refresh the page +3. The page should reload correctly — not show a 404 + +--- + +## 💻 Try It Yourself + +Trigger the Angular workflow manually from the GitHub UI: + +``` +GitHub → Actions → Deploy Angular to Azure Static Web Apps → Run workflow → Run workflow +``` + +Or push any change to the Angular submodule and observe the workflow trigger automatically. Watch the "Inject production environment variables" step and confirm the correct URLs are being injected (the URLs are visible in the log; the secrets themselves are masked). + +--- + +## 🔑 Key Design Decisions + +**`sed` injection before `ng build`, not runtime injection.** Angular SPAs have no runtime server. All configuration must be in the compiled bundle. The `sed` approach modifies the source file in the ephemeral workflow workspace — the committed file remains unchanged. This is simpler and more reliable than runtime meta-tag injection or environment-specific `window.__env` objects. + +**`skip_app_build: true` with a manual build step.** The Static Web Apps action's built-in Oryx builder cannot receive GitHub Secrets as environment variables in the same way a workflow step can. Running `ng build` as a standard workflow step (with secrets available as `env:` variables) gives full control over the build environment. The action is used only for the upload step. + +**`npm ci` not `npm install`.** The `ci` command is deterministic, fast on cache hits, and fails loudly on lock file mismatches. `npm install` can silently update `package-lock.json` during a workflow run and install different versions than what was tested locally. In CI/CD, reproducibility is more important than convenience. + +**`staticwebapp.config.json` in `public/`, not a post-build copy.** The Angular build configuration already copies everything in `public/` to the output directory. Placing the config file there avoids a manual copy step in the workflow and keeps the configuration alongside the application code where it is easy to find and review. + +**Deployment token retrieved via OIDC, not stored as a secret.** The Static Web Apps deployment token is long-lived (it does not expire unless regenerated). Storing it as a GitHub Secret means another secret to manage. Retrieving it from Azure using the OIDC session at deploy time means one fewer secret, and the token is only in memory for the duration of the deploy step. + +--- + +## 🌟 Why This Matters + +Angular deployment is deceptively simple — until a user reports that the login button does nothing, the dashboard shows the wrong data, or half the routes return 404 after a refresh. Each of those failures has a specific cause: wrong IdentityServer URL baked into the bundle, wrong API URL baked in, or missing SPA fallback routing. + +The workflow and configuration in this article solve all three systematically, using patterns that apply to any Angular application hosted on any static file host: + +* **Environment injection at build time** — works for any SPA (React, Vue, Angular) on any host +* **SPA fallback routing config** — required on any static file host that doesn't natively understand client-side routing (S3, GitHub Pages, Azure SWA, Netlify all have equivalent configurations) +* **Build in CI, not in the deploy action** — gives full control over the build environment and makes the build reproducible + +**Transferable skills:** + +* **`sed -i` for config injection** — applies to any build that reads from a source file (environment files, JSON configs, YAML manifests) +* **`staticwebapp.config.json` routing rules** — the exact syntax for Azure SWA; equivalent files exist for S3/CloudFront (`cloudfront-routing.json`) and Netlify (`_redirects`) +* **`npm ci` in CI/CD** — the correct npm command for any CI workflow, regardless of the host or framework + +--- + +## 🤝 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 #angular #githubactions #staticwebapps #cicd #devops #typescript #oidc #clouddeployment #spa #angularmaterial #webdevelopment #fullstack #azurecli #frontend diff --git a/blogs/series-5-devops-data/5.8-azure-post-deployment-config.md b/blogs/series-5-devops-data/5.8-azure-post-deployment-config.md new file mode 100644 index 0000000..32b52a4 --- /dev/null +++ b/blogs/series-5-devops-data/5.8-azure-post-deployment-config.md @@ -0,0 +1,295 @@ +# Connect the Stack: Post-Deployment Configuration and Validation + +## Add Production URLs to IdentityServer, Set API CORS, and Validate the Full Login Flow + +Three services are running in Azure. Each started successfully. The Swagger UI loads. The Angular app loads. But clicking Login redirects to IdentityServer and bounces back with an error — `invalid_redirect_uri`. The stack is deployed but not yet connected. + +This article covers the configuration changes that wire the three services together: adding the production Azure URLs to IdentityServer's client registration, setting the API CORS policy, and walking through a validation checklist that confirms the complete OAuth 2.0 login flow works end-to-end. + +![Post-Deployment Configuration](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. The full-stack tutorial — covering Angular 20, .NET 10 Web API, and OAuth 2.0 with Duende IdentityServer — has been published at [Building Modern Web Applications with Angular, .NET, and OAuth 2.0](https://medium.com/scrum-and-coke/building-modern-web-applications-with-angular-net-and-oauth-2-0-complete-tutorial-series-7ea97ed3fc56). **This article is the final step in the Azure deployment sub-series. Articles 5.3–5.7 provisioned infrastructure and deployed all three services.** + +--- + +## 📚 What You'll Learn + +* **Why `invalid_redirect_uri` happens** — and exactly which config file to edit +* **`identityserverdata.json` structure** — the `RedirectUris`, `PostLogoutRedirectUris`, and `AllowedCorsOrigins` arrays for the Angular client +* **How CORS settings flow** — from `appsettings.json` to the API's middleware pipeline +* **Validation sequence** — the correct order to test each layer before testing the full flow +* **Common failure checklist** — 401, CORS, scope mismatch, and silent refresh errors + +--- + +## 📋 Prerequisites + +**Before following this article, you should have:** + +* **Article 5.6 complete** — IdentityServer deployed at `https://app-talent-ids-dev.azurewebsites.net` +* **Article 5.7 complete** — Angular deployed at the Static Web App URL (e.g., `https://agreeable-desert-01234567.azurestaticapps.net`) +* **The three Azure URLs** — retrieve them: + +```bash +# API URL +az webapp show \ + --resource-group rg-talent-dev \ + --name app-talent-api-dev \ + --query defaultHostName --output tsv + +# IdentityServer URL +az webapp show \ + --resource-group rg-talent-dev \ + --name app-talent-ids-dev \ + --query defaultHostName --output tsv + +# Static Web App URL +az staticwebapp show \ + --resource-group rg-talent-dev \ + --name swa-talent-ui-dev \ + --query defaultHostname --output tsv +``` + +--- + +## 🎯 The Problem + +OAuth 2.0 authorization codes and tokens can only flow to URLs that are explicitly registered in the authorization server (IdentityServer). If the Angular application running at `https://agreeable-desert-01234567.azurestaticapps.net` sends an authorization request asking IdentityServer to redirect the browser back to that URL, IdentityServer checks its registered `RedirectUris` for the `TalentManagement` client. If the production URL isn't there, IdentityServer rejects the request immediately with `invalid_redirect_uri`. + +Similarly, the API's CORS policy controls which origins can call the API from a browser. If the Angular app's domain is not in the allowed origins list, the browser blocks the API response before Angular can read it — even though the API returned HTTP 200. + +These two configurations — IdentityServer redirect URIs and API CORS origins — are the connective tissue that makes the three-tier stack function as a unit. + +--- + +## 💡 The Solution + +Add the production Static Web App URL to three lists in `identityserverdata.json` (the file that seeds IdentityServer's database): + +* `RedirectUris` — where IdentityServer may send authorization codes after login +* `PostLogoutRedirectUris` — where IdentityServer may redirect after logout +* `AllowedCorsOrigins` — origins that IdentityServer's token endpoint accepts cross-origin requests from + +Then update the API's allowed CORS origins so the Angular app can call protected endpoints. + +Both changes go into source control, IdentityServer's database is re-seeded, and the API is redeployed with updated settings. + +--- + +## 🚀 How It Works + +### Step 1: Find Your Static Web App URL + +```bash +az staticwebapp show \ + --resource-group rg-talent-dev \ + --name swa-talent-ui-dev \ + --query defaultHostname \ + --output tsv +``` + +The URL follows the pattern `https://.azurestaticapps.net`. Record this value — it is needed in several places. + +If you configured a custom domain, use that instead. + +### Step 2: Update `identityserverdata.json` + +Open `TokenService/Duende-IdentityServer/shared/identityserverdata.json`. Find the `TalentManagement` client section: + +```json +{ + "ClientId": "TalentManagement", + "ClientName": "Talent Management Demo Angular App", + "RedirectUris": [ + "http://localhost:4200", + "https://localhost:4200", + "http://localhost:4200/silent-refresh.html", + "https://localhost:4200/silent-refresh.html", + "http://localhost:4200/callback", + "https://localhost:4200/callback" + ], + "PostLogoutRedirectUris": [ + "http://localhost:4200", + "https://localhost:4200" + ], + "AllowedCorsOrigins": [ + "http://localhost:4200", + "https://localhost:4200" + ] +} +``` + +Add the production Azure URLs to each list. Replace `https://agreeable-desert-01234567.azurestaticapps.net` with your actual Static Web App URL: + +```json +{ + "ClientId": "TalentManagement", + "ClientName": "Talent Management Demo Angular App", + "RedirectUris": [ + "http://localhost:4200", + "https://localhost:4200", + "http://localhost:4200/silent-refresh.html", + "https://localhost:4200/silent-refresh.html", + "http://localhost:4200/callback", + "https://localhost:4200/callback", + "https://agreeable-desert-01234567.azurestaticapps.net", + "https://agreeable-desert-01234567.azurestaticapps.net/silent-refresh.html", + "https://agreeable-desert-01234567.azurestaticapps.net/callback" + ], + "PostLogoutRedirectUris": [ + "http://localhost:4200", + "https://localhost:4200", + "https://agreeable-desert-01234567.azurestaticapps.net" + ], + "AllowedCorsOrigins": [ + "http://localhost:4200", + "https://localhost:4200", + "https://agreeable-desert-01234567.azurestaticapps.net" + ] +} +``` + +**Three redirect URIs for the production URL:** +* The root URL — for the implicit return to the app after login +* `/silent-refresh.html` — for the silent token refresh iframe (background token renewal without a visible login page) +* `/callback` — the explicit OAuth callback route that the angular-auth-oidc-client library uses to process the authorization code + +### Step 3: Re-seed IdentityServer's Database + +`identityserverdata.json` is a seed file. On first run (or when the IdentityServer Admin seeds on startup), its contents are written to the IdentityServer database tables. After editing the file, IdentityServer's database must be updated. + +**Option A: Redeploy IdentityServer** — push the change to `main`, the IdentityServer workflow runs, IdentityServer restarts and re-seeds its database from the updated file on startup (if seed-on-startup is configured). + +**Option B: Use the Admin UI** — navigate to `https://app-talent-ids-dev.azurewebsites.net/admin` (if the Admin project is deployed), log in with admin credentials, and update the client's redirect URIs and CORS origins directly through the UI. Changes take effect immediately without a deployment. + +For this tutorial, Option A (commit and push) is preferred — the source file remains the source of truth. + +### Step 4: Update API CORS Settings + +The API allows CORS origins configured in `appsettings.json`. Locate the CORS section: + +```bash +grep -n "CorsOrigins\|AllowedOrigins\|Cors" \ + ApiResources/TalentManagement-API/TalentManagementAPI.WebApi/appsettings.json +``` + +Add the Static Web App URL to the allowed origins, then push the change or set it as an App Service setting: + +```bash +az webapp config appsettings set \ + --resource-group rg-talent-dev \ + --name app-talent-api-dev \ + --settings "CorsOrigins=https://agreeable-desert-01234567.azurestaticapps.net" +``` + +The API middleware reads this setting at startup and adds the origin to the allowed list. + +### Step 5: Validate Each Layer in Order + +Test from the bottom up. Do not attempt end-to-end validation until each individual layer passes. + +**Layer 1: IdentityServer discovery endpoint** + +```bash +curl https://app-talent-ids-dev.azurewebsites.net/.well-known/openid-configuration +``` + +Expected: a JSON object containing `issuer`, `authorization_endpoint`, `token_endpoint`, `jwks_uri`. If this fails, IdentityServer is not running or the App Service is not healthy — check Application Insights or the App Service log stream. + +**Layer 2: API health endpoint** + +```bash +curl https://app-talent-api-dev.azurewebsites.net/api/v1/health +``` + +Or open `https://app-talent-api-dev.azurewebsites.net/swagger`. If Swagger loads, the API started correctly. If it returns 500, check the App Service settings — a missing or malformed connection string is the most common cause. + +**Layer 3: Angular app loads** + +Open the Static Web App URL in a browser. The Angular application should load — the nav bar, the login button, and the default page should be visible without any console errors. If the page is blank or shows a JavaScript error, the `environment.prod.ts` injection likely used the wrong URL. + +Check the browser console for errors like `cannot GET /dashboard` (SPA routing fallback missing) or `Failed to fetch` (wrong API URL). + +**Layer 4: Login redirect** + +Click Login. The browser should redirect to `https://app-talent-ids-dev.azurewebsites.net/Account/Login`. The IdentityServer login page should appear. If the browser instead shows `invalid_redirect_uri`, Step 2 is incomplete — the production callback URL is not in `RedirectUris`. + +**Layer 5: Login with test credentials** + +Enter test credentials (`ashtyn1` / `Pa$$word123`). IdentityServer authenticates the user and redirects back to the Angular application with an authorization code in the query string. Angular's OIDC library exchanges the code for tokens. + +After successful login: +* The Angular route should update to the dashboard +* The browser developer tools → Application → Local Storage should show the OIDC tokens +* Network tab should show API requests with an `Authorization: Bearer ...` header + +**Layer 6: API call with Bearer token** + +Navigate to the Employees list in the Angular app. The network tab should show `GET /api/v1/employees` returning HTTP 200. If it returns 401, the `Sts__ServerUrl` App Service setting on the API points to a wrong or unreachable IdentityServer URL. If it returns a CORS error, the CORS setting in Step 4 is missing or incorrect. + +--- + +## 💻 Try It Yourself + +After completing Steps 1–4, run through the Layer 1–6 validation in order. Each layer takes under 60 seconds to validate. A failure at any layer gives a clear signal of what to fix — you don't need to guess why the login flow isn't working if you know exactly which layer failed. + +**Quick CORS verification from the browser console:** + +```javascript +fetch('https://app-talent-api-dev.azurewebsites.net/api/v1/health', { + headers: { 'Origin': 'https://agreeable-desert-01234567.azurestaticapps.net' } +}).then(r => console.log('Status:', r.status, 'CORS:', r.headers.get('access-control-allow-origin'))) +``` + +Expected: `Status: 200 CORS: https://agreeable-desert-01234567.azurestaticapps.net` + +--- + +## 🔑 Key Design Decisions + +**Add production URLs alongside localhost, not instead.** The `localhost` entries in `identityserverdata.json` keep local development working without any configuration change. A developer cloning the repo and running locally does not need to edit a config file to log in. The production and local URLs coexist in the same list. + +**Three redirect URIs per environment, not one.** OAuth 2.0 redirect URI matching is exact — a trailing slash, a missing path segment, or an `http` vs `https` mismatch causes a rejection. The three patterns (root, `/callback`, `/silent-refresh.html`) cover the full OIDC library's behavior: the initial login callback, the explicit callback route, and the silent token renewal iframe. + +**Validate layer by layer, not end-to-end first.** Attempting to log in before verifying the discovery endpoint conflates multiple possible failures. If login fails, you don't know whether IdentityServer is unreachable, the redirect URI is wrong, or the Angular env vars are incorrect. Bottom-up validation isolates each failure to a single cause. + +**App Service settings for CORS origins, not a code change.** The API CORS policy reads from configuration. Updating allowed origins as an App Service setting (rather than editing `appsettings.json` and redeploying) takes effect on the next app restart without a new deployment. This is the correct pattern for any configuration that may change between environments — keep the code the same, change only the config. + +--- + +## 🌟 Why This Matters + +Post-deployment configuration is the step most tutorials skip. They provision infrastructure, deploy the code, and leave it to the reader to figure out why the login button doesn't work. The `invalid_redirect_uri` error, CORS rejections, and 401 responses from the API are not deployment failures — they are configuration gaps that require connecting the services together after deployment. + +Understanding the configuration layer means understanding the security model. IdentityServer's explicit allowlist of redirect URIs is a security control, not a convenience setting. It prevents an attacker from crafting a login URL that redirects tokens to their own server. CORS origins on the API prevent browsers from leaking token-authenticated responses to unauthorized origins. These are not obstacles to get past — they are the reason the auth flow is trustworthy. + +**Transferable skills:** + +* **IdentityServer client configuration** — `RedirectUris`, `PostLogoutRedirectUris`, and `AllowedCorsOrigins` are standard OpenID Connect client properties; the same patterns apply to any OpenID Connect provider (Auth0, Azure Entra ID, Okta) +* **Layer-by-layer validation** — the discovery endpoint → health endpoint → app load → redirect → login → API call sequence applies to any three-tier app with OAuth 2.0 +* **App Service settings for CORS** — the `az webapp config appsettings set` pattern for runtime config applies to any .NET setting, not just CORS + +--- + +## 🤝 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 #identityserver #oauth2 #oidc #angular #dotnet #cors #appservice #staticwebapps #cicd #devops #authentication #security #webdevelopment #fullstack diff --git a/blogs/series-6-ai-app-features/6.1-dotnet-ai-foundation.md b/blogs/series-6-ai-app-features/6.1-dotnet-ai-foundation.md new file mode 100644 index 0000000..7192ecf --- /dev/null +++ b/blogs/series-6-ai-app-features/6.1-dotnet-ai-foundation.md @@ -0,0 +1,367 @@ +# Run a Local LLM in Your .NET 10 API with Ollama + +## How Microsoft.Extensions.AI Makes Your API AI-Ready Without Locking You Into One Provider + +Every developer wants AI in their app. The problem is getting started: API keys, cloud costs, rate limits, and the fear of betting your architecture on one vendor. What if you could add a working AI endpoint to your .NET 10 API in under an hour — for free, running entirely on your laptop? + +This article shows you exactly how, using [Ollama](https://ollama.com) for a local LLM and `Microsoft.Extensions.AI` as a provider-agnostic abstraction. + +📖 **Tutorial Repository:** [AngularNetTutorial on GitHub](https://github.com/workcontrolgit/AngularNetTutorial) + +--- + +This article is part of the **AngularNetTutorial** series. The full-stack tutorial — covering Angular 20, .NET 10 Web API, and OAuth 2.0 with Duende IdentityServer — has been published at [Building Modern Web Applications with Angular, .NET, and OAuth 2.0](https://medium.com/scrum-and-coke/building-modern-web-applications-with-angular-net-and-oauth-2-0-complete-tutorial-series-7ea97ed3fc56). **This article kicks off Series 6 by adding AI capabilities to the existing TalentManagement API — without breaking any existing functionality for developers who don't have Ollama installed.** + +--- + +## 🎓 What You'll Learn + +* **Microsoft.Extensions.AI abstraction** — How `IChatClient` lets you swap LLM providers by changing one line +* **Ollama integration** — Pull a free local model and connect it to your .NET API in minutes +* **Feature flag gating** — Why `[FeatureGate("AiEnabled")]` is the safest way to ship AI without breaking existing users +* **Clean Architecture placement** — Where AI interfaces, implementations, and controllers belong in the layer structure +* **Provider-agnostic DI** — How to register `AddOllamaChatClient()` so the rest of the app never knows which provider you're using + +--- + +## 📋 Prerequisites + +**Before following this article, you should have:** + +* **TalentManagement stack running** — Complete [Series 0–5](https://medium.com/scrum-and-coke/building-modern-web-applications-with-angular-net-and-oauth-2-0-complete-tutorial-series-7ea97ed3fc56) or clone the tutorial repo +* **.NET 10 SDK** — `dotnet --version` should show `10.x` +* **Ollama installed** — Download from [ollama.com](https://ollama.com/download) (free, no account required) +* **llama3.2 model pulled** — `ollama pull llama3.2` (~2 GB download) +* **Basic C# and Clean Architecture familiarity** — Understanding of interfaces, DI, and MediatR helps + +**Not set up yet?** Follow the [AngularNetTutorial setup guide](https://github.com/workcontrolgit/AngularNetTutorial) first. + +--- + +## 🎯 The Problem + +Adding AI to a production .NET API sounds daunting. Most tutorials show you how to call OpenAI with an API key — which is fine until you hit a rate limit, get an unexpected bill, or need to demo the app offline. Developers following a tutorial shouldn't need a credit card. + +Beyond getting started, there's an architectural risk: if your AI code reaches directly into the OpenAI SDK, switching providers later means touching every file that calls it. You've created a tight dependency on one vendor. + +**Common pain points:** + +* **Vendor lock-in** — Switching from OpenAI to Azure OpenAI (or Ollama) requires rewriting service code +* **Cost barrier** — Cloud LLMs require API keys, rate limits, and billing setup before you can write a single test +* **Feature flag complexity** — Without proper gating, enabling AI affects every user — even those on machines without Ollama installed + +--- + +## 💡 The Solution + +[Microsoft.Extensions.AI](https://learn.microsoft.com/en-us/dotnet/ai/microsoft-extensions-ai) provides a single `IChatClient` interface that works identically across providers. Register `AddOllamaChatClient()` in development, `AddAzureOpenAIChatClient()` in production — your `IAiChatService` implementation doesn't change. + +[Ollama](https://ollama.com) runs open-weight models like `llama3.2` locally. No API key. No cloud. Works offline. Perfect for tutorials and development. + +We gate the entire `AiController` behind a `[FeatureGate("AiEnabled")]` attribute. When `"AiEnabled": false` (the default), the controller doesn't even respond to requests — no Ollama connection is attempted, the rest of the API is unaffected. + +**Key benefits:** + +* ✅ **Zero cost** — Ollama is free; no API key, no credit card, no rate limits +* ✅ **Provider-agnostic** — Swap Ollama → Azure OpenAI → Anthropic by changing one DI registration line +* ✅ **Safe coexistence** — Feature flag default `false` means original tutorial (Series 0–5) works unchanged +* ✅ **Clean Architecture** — Interface in Application, implementation in Infrastructure.Shared, controller in WebApi + +--- + +## 🚀 How It Works + +### Step 1: Install Ollama and Pull a Model + +Download Ollama from [ollama.com/download](https://ollama.com/download) for your OS. After installation: + +```bash +# Pull the llama3.2 model (~2 GB — fast, capable, great for tutorials) +ollama pull llama3.2 + +# Start the Ollama server (runs at http://localhost:11434) +ollama serve + +# Verify it's running +curl http://localhost:11434/api/tags +``` + +**What this does:** Ollama downloads model weights and runs a local HTTP server that accepts chat requests. Our .NET API will call this endpoint internally — no external network traffic. + +### Step 2: Add NuGet Packages + +The AI packages split across two projects to maintain Clean Architecture separation: + +**`TalentManagementAPI.WebApi.csproj`** — The Ollama provider lives here: + +```xml + +``` + +**`TalentManagementAPI.Infrastructure.Shared.csproj`** — The abstraction lives here: + +```xml + +``` + +**Why split?** The Application and Infrastructure layers must never reference provider-specific packages (`Microsoft.Extensions.AI.Ollama`). Only WebApi knows which provider is registered. Infrastructure.Shared only knows about `IChatClient` from `Microsoft.Extensions.AI`. + +### Step 3: Add Feature Flag and Ollama Config + +In `TalentManagementAPI.WebApi/appsettings.json`, add `AiEnabled` to the existing `FeatureManagement` section and a new `Ollama` section: + +```json +"FeatureManagement": { + "AuthEnabled": true, + "CacheEnabled": true, + "AiEnabled": false +}, +"Ollama": { + "BaseUrl": "http://localhost:11434", + "Model": "llama3.2" +} +``` + +**Key point:** `"AiEnabled": false` is the default. Developers who haven't installed Ollama can still clone and run the full stack — the AI endpoint simply returns 404. To activate AI features, change this to `true` and ensure Ollama is running. + +### Step 4: Define the Application Interface + +Create `TalentManagementAPI.Application/Interfaces/IAiChatService.cs`: + +```csharp +namespace TalentManagementAPI.Application.Interfaces +{ + public interface IAiChatService + { + Task ChatAsync(string message, string? systemPrompt = null, + CancellationToken cancellationToken = default); + } +} +``` + +**Why an interface?** The Application layer defines *what* the service does — not *how*. This follows the Dependency Inversion Principle: high-level modules (Application) don't depend on low-level details (Ollama SDK). Tests can inject a mock `IAiChatService` without needing Ollama running. + +### Step 5: Implement in Infrastructure.Shared + +Create `TalentManagementAPI.Infrastructure.Shared/Services/OllamaAiService.cs`: + +```csharp +using Microsoft.Extensions.AI; +using TalentManagementAPI.Application.Interfaces; + +namespace TalentManagementAPI.Infrastructure.Shared.Services +{ + public class OllamaAiService : IAiChatService + { + private readonly IChatClient _chatClient; + + public OllamaAiService(IChatClient chatClient) + { + _chatClient = chatClient; + } + + public async Task ChatAsync(string message, string? systemPrompt = null, + CancellationToken cancellationToken = default) + { + var messages = new List(); + + if (!string.IsNullOrWhiteSpace(systemPrompt)) + messages.Add(new ChatMessage(ChatRole.System, systemPrompt)); + + messages.Add(new ChatMessage(ChatRole.User, message)); + + var response = await _chatClient.CompleteAsync(messages, cancellationToken: cancellationToken); + return response.Message.Text ?? string.Empty; + } + } +} +``` + +**What this does:** `OllamaAiService` takes `IChatClient` from DI — it has no idea it's talking to Ollama specifically. The `CompleteAsync` method sends the message list and returns the model's reply. An optional system prompt lets callers control the AI's persona or constraints. + +### Step 6: Register Services + +In `Infrastructure.Shared/ServiceRegistration.cs`, add the `IAiChatService` → `OllamaAiService` binding: + +```csharp +using TalentManagementAPI.Application.Interfaces; +using TalentManagementAPI.Infrastructure.Shared.Services; + +public static void AddSharedInfrastructure(this IServiceCollection services, IConfiguration _config) +{ + services.Configure(_config.GetSection("MailSettings")); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); +} +``` + +In `WebApi/Program.cs`, register the Ollama provider for `IChatClient`: + +```csharp +// Register application services +builder.Services.AddApplicationLayer(); +builder.Services.AddPersistenceInfrastructure(builder.Configuration); +builder.Services.AddSharedInfrastructure(builder.Configuration); + +// Register Ollama chat client (IChatClient) — used by OllamaAiService +// AiController is gated by [FeatureGate("AiEnabled")], so no calls are made when AI is disabled +var ollamaBaseUrl = builder.Configuration["Ollama:BaseUrl"] ?? "http://localhost:11434"; +var ollamaModel = builder.Configuration["Ollama:Model"] ?? "llama3.2"; +builder.Services.AddOllamaChatClient(ollamaModel, new Uri(ollamaBaseUrl)); +``` + +**What this does:** `AddOllamaChatClient()` registers `IChatClient` in the DI container pointing to Ollama. `OllamaAiService` receives this via constructor injection. If you later want to use Azure OpenAI, you'd replace `AddOllamaChatClient()` with `AddAzureOpenAIChatClient()` — and nothing else changes. + +### Step 7: Create the AI Controller + +Create `TalentManagementAPI.WebApi/Controllers/v1/AiController.cs`: + +```csharp +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.FeatureManagement.Mvc; +using TalentManagementAPI.Application.Interfaces; + +namespace TalentManagementAPI.WebApi.Controllers.v1 +{ + [FeatureGate("AiEnabled")] + [ApiVersion("1.0")] + [AllowAnonymous] + [Route("api/v{version:apiVersion}/ai")] + public sealed class AiController : BaseApiController + { + private readonly IAiChatService _aiChatService; + + public AiController(IAiChatService aiChatService) + { + _aiChatService = aiChatService; + } + + /// + /// Send a message to the AI assistant and receive a reply. + /// + [HttpPost("chat")] + public async Task Chat([FromBody] AiChatRequest request, + CancellationToken cancellationToken) + { + var reply = await _aiChatService.ChatAsync( + request.Message, request.SystemPrompt, cancellationToken); + return Ok(new AiChatResponse(reply)); + } + } + + public record AiChatRequest(string Message, string? SystemPrompt = null); + public record AiChatResponse(string Reply); +} +``` + +**What `[FeatureGate("AiEnabled")]` does:** When `AiEnabled` is `false` in `appsettings.json`, ASP.NET Core returns a `404 Not Found` for all routes under this controller. Ollama is never called. The controller doesn't appear in Swagger. To the rest of the app, it doesn't exist. + +When `AiEnabled` is `true`, the endpoint becomes fully active. No other code changes needed. + +--- + +## 💻 Try It Yourself + +**Enable AI features** by setting `"AiEnabled": true` in `appsettings.json` and starting Ollama: + +```bash +# Terminal 1: Start Ollama (if not already running) +ollama serve + +# Terminal 2: Start the .NET API (from the ApiResources submodule) +cd ApiResources/TalentManagement-API +dotnet run +``` + +Open Swagger at `https://localhost:44378/swagger` and find the **Ai** section. + +![Swagger AI Chat endpoint showing POST /api/v1/ai/chat](../../docs/images/ai/swagger-ai-chat-endpoint.png) + +Click **POST /api/v1/ai/chat**, then **Try it out**, and send: + +```json +{ + "message": "What is the difference between OAuth 2.0 and OIDC?", + "systemPrompt": "You are a helpful assistant specializing in identity and security." +} +``` + +You'll see Ollama's reply in the response body within a few seconds. + +**To test without Swagger** — use curl: + +```bash +curl -X POST https://localhost:44378/api/v1/ai/chat \ + -H "Content-Type: application/json" \ + -k \ + -d '{"message": "Explain JWT tokens in one paragraph."}' +``` + +**To verify the feature flag** — set `"AiEnabled": false`, restart the API, and try the same curl. You'll get a `404` — the controller is invisible. + +--- + +## 📊 Real-World Impact + +**Before this approach:** + +* ❌ AI code is tightly coupled to OpenAI SDK — migrating to another provider requires rewriting service code +* ❌ Tutorial readers need an API key and billing account just to run the demo +* ❌ Enabling AI in `develop` branch breaks builds for developers without Ollama + +**After this approach:** + +* ✅ Swap Ollama for Azure OpenAI in production by changing one DI line — application code unchanged +* ✅ Zero-cost, zero-signup AI during development — every tutorial reader can follow along +* ✅ Feature flag default `false` means the full Series 0–5 stack runs unchanged — AI is opt-in + +--- + +## 🌟 Why This Matters + +The `IChatClient` abstraction from Microsoft is the `.NET HTTP Client` of AI — a standard interface the ecosystem is aligning around. By building on it now, your code is forward-compatible with whatever provider becomes the best choice in 12 months. + +For tutorial purposes, Ollama removes the biggest barrier to learning: access. Every developer on every OS can pull `llama3.2`, type `ollama serve`, and have a working LLM in their local environment. No billing, no configuration, no waiting for API access. + +The feature flag pattern ensures this is safe to ship: the codebase always builds, always runs, and the original Series 0–5 experience is completely unchanged. AI features activate on demand. + +**Transferable skills:** + +* **Provider-agnostic AI abstractions** — The `IChatClient` pattern applies equally to Azure OpenAI, Anthropic, Google, and Hugging Face endpoints +* **Feature flag architecture** — The `[FeatureGate]` pattern applies to any experimental or optional feature +* **Clean Architecture for external services** — Interface in Application, implementation in Infrastructure, provider registration in WebApi + +--- + +## 🤝 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 Navigation + +**AngularNetTutorial Blog Series:** + +* [Building Modern Web Applications with Angular, .NET, and OAuth 2.0](https://medium.com/scrum-and-coke/building-modern-web-applications-with-angular-net-and-oauth-2-0-complete-tutorial-series-7ea97ed3fc56) — Main tutorial +* [Stop Juggling Multiple Repos: Manage Your Full-Stack App Like a Workspace](../series-0-architecture/0.1-git-submodule-workspace.md) — Git Submodules +* [End-to-End Testing Made Simple: How Playwright Transforms Testing](../series-0-architecture/0.2-playwright-testing.md) — Playwright Overview +* [Speed Up Your Dashboard: Easy Response Caching in .NET 10 With EasyCaching](../series-2-dotnet-api/2.5-dotnet-easycaching.md) — Response Caching (Series 2.5) +* **This Article** — Run a Local LLM in Your .NET 10 API with Ollama (Series 6.1) +* [Build an HR AI Assistant That Knows Your Data](6.2-dotnet-ai-hr-assistant.md) — HR AI Assistant (Series 6.2) +* [Add an AI Chat Widget to Angular with Streaming](6.3-angular-ai-chat-widget.md) — Angular Chat Widget (Series 6.3) + +--- + +**📌 Tags:** #dotnet #csharp #ai #ollama #llm #microsoftextensionsai #cleanarchitecture #aspnetcore #webapi #featureflags #fullstack #angular #oauth2 #locallm #generativeai diff --git a/docs/deployment/azure-bicep-infrastructure-plan.md b/docs/deployment/azure-bicep-infrastructure-plan.md new file mode 100644 index 0000000..ed5350b --- /dev/null +++ b/docs/deployment/azure-bicep-infrastructure-plan.md @@ -0,0 +1,695 @@ +# Azure Bicep Infrastructure Plan + +## What is Azure Bicep? + +**Azure Bicep is a declarative Infrastructure as Code (IaC) language** for defining and deploying Azure resources. Instead of clicking through the Azure Portal to create resources, you write a `.bicep` file that describes *what* you want — and Azure creates it. + +### Bicep vs. the Azure Portal + +Without Bicep, setting up this project's Azure infrastructure means: + +1. Log into portal.azure.com +2. Manually create a Resource Group +3. Manually create an App Service Plan +4. Manually create two Web Apps +5. Manually create a SQL Server and two databases +6. Repeat for every new environment (staging, production) + +With Bicep, all of that becomes **one command**: + +```bash +az deployment group create \ + --resource-group rg-talent-dev \ + --template-file infra/main.bicep \ + --parameters infra/parameters/dev.bicepparam +``` + +Run it once and all resources are created. Run it again and Azure only updates what changed. Tear down the environment and recreate it identically in minutes. + +### How Bicep Works + +**Yes — Bicep templates are deployed using the Azure CLI** (`az` command) or Azure PowerShell. The typical workflow is: + +``` +Write .bicep file → Run az CLI command → Azure creates resources +``` + +Bicep is not a script that runs step by step. It is a **declaration** of the desired end state. Azure reads the file and figures out what to create, update, or leave alone. + +### Bicep vs. ARM Templates + +Bicep compiles down to ARM (Azure Resource Manager) templates — the native Azure deployment format. Bicep is a cleaner, more readable syntax that Microsoft built on top of ARM. You never need to write raw ARM JSON; write Bicep and the compiler handles the rest. + +### Prerequisites for Running Bicep + +Install these tools once: + +```bash +# 1. Install Azure CLI +# Windows: https://learn.microsoft.com/en-us/cli/azure/install-azure-cli-windows +winget install Microsoft.AzureCLI + +# 2. Install Bicep CLI (included with Azure CLI 2.20+, or install separately) +az bicep install + +# 3. Log in to Azure +az login + +# 4. Set your target subscription +az account set --subscription "Your Subscription Name" +``` + +### A Minimal Bicep Example + +Here is what a Bicep file looks like — this creates one App Service Plan: + +```bicep +param location string = 'eastus' +param appServicePlanName string = 'asp-talent-b1-dev' + +resource appServicePlan 'Microsoft.Web/serverfarms@2022-03-01' = { + name: appServicePlanName + location: location + sku: { + name: 'B1' + tier: 'Basic' + } +} +``` + +Deploy it with: + +```bash +az group create --name rg-talent-dev --location eastus +az deployment group create \ + --resource-group rg-talent-dev \ + --template-file infra/main.bicep +``` + +### Bicep in GitHub Actions + +In this project, Bicep will be run from GitHub Actions as part of the CI/CD pipeline — so infrastructure changes are version-controlled and deployed automatically alongside code changes. + +```yaml +- name: Deploy Bicep infrastructure + uses: azure/arm-deploy@v1 + with: + resourceGroupName: rg-talent-dev + template: infra/main.bicep + parameters: infra/parameters/dev.bicepparam +``` + +--- + +## Purpose + +This document defines the infrastructure that should be provisioned with Bicep for the low-cost Azure deployment design. + +The goal is to express the target Azure resources as Infrastructure as Code instead of relying on ad hoc portal setup. + +## Bicep Scope + +The Bicep template should provision: + +1. App Service Plan +2. API Web App +3. IdentityServer Web App +4. Angular Static Web App +5. Azure SQL logical server +6. API database +7. IdentityServer database +8. optional Application Insights later, if budget allows + +## Is Bicep a Separate Project or Repository? + +**No — Bicep lives inside the parent repository, not in a separate repo.** + +This project uses Git submodules. The parent repository (`AngularNetTutorial`) is the right place for Bicep because it orchestrates the full stack. The submodules (API, Angular, IdentityServer) contain only their own application code. + +``` +AngularNetTutorial/ ← parent repo — Bicep lives HERE +├── infra/ ← all Bicep files +│ ├── main.bicep ← entry point, composes all modules +│ ├── modules/ ← one file per resource type +│ │ ├── appServicePlan.bicep +│ │ ├── webApp.bicep +│ │ ├── staticWebApp.bicep +│ │ └── sqlServer.bicep +│ └── parameters/ ← one file per environment +│ ├── dev.bicepparam +│ └── prod.bicepparam +├── .github/ +│ └── workflows/ +│ ├── deploy-infra.yml ← runs Bicep (infrastructure only) +│ ├── deploy-api.yml ← deploys .NET API +│ ├── deploy-identityserver.yml +│ └── deploy-angular.yml ← deploys Angular to Static Web Apps +├── ApiResources/TalentManagement-API/ ← git submodule +├── Clients/TalentManagement-Angular-Material/ ← git submodule +├── TokenService/Duende-IdentityServer/ ← git submodule +└── Tests/AngularNetTutorial-Playwright/ ← git submodule +``` + +### Why Not a Separate Repo? + +A separate "infrastructure repo" is appropriate for large teams where a platform/ops team owns infrastructure independently of application code. For this project: + +- the infrastructure is tightly coupled to one application stack +- Bicep changes and app code changes are often committed together +- a single parent repo keeps the full picture in one place +- the `infra/` folder is small — it does not warrant its own repo + +### Four Separate Workflows, Not One + +Infrastructure and application deployments use **separate GitHub Actions workflows**: + +| Workflow | Trigger | What It Does | +|---|---|---| +| `deploy-infra.yml` | Manual or push to `infra/` | Runs Bicep — creates/updates Azure resources | +| `deploy-api.yml` | Push to `ApiResources/` submodule | Builds and deploys .NET API | +| `deploy-identityserver.yml` | Push to `TokenService/` submodule | Builds and deploys IdentityServer | +| `deploy-angular.yml` | Push to `Clients/` submodule | Builds Angular and deploys to Static Web Apps | + +**Why separate?** Infrastructure rarely changes. Keeping it in its own workflow means an API code change does not re-run Bicep. It also makes it easier to run Bicep manually (first time setup) without triggering an application deployment. + +### Deploy Order (First Time Setup) + +On first setup, run the workflows in this order: + +``` +1. deploy-infra.yml ← provision all Azure resources first +2. deploy-identityserver.yml ← IdentityServer must be up before API can validate tokens +3. deploy-api.yml ← API needs IdentityServer running +4. deploy-angular.yml ← Angular can deploy any time after infra is ready +``` + +After the first setup, each workflow runs independently on its own trigger. + +--- + +## Recommended File Location + +Create Bicep assets under: + +``` +infra/ +├── main.bicep +├── modules/ +│ ├── appServicePlan.bicep +│ ├── webApp.bicep +│ ├── staticWebApp.bicep +│ └── sqlServer.bicep +└── parameters/ + ├── dev.bicepparam + └── prod.bicepparam +``` + +### GitHub Actions Workflow File + +Create `.github/workflows/deploy-infra.yml` in the parent repository: + +```yaml +name: Deploy Infrastructure (Bicep) + +on: + workflow_dispatch: # manual trigger — run this first on new environments + push: + branches: [main] + paths: + - 'infra/**' # only runs when Bicep files change + +permissions: + id-token: write # required for Azure OpenID Connect (OIDC) login + contents: read + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log in to Azure (OIDC — no stored secrets) + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Create Resource Group (if it does not exist) + run: | + az group create \ + --name rg-talent-dev \ + --location eastus + + - name: Deploy Bicep infrastructure + uses: azure/arm-deploy@v2 + with: + resourceGroupName: rg-talent-dev + template: infra/main.bicep + parameters: infra/parameters/dev.bicepparam + failOnStdErr: false +``` + +### Safeguarding AZURE_CLIENT_ID, AZURE_TENANT_ID, and AZURE_SUBSCRIPTION_ID + +#### Where These Values Live + +**These three IDs are never written inside any Bicep file or committed to source control.** They are stored exclusively as encrypted GitHub repository secrets and referenced in the workflow using `${{ secrets.SECRET_NAME }}` syntax. + +```yaml +# In the workflow file — the actual values are NEVER visible here +- uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} # resolved at runtime from GitHub Secrets + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} +``` + +GitHub Secrets are: +- **encrypted at rest** — GitHub encrypts the value when you save it +- **masked in logs** — if a secret value appears in a workflow log, GitHub replaces it with `***` +- **never exposed to forks** — pull requests from forked repositories cannot access repository secrets +- **not visible after entry** — once saved, the value cannot be retrieved from the GitHub UI + +#### Why OIDC — No Password Stored + +The traditional approach to GitHub → Azure authentication uses a **client secret** (a password): + +``` +GitHub stores password → sends password to Azure → Azure validates password +``` + +The problem: that password is a long-lived credential. If it leaks (log file, PR comment, accident), an attacker has access until someone rotates it. + +**OIDC (OpenID Connect) eliminates the password entirely:** + +``` +GitHub generates a short-lived JWT for this specific job run +→ Azure validates the JWT against the trusted GitHub OIDC issuer +→ Azure issues a short-lived access token (valid ~1 hour) +→ Job runs and token expires automatically +``` + +No password exists to leak. Even if an attacker intercepted the access token, it expires when the job ends. + +#### Azure Side Setup (One-Time, Done in Azure Portal) + +Before the workflow can authenticate, configure trust on the Azure side: + +**Step 1 — Create an App Registration** + +``` +Azure Portal → Azure Active Directory → App registrations → New registration +Name: github-actions-talent-dev +``` + +This gives you the `AZURE_CLIENT_ID` (Application ID) and `AZURE_TENANT_ID` (shown on the overview page). + +**Step 2 — Add a Federated Identity Credential** + +``` +App Registration → Certificates & secrets → Federated credentials → Add credential + +Scenario: GitHub Actions deploying Azure resources +Organization: workcontrolgit +Repository: AngularNetTutorial +Entity type: Branch +Branch: main +``` + +This tells Azure: "trust JWT tokens issued by GitHub Actions for this specific repo and branch." No password is created. + +**Step 3 — Grant the App Registration a Role on the Resource Group** + +``` +Azure Portal → Resource Groups → rg-talent-dev → Access control (IAM) +→ Add role assignment +Role: Contributor +Member: github-actions-talent-dev (the App Registration from Step 1) +``` + +> **Use Resource Group scope, not Subscription scope.** `Contributor` at the Subscription level gives access to all resources in your entire Azure subscription. Scoping to the resource group limits the blast radius — if the credential is ever misused, it can only affect `rg-talent-dev`. + +**Step 4 — Add the Three Values as GitHub Secrets** + +``` +GitHub → Repository → Settings → Secrets and variables → Actions → New repository secret +``` + +| Secret Name | Value | How to Find It | +|---|---|---| +| `AZURE_CLIENT_ID` | `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` | App Registration → Overview → Application (client) ID | +| `AZURE_TENANT_ID` | `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` | App Registration → Overview → Directory (tenant) ID | +| `AZURE_SUBSCRIPTION_ID` | `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` | Azure Portal → Subscriptions → Subscription ID | + +#### Automate the Setup with a Script + +All four steps can be run with a single script instead of clicking through the portal. The script lives at `infra/scripts/setup-oidc.sh` in the repository. + +**What the script does:** + +* Creates the App Registration (`az ad app create`) +* Creates the Service Principal (`az ad sp create`) +* Adds the Federated Identity Credential for the `main` branch (`az ad app federated-credential create`) +* Creates the Resource Group if it does not yet exist (`az group create`) +* Grants `Contributor` on the Resource Group only (`az role assignment create`) +* Saves `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, and `AZURE_SUBSCRIPTION_ID` directly to GitHub Secrets (`gh secret set`) + +**Prerequisites before running:** + +```bash +# Log in to Azure CLI and select the correct subscription +az login +az account set --subscription "Your Subscription Name" + +# Log in to GitHub CLI (needed to write GitHub Secrets) +gh auth login +``` + +**Run the script once:** + +```bash +# From the root of the repository +chmod +x infra/scripts/setup-oidc.sh +./infra/scripts/setup-oidc.sh +``` + +**Edit the configuration block at the top of the script** before running: + +```bash +APP_NAME="github-actions-talent-dev" # App Registration display name +RESOURCE_GROUP="rg-talent-dev" # Resource group to grant access to +LOCATION="eastus" # Azure region +GITHUB_ORG="workcontrolgit" # GitHub organisation +GITHUB_REPO="AngularNetTutorial" # Repository name +BRANCH="main" # Branch that triggers deployments +``` + +**After the script finishes:** + +The script prints a summary and reminds you to add the one secret it cannot generate — the SQL admin password: + +```bash +gh secret set SQL_ADMIN_PASSWORD --repo workcontrolgit/AngularNetTutorial +``` + +Then verify all four secrets are present at: +`https://github.com/workcontrolgit/AngularNetTutorial/settings/secrets/actions` + +> **Run once per environment.** For a `prod` environment, copy the script, set `APP_NAME="github-actions-talent-prod"` and `RESOURCE_GROUP="rg-talent-prod"`, and run again. This creates a separate App Registration with a separate Federated Credential scoped to the `prod` resource group. + +--- + +#### Principle of Least Privilege + +| Concern | Recommendation | +|---|---| +| Role scope | `Contributor` on the **resource group only** — not the subscription | +| Number of credentials | One App Registration per environment (`dev`, `prod`) — never share credentials across environments | +| Federated credential scope | Lock to a specific branch (`main`) — not `*` for all branches | +| `sqlAdminPassword` | Store as a GitHub secret, pass as a `--parameters` argument to Bicep — never hardcode in `.bicepparam` | + +#### `sqlAdminPassword` — Handling Bicep Secure Parameters + +The SQL admin password is a `@secure()` Bicep parameter. Never put it in `dev.bicepparam`. Instead, pass it at deploy time from a GitHub secret: + +```yaml +- name: Deploy Bicep infrastructure + uses: azure/arm-deploy@v2 + with: + resourceGroupName: rg-talent-dev + template: infra/main.bicep + parameters: > + infra/parameters/dev.bicepparam + sqlAdminPassword=${{ secrets.SQL_ADMIN_PASSWORD }} +``` + +Add `SQL_ADMIN_PASSWORD` as a fourth GitHub repository secret containing the chosen password. + +#### Required GitHub Secrets (Complete List) + +| Secret | Purpose | +|---|---| +| `AZURE_CLIENT_ID` | Identifies the App Registration for OIDC login | +| `AZURE_TENANT_ID` | Identifies the Azure AD tenant | +| `AZURE_SUBSCRIPTION_ID` | Identifies the target Azure subscription | +| `SQL_ADMIN_PASSWORD` | Passed as a secure Bicep parameter — never in source files | + +Set them at: **Repository → Settings → Secrets and variables → Actions → New repository secret**. + +### Triggering the Workflow + +After pushing the workflow file and setting the secrets: + +```bash +# Option 1: Push a change to any file in infra/ — workflow triggers automatically +git add infra/main.bicep +git commit -m "Add initial Bicep infrastructure" +git push + +# Option 2: Trigger manually from GitHub UI +# Go to: Actions → Deploy Infrastructure (Bicep) → Run workflow +``` + +## Required Resources + +### App Service Plan + +- SKU: `B1` +- OS: Linux preferred if both applications support it cleanly +- shared by both Web Apps + +### Web App: API + +- runtime configured for the target .NET version +- app settings for database connection and IdentityServer integration +- HTTPS only enabled + +### Web App: IdentityServer + +- runtime configured for the target .NET version +- app settings for IdentityServer database and external URLs +- HTTPS only enabled + +### Azure SQL logical server + +- administrator login configured as a deployment parameter +- firewall/network access kept minimal + +### SQL databases + +- one database for API +- one database for IdentityServer +- lowest practical starting SKU + +## Bicep Parameters + +Parameter names use **camelCase**. Resource name values follow the [Azure Cloud Adoption Framework (CAF) abbreviation convention](https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-abbreviations): `{type}-{workload}-{qualifier}-{env}`. + +### Infrastructure Parameters + +| Parameter | Type | Example Value | CAF Abbreviation | +|---|---|---|---| +| `location` | string | `eastus` | — | +| `environment` | string | `dev` | suffix for all names | +| `appServicePlanName` | string | `asp-talent-b1-dev` | `asp-` | +| `apiAppName` | string | `app-talent-api-dev` | `app-` | +| `identityAppName` | string | `app-talent-ids-dev` | `app-` | +| `angularStaticWebAppName` | string | `swa-talent-ui-dev` | `swa-` | +| `sqlServerName` | string | `sql-talent-dev` | `sql-` | +| `apiDatabaseName` | string | `sqldb-talent-api-dev` | `sqldb-` | +| `identityDatabaseName` | string | `sqldb-talent-ids-dev` | `sqldb-` | +| `sqlAdminLogin` | string | `sqladmin` | — | +| `sqlAdminPassword` | securestring | *(secret)* | `@secure()` | + +### URL Parameters (used to configure IdentityServer and app settings) + +These parameters are derived from the provisioned resource names and must be passed explicitly or computed in the Bicep template using `reference()`. + +| Parameter | Type | Example Value | Used By | +|---|---|---|---| +| `angularAppUrl` | string | `https://swa-talent-ui-dev.azurestaticapps.net` | IdentityServer redirect URIs, API CORS | +| `apiAppUrl` | string | `https://app-talent-api-dev.azurewebsites.net` | Angular `environment.prod.ts`, IdentityServer | +| `identityAppUrl` | string | `https://app-talent-ids-dev.azurewebsites.net` | Angular `environment.prod.ts`, API `Sts:ServerUrl` | + +> These URL parameters are the critical link between infrastructure provisioning and application configuration. They must be confirmed before configuring any app settings. + +## Security Decisions + +The Bicep implementation should enforce these defaults: + +- `httpsOnly: true` on Web Apps +- no secrets committed into source control +- use parameters for credentials +- avoid unnecessary public exposure +- add managed identity later if required by the application design + +## Configuration Strategy + +The template should create infrastructure only. + +Application secrets and environment-specific values should be applied separately through: + +- Azure App Service configuration +- GitHub Actions deployment configuration +- secure deployment parameters + +Avoid hardcoding live connection strings inside the Bicep files. + +## Deployment Flow + +Recommended flow: + +1. deploy Bicep infrastructure +2. configure application settings +3. run database migrations +4. deploy IdentityServer +5. deploy API +6. validate auth and API connectivity + +## Example Resource Relationships + +- one App Service Plan hosts: + - API Web App + - IdentityServer Web App +- one SQL logical server hosts: + - API database + - IdentityServer database + +## Out of Scope for Initial Template + +These items are intentionally excluded from the first low-cost template unless required later: + +- virtual network integration +- private endpoints +- deployment slots +- premium App Service tiers +- Azure Container Registry +- Container Apps resources + +## Configuration Updates Required for Azure + +Once the Azure resources are provisioned and their URLs are known, the following files must be updated before deploying. + +--- + +### 1. IdentityServer — `identityserverdata.json` + +**File:** `TokenService/Duende-IdentityServer/shared/identityserverdata.json` + +Locate the `TalentManagement` client entry (ClientId: `"TalentManagement"`) and add the Azure SWA URLs alongside the existing localhost entries: + +**`RedirectUris`** — add: +``` +https://{angularAppUrl} +https://{angularAppUrl}/silent-refresh.html +https://{angularAppUrl}/callback +``` + +**`PostLogoutRedirectUris`** — add: +``` +https://{angularAppUrl} +``` + +**`AllowedCorsOrigins`** — add: +``` +https://{angularAppUrl} +``` + +**`ClientUri`** — update to: +``` +https://{angularAppUrl} +``` + +> Keep the `localhost` entries. Removing them will break local development. + +--- + +### 2. Angular — `environment.prod.ts` + +**File:** `Clients/TalentManagement-Angular-Material/talent-management/src/environments/environment.prod.ts` + +Current values that need updating: + +```typescript +// BEFORE +apiUrl: 'https://your-production-api.com/api/v1', +identityServerUrl: 'https://localhost:44310', + +// AFTER +apiUrl: 'https://{apiAppUrl}/api/v1', +identityServerUrl: 'https://{identityAppUrl}', +``` + +> In GitHub Actions, these can be injected at build time using `sed` or Angular's `fileReplacements` with environment variables, so the actual URLs do not need to be hardcoded in source control. + +--- + +### 3. API — App Service Configuration (not `appsettings.json`) + +**Do not** commit Azure connection strings or URLs to `appsettings.json`. Instead, set these in **Azure App Service → Configuration → Application settings**: + +| Setting Name | Value | +|---|---| +| `ConnectionStrings__DefaultConnection` | Azure SQL connection string for `sqldb-talent-api-dev` | +| `Sts__ServerUrl` | `https://{identityAppUrl}` | +| `Sts__ValidIssuer` | `https://{identityAppUrl}` | +| `AllowedHosts` | `app-talent-api-dev.azurewebsites.net` | + +> The current `appsettings.json` has `"AllowedHosts": "*"` and `Sts:ServerUrl: "https://localhost:44310"`. Both must be overridden via App Service configuration in Azure. + +--- + +### 4. API — CORS Configuration + +The API must allow cross-origin requests from the Angular Static Web App domain. + +Check the CORS configuration in `appsettings.json` (or the `AddCorsExtension` call in `Program.cs`) and add: + +``` +https://{angularAppUrl} +``` + +as an allowed origin for the production CORS policy. + +--- + +### 5. IdentityServer — App Service Configuration + +Set in **Azure App Service → Configuration → Application settings** for `app-talent-ids-dev`: + +| Setting Name | Value | +|---|---| +| `ConnectionStrings__ConfigurationDbConnection` | Azure SQL connection string for `sqldb-talent-ids-dev` | +| `ConnectionStrings__PersistedGrantDbConnection` | Azure SQL connection string for `sqldb-talent-ids-dev` | +| `ConnectionStrings__IdentityDbConnection` | Azure SQL connection string for `sqldb-talent-ids-dev` | +| `AdminConfiguration__IdentityAdminBaseUrl` | `https://app-talent-ids-dev.azurewebsites.net` | + +--- + +### Configuration Update Checklist + +- [ ] Add Azure SWA URLs to `RedirectUris` in `identityserverdata.json` +- [ ] Add Azure SWA URL to `PostLogoutRedirectUris` in `identityserverdata.json` +- [ ] Add Azure SWA URL to `AllowedCorsOrigins` in `identityserverdata.json` +- [ ] Update `environment.prod.ts` `apiUrl` to Azure API URL +- [ ] Update `environment.prod.ts` `identityServerUrl` to Azure IdentityServer URL +- [ ] Set `Sts__ServerUrl` and `Sts__ValidIssuer` in API App Service configuration +- [ ] Set `ConnectionStrings__DefaultConnection` in API App Service configuration +- [ ] Set IdentityServer database connection strings in IdentityServer App Service configuration +- [ ] Configure CORS in API to allow Angular Static Web App domain +- [ ] Create `staticwebapp.config.json` in Angular for SPA route fallback + +--- + +## Decision Summary + +The Bicep template should provision the smallest practical Azure footprint for this solution: + +- one shared `B1` App Service Plan +- two Web Apps +- one shared Azure SQL logical server +- two databases + +This keeps the infrastructure aligned with the low-cost hosting decision already documented in `docs/azure-deployment-plan.md`. diff --git a/docs/deployment/azure-deployment-plan.md b/docs/deployment/azure-deployment-plan.md new file mode 100644 index 0000000..482b1d3 --- /dev/null +++ b/docs/deployment/azure-deployment-plan.md @@ -0,0 +1,299 @@ +# Azure Deployment Plan + +## Goal + +Deploy the full Talent Management stack — Angular client, .NET API, and IdentityServer — to Azure from GitHub at the lowest practical monthly cost, while keeping the design clean enough to support the existing IdentityServer integration and future growth. + +This plan assumes: + +- Azure budget target is approximately `$50/month` +- The API and IdentityServer will both run in Azure App Service +- The Angular SPA will be deployed to Azure Static Web Apps +- A single Azure SQL logical server will be shared +- The SQL server will host two databases: + - `TalentManagementApiDb` + - `IdentityServerDb` + +## Final Decision + +Use the following Azure footprint: + +1. One Azure Resource Group +2. One Azure App Service Plan on the `Basic B1` tier +3. Two Azure Web Apps on that same App Service Plan + - one for the API + - one for IdentityServer +4. One Azure Static Web App for the Angular client +5. One shared Azure SQL logical server +6. Two separate Azure SQL databases on that server + - one for the API + - one for IdentityServer +7. GitHub Actions for CI/CD using Azure OpenID Connect authentication + +## Why This Decision + +This is the lowest-cost practical option for the current requirement. + +### App Service decision + +Azure App Service was selected instead of Azure Container Apps because: + +- it is simpler to operate for standard ASP.NET Core applications +- it avoids container-specific complexity +- it fits the current repo better for straightforward GitHub Actions deployment +- it is easier to keep cost predictable for a small two-app setup + +The `Basic B1` App Service Plan was selected because: + +- it is the lowest dedicated App Service tier appropriate for a real hosted app +- both the API and IdentityServer can share the same plan +- Azure charges at the App Service Plan level, so placing both apps on one plan is significantly cheaper than separate plans + +### Database decision + +A single Azure SQL logical server with two databases was selected because: + +- the API and IdentityServer remain logically isolated +- administration stays simple +- cost stays lower than using more infrastructure than necessary +- this matches the stated requirement that both apps share the same SQL server + +Each application gets its own database because: + +- it avoids coupling the application data model to the IdentityServer schema +- migrations stay independent +- backup/restore and troubleshooting remain cleaner + +### Angular deployment decision + +Azure Static Web Apps was selected for the Angular client because: + +- it is purpose-built for static SPAs and has a free tier suitable for development and demo +- it provides built-in GitHub Actions CI/CD with automatic preview deployments for pull requests +- it includes a built-in CDN and global edge distribution at no extra cost +- it avoids the overhead of running a web server just to serve static files +- adding a third App Service Web App for a static SPA would waste compute resources and increase cost unnecessarily + +The Angular app will be built via `ng build` and the `dist/talent-management/browser` output folder deployed as static assets. + +### CI/CD decision + +GitHub Actions with Azure OpenID Connect was selected because: + +- it integrates directly with the GitHub repository +- it avoids long-lived deployment credentials where possible +- it is the recommended modern deployment model for Azure from GitHub + +## Target Architecture + +### Azure resources + +- Resource Group: one shared resource group for the environment +- App Service Plan: `Basic B1` +- Web App 1: Talent Management API +- Web App 2: IdentityServer +- Static Web App: Angular client (Free tier) +- Azure SQL logical server: shared +- Azure SQL database 1: API database +- Azure SQL database 2: IdentityServer database + +### Networking and configuration + +- keep all App Service resources in the same Azure region +- store connection strings in App Service configuration, not in source control +- store feature flags and environment-specific settings in App Service configuration +- keep production secrets out of `appsettings.json` +- configure Angular production `environment.ts` with Azure API and IdentityServer URLs at build time via GitHub Actions environment variables +- set CORS on the API Web App to allow requests from the Static Web App domain + +## Cost-First Constraints + +This design is intentionally optimized for cost first, not scale first. + +Expected cost controls: + +- one shared `B1` App Service Plan instead of two plans +- Angular on Azure Static Web Apps Free tier (no compute cost) +- one shared Azure SQL logical server +- smallest practical database tiers at the start +- no extra container registry unless later required +- no premium networking or premium compute features initially + +## Known Tradeoffs + +This design has limits that need to be acknowledged. + +### Angular Static Web Apps Free tier + +The Free tier has limits that may require upgrading to Standard (~$9/month): + +- custom domain with free managed TLS is supported +- no SLA on the Free tier +- limited staging environments on Free (only 3 pre-production environments) +- if a backend API proxy or serverless functions are added later, Standard is required + +For development, demo, and MVP usage the Free tier is appropriate. Upgrade to Standard when an SLA or additional staging environments are needed. + +### Shared App Service Plan + +Both applications will compete for the same compute resources. + +Implications: + +- heavy IdentityServer traffic can affect API responsiveness +- heavy API traffic can affect login/token endpoints +- scaling one app means scaling both apps together while they share the same plan + +This is acceptable for: + +- development +- demo +- MVP +- low-traffic internal usage + +This is not ideal for: + +- medium or high production traffic +- independent scaling needs +- strict performance isolation + +### Low-cost SQL tiers + +Starting with the cheapest SQL option reduces cost, but performance headroom is limited. + +Implications: + +- query throughput is limited +- IdentityServer persisted grant activity may become a bottleneck earlier than expected +- future upgrade to a higher tier may be required + +## Recommended Starting SKUs + +These are the initial SKUs to provision unless testing shows they are too small. + +### Compute + +- Azure App Service Plan: `Basic B1` + +### Databases + +- `TalentManagementApiDb`: low-cost Azure SQL single database tier +- `IdentityServerDb`: low-cost Azure SQL single database tier + +The exact database SKU should be confirmed in the Azure Pricing Calculator at provisioning time because pricing changes over time and may vary by region. + +## Deployment Flow + +### Phase 1: Provision Azure resources + +Create: + +1. Resource Group +2. App Service Plan (`B1`) +3. Web App for the API +4. Web App for IdentityServer +5. Static Web App for the Angular client (Free tier) +6. Shared Azure SQL logical server +7. API database +8. IdentityServer database + +### Phase 2: Configure application settings + +For the API Web App: + +- API database connection string +- IdentityServer authority / STS URL +- JWT and auth-related settings +- feature flags +- environment-specific URLs + +For the IdentityServer Web App: + +- IdentityServer database connection string +- signing and runtime settings +- client/app URLs (including Angular Static Web App URL as allowed redirect/logout URI) + +For the Angular Static Web App: + +- `API_URL` — production API base URL (injected at build time via GitHub Actions) +- `IDENTITY_SERVER_URL` — production IdentityServer URL +- configure `staticwebapp.config.json` for SPA fallback routing (all routes return `index.html`) + +### Phase 3: Configure GitHub deployment + +Set up GitHub Actions authentication with Azure using: + +- `AZURE_CLIENT_ID` +- `AZURE_TENANT_ID` +- `AZURE_SUBSCRIPTION_ID` + +Then create deployment workflows for: + +1. IdentityServer +2. Talent Management API +3. Angular client + +For the .NET workflows (IdentityServer and API), each workflow should: + +1. restore +2. build +3. test +4. publish +5. deploy to the target Web App + +For the Angular workflow: + +1. install (`npm ci`) +2. build production (`ng build --configuration production`) +3. deploy `dist/talent-management/browser` to Azure Static Web Apps using the `Azure/static-web-apps-deploy` GitHub Action + +Note: Azure Static Web Apps GitHub Actions integration can be bootstrapped automatically by the Azure portal — it commits a workflow file directly to the repository. This is the easiest starting point. + +### Phase 4: Database migration strategy + +Run migrations separately for each application: + +- API migrations against `TalentManagementApiDb` +- IdentityServer migrations against `IdentityServerDb` + +Migration execution should be explicit and environment-aware. Do not assume both apps can safely auto-migrate on startup in production. + +### Phase 5: Validation + +Validate: + +- Angular app loads at the Static Web App URL +- login redirects to IdentityServer and returns to Angular correctly +- API health and Swagger endpoint +- IdentityServer discovery endpoint +- database connectivity +- token issuance +- API token validation against IdentityServer +- Angular API calls return data with Bearer token (check Network tab) + +## Environment Naming Proposal + +Suggested dev naming: + +- Resource Group: `rg-talent-dev` +- App Service Plan: `asp-talent-b1-dev` +- API Web App: `app-talent-api-dev` +- Identity Web App: `app-talent-ids-dev` +- Angular Static Web App: `swa-talent-ui-dev` +- SQL Server: `sql-talent-dev` +- API DB: `sqldb-talent-api-dev` +- Identity DB: `sqldb-talent-ids-dev` + +## Decision Summary + +The chosen Azure deployment design is: + +- Azure App Service, not Container Apps +- one shared `Basic B1` App Service Plan +- two Web Apps on that shared plan (API + IdentityServer) +- Azure Static Web Apps Free tier for the Angular client +- one shared Azure SQL logical server +- two separate Azure SQL databases +- GitHub Actions with Azure OpenID Connect + +This is the best fit for the current requirement because it keeps monthly cost low, uses the right hosting model for each component (static hosting for the SPA, managed web servers for .NET), respects the shared SQL server constraint, and remains simple to deploy and operate from GitHub. diff --git a/docs/deployment/github-actions-api-deployment.md b/docs/deployment/github-actions-api-deployment.md new file mode 100644 index 0000000..755d4f9 --- /dev/null +++ b/docs/deployment/github-actions-api-deployment.md @@ -0,0 +1,141 @@ +# GitHub Actions Deployment Plan for Talent Management API + +## Purpose + +This document defines the GitHub Actions deployment approach for the `TalentManagementAPI.WebApi` project when deploying to Azure App Service. + +The target Azure resource is a dedicated Web App hosted on a shared `Basic B1` App Service Plan. + +## Deployment Target + +- Azure App Service +- separate Web App for the API +- shared App Service Plan with IdentityServer + +## Authentication Model + +Use Azure OpenID Connect from GitHub Actions. + +Required GitHub secrets: + +- `AZURE_CLIENT_ID` +- `AZURE_TENANT_ID` +- `AZURE_SUBSCRIPTION_ID` + +This avoids storing long-lived publish-profile credentials in GitHub. + +## Workflow Responsibilities + +The API workflow should: + +1. trigger on pushes to the deployment branch +2. restore NuGet packages +3. build the solution +4. run tests +5. publish the API project +6. deploy the published output to the API Web App + +## Suggested Workflow File + +Store the workflow as: + +- `.github/workflows/deploy-api.yml` + +## Recommended Trigger + +Start with: + +- push to `main` + +Optional later refinements: + +- path filtering for API-related files only +- manual dispatch for controlled releases +- environment approvals for production + +## Recommended Build Scope + +Restore and build from the solution root: + +- `TalentManagementAPI.slnx` + +Publish only: + +- `TalentManagementAPI.WebApi/TalentManagementAPI.WebApi.csproj` + +## Suggested Workflow Structure + +```yaml +name: Deploy Talent Management API + +on: + push: + branches: [ main ] + +permissions: + id-token: write + contents: read + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Restore + run: dotnet restore TalentManagementAPI.slnx + + - name: Build + run: dotnet build TalentManagementAPI.slnx -c Release --no-restore + + - name: Test + run: dotnet test TalentManagementAPI.slnx -c Release --no-build + + - name: Publish API + run: dotnet publish TalentManagementAPI.WebApi/TalentManagementAPI.WebApi.csproj -c Release -o ./publish/api + + - name: Azure Login + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Deploy API Web App + uses: azure/webapps-deploy@v3 + with: + app-name: app-talent-api-dev + package: ./publish/api +``` + +## App Settings Required in Azure + +These settings should be configured in the Azure Web App, not committed to source control: + +- API database connection string +- `Sts:ServerUrl` +- `Sts:ValidIssuer` +- `Sts:Audience` +- feature flags such as `FeatureManagement__AiEnabled` +- any production-specific cache and mail settings + +## Notes About Database Migration + +Do not couple deployment to automatic production migration unless the migration strategy has been reviewed. + +Preferred options: + +1. separate migration step in the release process +2. controlled manual migration +3. explicit pipeline step added later after validation + +## Decision Summary + +The API deployment workflow should be a standard GitHub Actions build/test/publish/deploy pipeline targeting Azure App Service through OpenID Connect authentication. diff --git a/docs/deployment/github-actions-identityserver-deployment.md b/docs/deployment/github-actions-identityserver-deployment.md new file mode 100644 index 0000000..34f0775 --- /dev/null +++ b/docs/deployment/github-actions-identityserver-deployment.md @@ -0,0 +1,120 @@ +# GitHub Actions Deployment Plan for IdentityServer + +## Purpose + +This document defines the GitHub Actions deployment approach for the IdentityServer application that supports the Talent Management API. + +IdentityServer is deployed separately from the API, but both applications share the same Azure App Service Plan. + +## Deployment Target + +- Azure App Service +- separate Web App for IdentityServer +- shared `Basic B1` App Service Plan with the API + +## Authentication Model + +Use Azure OpenID Connect from GitHub Actions. + +Required GitHub secrets: + +- `AZURE_CLIENT_ID` +- `AZURE_TENANT_ID` +- `AZURE_SUBSCRIPTION_ID` + +If the API and IdentityServer are deployed from separate repositories, each repository must hold its own GitHub secrets or use GitHub environments with shared policy. + +## Workflow Responsibilities + +The IdentityServer workflow should: + +1. trigger on pushes to the deployment branch +2. restore packages +3. build the IdentityServer solution or project +4. run tests +5. publish the IdentityServer host project +6. deploy the published output to the IdentityServer Web App + +## Suggested Workflow File + +Store the workflow as: + +- `.github/workflows/deploy-identityserver.yml` + +## Suggested Workflow Structure + +The exact project path depends on the IdentityServer repository layout, but the structure should mirror the API deployment pipeline: + +```yaml +name: Deploy IdentityServer + +on: + push: + branches: [ main ] + +permissions: + id-token: write + contents: read + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Restore + run: dotnet restore + + - name: Build + run: dotnet build -c Release --no-restore + + - name: Test + run: dotnet test -c Release --no-build + + - name: Publish IdentityServer + run: dotnet publish .csproj -c Release -o ./publish/identity + + - name: Azure Login + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Deploy Identity Web App + uses: azure/webapps-deploy@v3 + with: + app-name: app-talent-ids-dev + package: ./publish/identity +``` + +## IdentityServer Settings Required in Azure + +These settings should be stored in Azure App Service configuration: + +- IdentityServer database connection string +- issuer URL / public origin settings +- client URLs +- signing and certificate-related settings +- external provider settings if used + +## Operational Notes + +IdentityServer is a dependency for the API authentication flow. + +That means: + +- IdentityServer should generally be deployed before the API when auth-related changes are involved +- the API configuration must point to the correct Azure IdentityServer URL +- post-deployment validation must include the discovery document and token issuance flow + +## Decision Summary + +IdentityServer should have its own GitHub Actions deployment workflow and its own Web App, even though it shares the same App Service Plan and Azure SQL logical server with the API. diff --git a/infra/main.bicep b/infra/main.bicep new file mode 100644 index 0000000..4245a65 --- /dev/null +++ b/infra/main.bicep @@ -0,0 +1,108 @@ +// ============================================================================= +// main.bicep — Talent Management full-stack Azure infrastructure +// ============================================================================= +// Provisions all resources for the AngularNetTutorial three-tier stack: +// - App Service Plan (B1, shared) +// - Web App: Talent Management API +// - Web App: Duende IdentityServer +// - Static Web App: Angular client (Free tier) +// - Azure SQL logical server (shared) +// - SQL database: TalentManagementApiDb +// - SQL database: IdentityServerDb +// +// Deploy command (from repo root): +// az deployment group create \ +// --resource-group rg-talent-dev \ +// --template-file infra/main.bicep \ +// --parameters infra/parameters/dev.bicepparam \ +// --parameters sqlAdminPassword=$SQL_ADMIN_PASSWORD +// ============================================================================= + +@description('Azure region for all resources') +param location string = resourceGroup().location + +@description('Name of the App Service Plan') +param appServicePlanName string + +@description('Name of the API Web App') +param apiAppName string + +@description('Name of the IdentityServer Web App') +param identityAppName string + +@description('Name of the Angular Static Web App') +param staticWebAppName string + +@description('Name of the Azure SQL logical server') +param sqlServerName string + +@description('Name of the API database') +param apiDbName string + +@description('Name of the IdentityServer database') +param identityDbName string + +@description('SQL administrator login name') +param sqlAdminLogin string + +@description('SQL administrator password — pass from GitHub Secret, never store in parameters file') +@secure() +param sqlAdminPassword string + +// ─── App Service Plan ───────────────────────────────────────────────────────── +module appServicePlan 'modules/appServicePlan.bicep' = { + name: 'appServicePlan' + params: { + appServicePlanName: appServicePlanName + location: location + } +} + +// ─── Web Apps ───────────────────────────────────────────────────────────────── +module apiApp 'modules/webApp.bicep' = { + name: 'apiApp' + params: { + webAppName: apiAppName + location: location + appServicePlanId: appServicePlan.outputs.id + } +} + +module identityApp 'modules/webApp.bicep' = { + name: 'identityApp' + params: { + webAppName: identityAppName + location: location + appServicePlanId: appServicePlan.outputs.id + } +} + +// ─── Angular Static Web App ─────────────────────────────────────────────────── +module angularSwa 'modules/staticWebApp.bicep' = { + name: 'angularSwa' + params: { + staticWebAppName: staticWebAppName + location: location + } +} + +// ─── SQL Server + Databases ─────────────────────────────────────────────────── +module sqlServer 'modules/sqlServer.bicep' = { + name: 'sqlServer' + params: { + sqlServerName: sqlServerName + location: location + sqlAdminLogin: sqlAdminLogin + sqlAdminPassword: sqlAdminPassword + apiDbName: apiDbName + identityDbName: identityDbName + } +} + +// ─── Outputs (used by deployment workflows and post-deployment config) ───────── +output apiAppUrl string = apiApp.outputs.url +output identityAppUrl string = identityApp.outputs.url +output angularAppUrl string = angularSwa.outputs.url +output sqlServerFqdn string = sqlServer.outputs.sqlServerFqdn +output apiDbConnectionString string = sqlServer.outputs.apiDbConnectionString +output identityDbConnectionString string = sqlServer.outputs.identityDbConnectionString diff --git a/infra/modules/appServicePlan.bicep b/infra/modules/appServicePlan.bicep new file mode 100644 index 0000000..3c37093 --- /dev/null +++ b/infra/modules/appServicePlan.bicep @@ -0,0 +1,19 @@ +@description('Name of the App Service Plan') +param appServicePlanName string + +@description('Azure region for all resources') +param location string + +resource appServicePlan 'Microsoft.Web/serverfarms@2023-01-01' = { + name: appServicePlanName + location: location + sku: { + name: 'B1' + tier: 'Basic' + } + properties: { + reserved: false // Windows + } +} + +output id string = appServicePlan.id diff --git a/infra/modules/sqlServer.bicep b/infra/modules/sqlServer.bicep new file mode 100644 index 0000000..6535ce3 --- /dev/null +++ b/infra/modules/sqlServer.bicep @@ -0,0 +1,73 @@ +@description('Name of the Azure SQL logical server') +param sqlServerName string + +@description('Azure region for all resources') +param location string + +@description('SQL administrator login name') +param sqlAdminLogin string + +@description('SQL administrator password') +@secure() +param sqlAdminPassword string + +@description('Name of the API database') +param apiDbName string + +@description('Name of the IdentityServer database') +param identityDbName string + +resource sqlServer 'Microsoft.Sql/servers@2023-05-01-preview' = { + name: sqlServerName + location: location + properties: { + administratorLogin: sqlAdminLogin + administratorLoginPassword: sqlAdminPassword + minimalTlsVersion: '1.2' + publicNetworkAccess: 'Enabled' + } +} + +// Allow Azure services to connect (required for App Service) +resource allowAzureServices 'Microsoft.Sql/servers/firewallRules@2023-05-01-preview' = { + parent: sqlServer + name: 'AllowAllWindowsAzureIps' + properties: { + startIpAddress: '0.0.0.0' + endIpAddress: '0.0.0.0' + } +} + +resource apiDatabase 'Microsoft.Sql/servers/databases@2023-05-01-preview' = { + parent: sqlServer + name: apiDbName + location: location + sku: { + name: 'Basic' + tier: 'Basic' + capacity: 5 + } + properties: { + collation: 'SQL_Latin1_General_CP1_CI_AS' + maxSizeBytes: 2147483648 // 2 GB + } +} + +resource identityDatabase 'Microsoft.Sql/servers/databases@2023-05-01-preview' = { + parent: sqlServer + name: identityDbName + location: location + sku: { + name: 'Basic' + tier: 'Basic' + capacity: 5 + } + properties: { + collation: 'SQL_Latin1_General_CP1_CI_AS' + maxSizeBytes: 2147483648 // 2 GB + } +} + +output sqlServerFqdn string = sqlServer.properties.fullyQualifiedDomainName +output apiDbConnectionString string = 'Server=tcp:${sqlServer.properties.fullyQualifiedDomainName},1433;Initial Catalog=${apiDbName};Persist Security Info=False;User ID=${sqlAdminLogin};Password=${sqlAdminPassword};MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;' +output identityDbConnectionString string = 'Server=tcp:${sqlServer.properties.fullyQualifiedDomainName},1433;Initial Catalog=${identityDbName};Persist Security Info=False;User ID=${sqlAdminLogin};Password=${sqlAdminPassword};MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;' diff --git a/infra/modules/staticWebApp.bicep b/infra/modules/staticWebApp.bicep new file mode 100644 index 0000000..d13471f --- /dev/null +++ b/infra/modules/staticWebApp.bicep @@ -0,0 +1,23 @@ +@description('Name of the Static Web App') +param staticWebAppName string + +@description('Azure region for all resources') +param location string + +resource staticWebApp 'Microsoft.Web/staticSites@2023-01-01' = { + name: staticWebAppName + location: location + sku: { + name: 'Free' + tier: 'Free' + } + properties: { + buildProperties: { + skipGithubActionWorkflowGeneration: true + } + } +} + +output id string = staticWebApp.id +output defaultHostName string = staticWebApp.properties.defaultHostname +output url string = 'https://${staticWebApp.properties.defaultHostname}' diff --git a/infra/modules/webApp.bicep b/infra/modules/webApp.bicep new file mode 100644 index 0000000..b3f032d --- /dev/null +++ b/infra/modules/webApp.bicep @@ -0,0 +1,30 @@ +@description('Name of the Web App') +param webAppName string + +@description('Azure region for all resources') +param location string + +@description('Resource ID of the App Service Plan') +param appServicePlanId string + +@description('Stack: dotnet, node, python, etc.') +param linuxFxVersion string = '' + +resource webApp 'Microsoft.Web/sites@2023-01-01' = { + name: webAppName + location: location + properties: { + serverFarmId: appServicePlanId + httpsOnly: true + siteConfig: { + netFrameworkVersion: 'v10.0' + http20Enabled: true + minTlsVersion: '1.2' + ftpsState: 'Disabled' + } + } +} + +output id string = webApp.id +output defaultHostName string = webApp.properties.defaultHostName +output url string = 'https://${webApp.properties.defaultHostName}' diff --git a/infra/parameters/dev.bicepparam b/infra/parameters/dev.bicepparam new file mode 100644 index 0000000..aa58388 --- /dev/null +++ b/infra/parameters/dev.bicepparam @@ -0,0 +1,20 @@ +// dev.bicepparam — parameter values for the dev environment +// DO NOT add sqlAdminPassword here — pass it at deploy time from a GitHub Secret: +// --parameters sqlAdminPassword=$SQL_ADMIN_PASSWORD + +using '../main.bicep' + +// ─── Resource naming (Cloud Adoption Framework convention) ──────────────────── +// Pattern: {type}-{workload}-{qualifier}-{env} + +param appServicePlanName = 'asp-talent-b1-dev' +param apiAppName = 'app-talent-api-dev' +param identityAppName = 'app-talent-ids-dev' +param staticWebAppName = 'swa-talent-ui-dev' +param sqlServerName = 'sql-talent-dev' +param apiDbName = 'sqldb-talent-api-dev' +param identityDbName = 'sqldb-talent-ids-dev' + +// ─── SQL admin login ────────────────────────────────────────────────────────── +// Password is passed at deploy time — never stored here +param sqlAdminLogin = 'sqladmin' diff --git a/infra/scripts/setup-oidc.sh b/infra/scripts/setup-oidc.sh new file mode 100644 index 0000000..247c349 --- /dev/null +++ b/infra/scripts/setup-oidc.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# ============================================================================= +# setup-oidc.sh — One-time Azure OIDC setup for GitHub Actions +# ============================================================================= +# +# Run this script ONCE per environment to wire up passwordless deployment. +# It creates an App Registration, adds a Federated Identity Credential so +# GitHub Actions can authenticate with Azure using short-lived OIDC tokens +# (no stored passwords), and saves the three required values as GitHub secrets. +# +# Prerequisites: +# az login (logged in to Azure CLI) +# az account set --subscription "..." (correct subscription selected) +# gh auth login (logged in to GitHub CLI) +# +# Usage: +# chmod +x infra/scripts/setup-oidc.sh +# ./infra/scripts/setup-oidc.sh +# ============================================================================= + +set -euo pipefail + +# ─── Configuration ──────────────────────────────────────────────────────────── +# Edit these values before running. + +APP_NAME="github-actions-talent-dev" # App Registration display name in Azure AD +RESOURCE_GROUP="rg-talent-dev" # Resource group the deployment identity can manage +LOCATION="eastus" # Azure region for the resource group +GITHUB_ORG="workcontrolgit" # GitHub organisation or username +GITHUB_REPO="AngularNetTutorial" # GitHub repository name (no owner prefix) +BRANCH="main" # Branch that triggers deployments +# ────────────────────────────────────────────────────────────────────────────── + +echo "" +echo "========================================================" +echo " Azure OIDC Setup for GitHub Actions" +echo " App: $APP_NAME" +echo " Repo: $GITHUB_ORG/$GITHUB_REPO (branch: $BRANCH)" +echo " RG: $RESOURCE_GROUP ($LOCATION)" +echo "========================================================" +echo "" + +# ─── Step 1: Create App Registration ────────────────────────────────────────── +echo ">>> Step 1: Create App Registration" + +APP_ID=$(az ad app create \ + --display-name "$APP_NAME" \ + --query appId \ + --output tsv) + +echo " Created App Registration: $APP_ID" + +# ─── Step 2: Create Service Principal ───────────────────────────────────────── +echo ">>> Step 2: Create Service Principal" + +az ad sp create --id "$APP_ID" --output none + +echo " Service Principal created" + +# ─── Step 3: Add Federated Identity Credential ──────────────────────────────── +echo ">>> Step 3: Add Federated Identity Credential" +# +# This tells Azure: "trust JWT tokens from GitHub Actions for this exact +# repo and branch — no password needed." + +FEDERATED_CREDENTIAL=$(cat <>> Step 4a: Create Resource Group (idempotent)" + +az group create \ + --name "$RESOURCE_GROUP" \ + --location "$LOCATION" \ + --output none + +echo " Resource group ready: $RESOURCE_GROUP" + +# ─── Step 4b: Grant Contributor Role on Resource Group ──────────────────────── +echo ">>> Step 4b: Grant Contributor role on $RESOURCE_GROUP" +# +# Scoped to the resource group only — not the entire subscription. +# Least-privilege: the deployment identity can only touch rg-talent-dev. + +SUBSCRIPTION_ID=$(az account show --query id --output tsv) +SCOPE="/subscriptions/${SUBSCRIPTION_ID}/resourceGroups/${RESOURCE_GROUP}" + +az role assignment create \ + --assignee "$APP_ID" \ + --role "Contributor" \ + --scope "$SCOPE" \ + --output none + +echo " Contributor role granted on: $SCOPE" + +# ─── Step 5: Retrieve Tenant ID ─────────────────────────────────────────────── +TENANT_ID=$(az account show --query tenantId --output tsv) + +# ─── Step 6: Save Secrets to GitHub Repository ──────────────────────────────── +echo ">>> Step 6: Save secrets to GitHub repository" + +gh secret set AZURE_CLIENT_ID --body "$APP_ID" --repo "${GITHUB_ORG}/${GITHUB_REPO}" +gh secret set AZURE_TENANT_ID --body "$TENANT_ID" --repo "${GITHUB_ORG}/${GITHUB_REPO}" +gh secret set AZURE_SUBSCRIPTION_ID --body "$SUBSCRIPTION_ID" --repo "${GITHUB_ORG}/${GITHUB_REPO}" + +echo " AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID saved" + +# ─── Done ───────────────────────────────────────────────────────────────────── +echo "" +echo "========================================================" +echo " Summary" +echo "========================================================" +echo " AZURE_CLIENT_ID: $APP_ID" +echo " AZURE_TENANT_ID: $TENANT_ID" +echo " AZURE_SUBSCRIPTION_ID: $SUBSCRIPTION_ID" +echo "" +echo " One secret still needed — set it manually:" +echo "" +echo " gh secret set SQL_ADMIN_PASSWORD --repo ${GITHUB_ORG}/${GITHUB_REPO}" +echo "" +echo " Then verify all 4 secrets at:" +echo " https://github.com/${GITHUB_ORG}/${GITHUB_REPO}/settings/secrets/actions" +echo "" +echo " OIDC setup complete. GitHub Actions can now deploy to Azure" +echo " without any stored passwords or client secrets." +echo "========================================================"