diff --git a/.github/workflows/cd-cleanup.yml b/.github/workflows/cd-cleanup.yml index eb926c0..7f1f2eb 100644 --- a/.github/workflows/cd-cleanup.yml +++ b/.github/workflows/cd-cleanup.yml @@ -1,4 +1,4 @@ -# Cloud env safety net: nightly scale dev/ppe container apps to min=0/max=3 so a misconfigured +# Cloud env safety net: nightly scale ci/ppe container apps to min=0/max=3 so a misconfigured # bicepparam (or manual `az` poke) can't keep instances pinned overnight and rack up bills. # Idempotent. Skips prod intentionally. name: cd-cleanup @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest # Per-matrix-env GitHub Environment so each iteration uses its own # AZURE_CLIENT_ID / AZURE_SUBSCRIPTION_ID vars + secrets. Without this, - # both dev and ppe would resolve from whatever single environment was + # both ci and ppe would resolve from whatever single environment was # hard-coded here, defeating the per-env credential separation that # the rest of CD relies on. environment: ${{ matrix.env }} @@ -26,7 +26,7 @@ jobs: strategy: fail-fast: false matrix: - env: [dev, ppe] + env: [ci, ppe] steps: - uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 with: diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 82b5f4f..391a003 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -1,16 +1,16 @@ # Cloud CD pipeline: builds the FTGO service images, pushes to GHCR, and deploys. # # TRIGGERS: -# - push to main → build + deploy-dev only +# - push to main → build + deploy-ci only # - workflow_dispatch → build + deploy through to chosen env: -# environment=dev → dev -# environment=ppe → dev → ppe -# environment=prod → dev → ppe → prod (requires reviewer on prod environment) +# environment=ci → ci +# environment=ppe → ci → ppe +# environment=prod → ci → ppe → prod (requires reviewer on prod environment) # # ppe and prod NEVER run on push — they require an explicit dispatch with the env input. # # Per-env deploy logic lives in the reusable workflow .github/workflows/deploy-env.yml -# so dev/ppe/prod stay in lock-step. To change deploy behaviour for ALL envs, +# so ci/ppe/prod stay in lock-step. To change deploy behaviour for ALL envs, # edit deploy-env.yml once. # # Environment GitHub vars expected per env: AZURE_CLIENT_ID, AZURE_SUBSCRIPTION_ID @@ -24,11 +24,11 @@ on: workflow_dispatch: inputs: environment: - description: Target environment (dev|ppe|prod). Defaults to full dev→ppe→prod chain. + description: Target environment (ci|ppe|prod). Defaults to full ci→ppe→prod chain. required: false - default: dev + default: ci type: choice - options: [dev, ppe, prod] + options: [ci, ppe, prod] imageTag: description: Existing image tag (e.g. sha-abc1234) to redeploy. Leave blank to build a fresh one from HEAD. required: false @@ -169,12 +169,12 @@ jobs: fi echo "imageTag=${TAG}" >> "$GITHUB_OUTPUT" - deploy-dev: + deploy-ci: needs: resolve-image-tag if: | always() && needs.resolve-image-tag.result == 'success' && (github.event_name == 'push' || - github.event.inputs.environment == 'dev' || + github.event.inputs.environment == 'ci' || github.event.inputs.environment == 'ppe' || github.event.inputs.environment == 'prod') permissions: @@ -182,14 +182,14 @@ jobs: contents: read uses: ./.github/workflows/deploy-env.yml with: - environment: dev + environment: ci image_tag: ${{ needs.resolve-image-tag.outputs.imageTag }} secrets: inherit deploy-ppe: - needs: [resolve-image-tag, deploy-dev] + needs: [resolve-image-tag, deploy-ci] if: | - always() && needs.deploy-dev.result == 'success' && + always() && needs.deploy-ci.result == 'success' && github.event_name == 'workflow_dispatch' && (github.event.inputs.environment == 'ppe' || github.event.inputs.environment == 'prod') diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 232cb8f..b54b6d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -126,7 +126,7 @@ jobs: run: | set -euo pipefail fail=0 - for env in dev ppe prod; do + for env in ci ppe prod; do for stem in main bootstrap azure; do file="infra/bicep/${stem}.${env}.bicepparam" [ -f "$file" ] || continue diff --git a/.github/workflows/deploy-env.yml b/.github/workflows/deploy-env.yml index 8be5e09..75fc3e2 100644 --- a/.github/workflows/deploy-env.yml +++ b/.github/workflows/deploy-env.yml @@ -1,7 +1,7 @@ # Reusable CD workflow: deploy infra/bicep/azure.bicep to a single environment. # -# Called once per env (dev / ppe / prod) from cd.yml. The caller is responsible -# for ordering (deploy-dev → deploy-ppe → deploy-prod) and for opting an env +# Called once per env (ci / ppe / prod) from cd.yml. The caller is responsible +# for ordering (deploy-ci → deploy-ppe → deploy-prod) and for opting an env # in/out via `if:`. This workflow assumes: # # - GitHub Environment named ${{ inputs.environment }} exists with vars @@ -18,7 +18,7 @@ on: workflow_call: inputs: environment: - description: Target environment name (dev|ppe|prod). Used for the GitHub Environment, RG name suffix, and bicepparam file. + description: Target deployment-tier name (ci|ppe|prod). Used for the GitHub Environment, RG name suffix, and bicepparam file. required: true type: string image_tag: diff --git a/DOCTRINE.md b/DOCTRINE.md index 3dcf760..54c8fc8 100644 --- a/DOCTRINE.md +++ b/DOCTRINE.md @@ -21,29 +21,29 @@ The rule is one-way: this repo **cites** the upstream guides; the upstream guide ## Doctrine reference table -| Topic in this repo | Upstream owner | Section / chapter | -| --- | --- | --- | -| ASP.NET Core auth (`scp` vs `roles`+`azp` named policies, JWT defaults, multi-tenant `IssuerValidator`, `tid` allow-list, deny-by-default) | dotnet-engineering-guide | [`docs/02-aspnetcore.md` §10 Authn/Authz](https://github.com/mghabin/dotnet-engineering-guide/blob/main/docs/02-aspnetcore.md#10-authnauthz) | -| HTTP pipeline order, problem-details, model binding, minimal APIs vs MVC | dotnet-engineering-guide | [`docs/02-aspnetcore.md`](https://github.com/mghabin/dotnet-engineering-guide/blob/main/docs/02-aspnetcore.md) | -| HttpClient + Polly resilience pipeline, named clients, timeouts | dotnet-engineering-guide | [`docs/02-aspnetcore.md`](https://github.com/mghabin/dotnet-engineering-guide/blob/main/docs/02-aspnetcore.md), [`docs/05-performance.md`](https://github.com/mghabin/dotnet-engineering-guide/blob/main/docs/05-performance.md) | -| Testing strategy (unit / integration / WebApplicationFactory / Testcontainers / contract) | dotnet-engineering-guide | [`docs/04-testing.md`](https://github.com/mghabin/dotnet-engineering-guide/blob/main/docs/04-testing.md) | -| Allocation, async, perf hot-paths | dotnet-engineering-guide | [`docs/05-performance.md`](https://github.com/mghabin/dotnet-engineering-guide/blob/main/docs/05-performance.md) | -| Cloud-native runtime (containers, health, config, secrets at runtime) | dotnet-engineering-guide | [`docs/06-cloud-native.md`](https://github.com/mghabin/dotnet-engineering-guide/blob/main/docs/06-cloud-native.md) | -| C# language features, project layout, nullable, analyzers | dotnet-engineering-guide | [`docs/01-foundations.md`](https://github.com/mghabin/dotnet-engineering-guide/blob/main/docs/01-foundations.md) | -| EF Core, Dapper, transactions, outbox | dotnet-engineering-guide | [`docs/03-data.md`](https://github.com/mghabin/dotnet-engineering-guide/blob/main/docs/03-data.md) | -| Decision trees a senior .NET architect actually faces | dotnet-engineering-guide | [`docs/decision-trees.md`](https://github.com/mghabin/dotnet-engineering-guide/blob/main/docs/decision-trees.md) | -| IaC tooling decision (Bicep vs Terraform vs Pulumi), module shape | infra-engineering-guide | [`docs/02-iac-tooling.md`](https://github.com/mghabin/infra-engineering-guide/blob/main/docs/02-iac-tooling.md) | -| CI/CD pipelines, image promotion by SHA, environments, gates | infra-engineering-guide | [`docs/03-ci-cd.md`](https://github.com/mghabin/infra-engineering-guide/blob/main/docs/03-ci-cd.md) | -| Container build (chiseled / distroless / non-root), Kubernetes / ACA defaults | infra-engineering-guide | [`docs/04-containers-k8s.md`](https://github.com/mghabin/infra-engineering-guide/blob/main/docs/04-containers-k8s.md) | -| Observability (OpenTelemetry → Azure Monitor / Loki / Tempo), SLOs | infra-engineering-guide | [`docs/05-observability.md`](https://github.com/mghabin/infra-engineering-guide/blob/main/docs/05-observability.md) | -| Security supply-chain (SLSA, pinact, OpenSSF Scorecard, Dependabot, gitleaks, dependency-review, CodeQL) | infra-engineering-guide | [`docs/06-security-supply-chain.md`](https://github.com/mghabin/infra-engineering-guide/blob/main/docs/06-security-supply-chain.md) | -| Networking (private endpoints, egress, VNet, WAF, mTLS) | infra-engineering-guide | [`docs/07-networking.md`](https://github.com/mghabin/infra-engineering-guide/blob/main/docs/07-networking.md) | -| Data-state (managed DB defaults, backup, residency) | infra-engineering-guide | [`docs/08-data-state.md`](https://github.com/mghabin/infra-engineering-guide/blob/main/docs/08-data-state.md) | -| Reliability (SLI / SLO / error budget, retry / timeout / circuit, blast-radius) | infra-engineering-guide | [`docs/09-reliability.md`](https://github.com/mghabin/infra-engineering-guide/blob/main/docs/09-reliability.md) | -| FinOps (idle cost, scale-to-zero, ACR Basic, daily caps, teardown) | infra-engineering-guide | [`docs/10-finops.md`](https://github.com/mghabin/infra-engineering-guide/blob/main/docs/10-finops.md) — repo-local specialization in [`docs/cost-zero.md`](./docs/cost-zero.md) | -| Platform engineering (golden paths, paved roads, multi-tenant compute) | infra-engineering-guide | [`docs/11-platform-engineering.md`](https://github.com/mghabin/infra-engineering-guide/blob/main/docs/11-platform-engineering.md) | -| Workload identity (Entra workload-id, FIC, MI, OIDC issuer, SPIFFE) — *infra-side* | infra-engineering-guide | [`docs/12-identity.md`](https://github.com/mghabin/infra-engineering-guide/blob/main/docs/12-identity.md) | -| Entra app-side patterns: token acquisition, validation, credential picker, sample registrations | **this repo** | [`docs/`](./docs/) + [`coverage-map.md`](./coverage-map.md) | +| Topic in this repo | Upstream owner | Section / chapter | +| ------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| ASP.NET Core auth (`scp` vs `roles`+`azp` named policies, JWT defaults, multi-tenant `IssuerValidator`, `tid` allow-list, deny-by-default) | dotnet-engineering-guide | [`docs/02-aspnetcore.md` §10 Authn/Authz](https://github.com/mghabin/dotnet-engineering-guide/blob/main/docs/02-aspnetcore.md#10-authnauthz) | +| HTTP pipeline order, problem-details, model binding, minimal APIs vs MVC | dotnet-engineering-guide | [`docs/02-aspnetcore.md`](https://github.com/mghabin/dotnet-engineering-guide/blob/main/docs/02-aspnetcore.md) | +| HttpClient + Polly resilience pipeline, named clients, timeouts | dotnet-engineering-guide | [`docs/02-aspnetcore.md`](https://github.com/mghabin/dotnet-engineering-guide/blob/main/docs/02-aspnetcore.md), [`docs/05-performance.md`](https://github.com/mghabin/dotnet-engineering-guide/blob/main/docs/05-performance.md) | +| Testing strategy (unit / integration / WebApplicationFactory / Testcontainers / contract) | dotnet-engineering-guide | [`docs/04-testing.md`](https://github.com/mghabin/dotnet-engineering-guide/blob/main/docs/04-testing.md) | +| Allocation, async, perf hot-paths | dotnet-engineering-guide | [`docs/05-performance.md`](https://github.com/mghabin/dotnet-engineering-guide/blob/main/docs/05-performance.md) | +| Cloud-native runtime (containers, health, config, secrets at runtime) | dotnet-engineering-guide | [`docs/06-cloud-native.md`](https://github.com/mghabin/dotnet-engineering-guide/blob/main/docs/06-cloud-native.md) | +| C# language features, project layout, nullable, analyzers | dotnet-engineering-guide | [`docs/01-foundations.md`](https://github.com/mghabin/dotnet-engineering-guide/blob/main/docs/01-foundations.md) | +| EF Core, Dapper, transactions, outbox | dotnet-engineering-guide | [`docs/03-data.md`](https://github.com/mghabin/dotnet-engineering-guide/blob/main/docs/03-data.md) | +| Decision trees a senior .NET architect actually faces | dotnet-engineering-guide | [`docs/decision-trees.md`](https://github.com/mghabin/dotnet-engineering-guide/blob/main/docs/decision-trees.md) | +| IaC tooling decision (Bicep vs Terraform vs Pulumi), module shape | infra-engineering-guide | [`docs/02-iac-tooling.md`](https://github.com/mghabin/infra-engineering-guide/blob/main/docs/02-iac-tooling.md) | +| CI/CD pipelines, image promotion by SHA, environments, gates | infra-engineering-guide | [`docs/03-ci-cd.md`](https://github.com/mghabin/infra-engineering-guide/blob/main/docs/03-ci-cd.md) | +| Container build (chiseled / distroless / non-root), Kubernetes / ACA defaults | infra-engineering-guide | [`docs/04-containers-k8s.md`](https://github.com/mghabin/infra-engineering-guide/blob/main/docs/04-containers-k8s.md) | +| Observability (OpenTelemetry → Azure Monitor / Loki / Tempo), SLOs | infra-engineering-guide | [`docs/05-observability.md`](https://github.com/mghabin/infra-engineering-guide/blob/main/docs/05-observability.md) | +| Security supply-chain (SLSA, pinact, OpenSSF Scorecard, Dependabot, gitleaks, dependency-review, CodeQL) | infra-engineering-guide | [`docs/06-security-supply-chain.md`](https://github.com/mghabin/infra-engineering-guide/blob/main/docs/06-security-supply-chain.md) | +| Networking (private endpoints, egress, VNet, WAF, mTLS) | infra-engineering-guide | [`docs/07-networking.md`](https://github.com/mghabin/infra-engineering-guide/blob/main/docs/07-networking.md) | +| Data-state (managed DB defaults, backup, residency) | infra-engineering-guide | [`docs/08-data-state.md`](https://github.com/mghabin/infra-engineering-guide/blob/main/docs/08-data-state.md) | +| Reliability (SLI / SLO / error budget, retry / timeout / circuit, blast-radius) | infra-engineering-guide | [`docs/09-reliability.md`](https://github.com/mghabin/infra-engineering-guide/blob/main/docs/09-reliability.md) | +| FinOps (idle cost, scale-to-zero, ACR Basic, daily caps, teardown) | infra-engineering-guide | [`docs/10-finops.md`](https://github.com/mghabin/infra-engineering-guide/blob/main/docs/10-finops.md) — repo-local specialization in [`docs/cost-zero.md`](./docs/cost-zero.md) | +| Platform engineering (golden paths, paved roads, multi-tenant compute) | infra-engineering-guide | [`docs/11-platform-engineering.md`](https://github.com/mghabin/infra-engineering-guide/blob/main/docs/11-platform-engineering.md) | +| Workload identity (Entra workload-id, FIC, MI, OIDC issuer, SPIFFE) — *infra-side* | infra-engineering-guide | [`docs/12-identity.md`](https://github.com/mghabin/infra-engineering-guide/blob/main/docs/12-identity.md) | +| Entra app-side patterns: token acquisition, validation, credential picker, sample registrations | **this repo** | [`docs/`](./docs/) + [`coverage-map.md`](./coverage-map.md) | --- @@ -55,7 +55,7 @@ This is the **only** layer you should not look upstream for: - The `OrdersDelegated` / `OrdersApp` named-policy split that operationalizes dotnet-guide ch02 §10. - The BFF-style `SignedAssertionFromManagedIdentity` pattern (clarified as **not** OIDC-FIC) and its disambiguation. - The credential-picker for *Entra app-credentials* (MI / FIC / cert / secret) including the three documented exceptions for client secret. -- The per-environment app-registration and FIC creation Bicep modules, parameterized for dev / ppe / prod. +- The per-environment app-registration and FIC creation Bicep modules, parameterized for ci / ppe / prod. - The end-to-end runnable FTGO demo (ApiGateway, Auth, Auth.Client, Orders.Api, Restaurants.Api, Kitchen.Worker) that exercises all of the above on Azure Container Apps. Everything else — language, framework, IaC, CI/CD, observability, FinOps, supply-chain — defers upstream. If a doctrine claim in this repo cannot be traced to a row in the table above or a primary source cited in the relevant doc's `## Sources` block, treat it as a bug and open an issue. diff --git a/README.md b/README.md index ea9fa1a..e25eed7 100644 --- a/README.md +++ b/README.md @@ -30,13 +30,13 @@ business-capability name. ## What's in here -| Service | Auth shape demonstrated | -|-------------------------------|--------------------------------------------------------| -| `Ftgo.ApiGateway` | API gateway / token aggregator: client (Scalar) signs the user in via Auth Code + PKCE, gateway validates the bearer JWT and performs server-side OBO + app-only fan-out (uses `SignedAssertionFromManagedIdentity`) | -| `Ftgo.Orders.Api` | Single-tenant resource API (delegated **xor** app — separate named policies, never both) | -| `Ftgo.Restaurants.Api` | Multi-tenant resource API (app-only, tenant allow-list)| -| `Ftgo.Kitchen.Worker` | Worker — **Managed Identity** direct (canonical Azure pattern) | -| `Ftgo.Auth` / `Ftgo.Auth.Client` | One-line `AddEntraAuth(...)` library | +| Service | Auth shape demonstrated | +| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Ftgo.ApiGateway` | API gateway / token aggregator: client (Scalar) signs the user in via Auth Code + PKCE, gateway validates the bearer JWT and performs server-side OBO + app-only fan-out (uses `SignedAssertionFromManagedIdentity`) | +| `Ftgo.Orders.Api` | Single-tenant resource API (delegated **xor** app — separate named policies, never both) | +| `Ftgo.Restaurants.Api` | Multi-tenant resource API (app-only, tenant allow-list) | +| `Ftgo.Kitchen.Worker` | Worker — **Managed Identity** direct (canonical Azure pattern) | +| `Ftgo.Auth` / `Ftgo.Auth.Client` | One-line `AddEntraAuth(...)` library | For the cert / FIC-on-GitHub / client-secret patterns, see [`docs/credential-patterns/`](docs/credential-patterns/) — they are @@ -50,7 +50,7 @@ flowchart LR User([User browser]):::external Entra[("Microsoft Entra ID")]:::entra - subgraph ACA["Azure Container Apps env (per env: dev / ppe / prod)"] + subgraph ACA["Azure Container Apps env (per tier: ci / ppe / prod)"] BFF["Ftgo.ApiGateway
(API gateway · system-MI + FIC)"]:::svc Orders["Ftgo.Orders.Api
(system-MI · resource API)"]:::svc Restaurants["Ftgo.Restaurants.Api
(system-MI · multi-tenant resource API)"]:::svc @@ -70,8 +70,8 @@ flowchart LR classDef svc fill:#e6f7ee,stroke:#2f855a,color:#22543d ``` -Three Entra app registrations per environment (`bff`, `orderservice`, -`restaurantservice`). The kitchen worker doesn't need its own app reg — +Three Entra app registrations per tier (`apigateway` (BFF), `orders-api`, +`restaurants-api`). The kitchen worker doesn't need its own app reg — its **system-assigned** MI's service principal is granted the resource API's app role directly. Zero client secrets, zero certificates: BFF uses **Federated Identity Credential** (the @@ -92,12 +92,12 @@ To run against real Entra patterns end-to-end, deploy to a free-tier cloud env ( ## Deploy to the cloud -Free-tier Azure Container Apps deployment with **dev → ppe → prod** promotion via GitHub Actions OIDC, image promotion by SHA, App Insights observability, zero stored client secrets: +Free-tier Azure Container Apps deployment with **ci → ppe → prod** promotion via GitHub Actions OIDC, image promotion by SHA, App Insights observability, zero stored client secrets: ```bash -./scripts/bootstrap-env.sh ENV=dev # one-time: GH OIDC UAMI + RG + RPs -git push origin main # auto-deploys to dev (only) -./scripts/provision-apps.sh ENV=dev # one-time per env (until app regs change): app regs + BFF FIC + MI grants + ENTRA_CONFIG_JSON GitHub var +./scripts/bootstrap-env.sh ENV=ci # one-time: GH OIDC UAMI + RG + RPs +git push origin main # auto-deploys to ci (only) +./scripts/provision-apps.sh ENV=ci # one-time per tier (until app regs change): app regs + BFF FIC + MI grants + ENTRA_CONFIG_JSON GitHub var gh workflow run cd.yml -f environment=ppe # manual promotion to ppe (auto-promotion is OFF — keeps idle cost at $0; see docs/cost-zero.md) ``` @@ -146,11 +146,11 @@ Full set of decision trees → [`docs/decision-trees.md`](docs/decision-trees.md ## Library cheat-sheet -| Library | Best for | Notes | -|---|---|---| -| **Microsoft.Identity.Web** | ASP.NET Core APIs / web apps | Wraps MSAL + JwtBearer; handles validation, OBO, token cache. Default choice for ASP.NET Core. | -| **MSAL.NET** (`Microsoft.Identity.Client`) | Non-ASP.NET hosts (workers, libraries) needing Entra-specific features (OBO, claims challenges, CAE, broker) | Lower-level than Microsoft.Identity.Web. | -| **Azure.Identity** (`TokenCredential`) | Calling **Azure resources** (Storage, Key Vault, Cosmos, Service Bus, Graph via SDK, etc.) | `DefaultAzureCredential` chains MI, env, VS, CLI. Not for arbitrary OAuth flows; does not implement OBO. | +| Library | Best for | Notes | +| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- | +| **Microsoft.Identity.Web** | ASP.NET Core APIs / web apps | Wraps MSAL + JwtBearer; handles validation, OBO, token cache. Default choice for ASP.NET Core. | +| **MSAL.NET** (`Microsoft.Identity.Client`) | Non-ASP.NET hosts (workers, libraries) needing Entra-specific features (OBO, claims challenges, CAE, broker) | Lower-level than Microsoft.Identity.Web. | +| **Azure.Identity** (`TokenCredential`) | Calling **Azure resources** (Storage, Key Vault, Cosmos, Service Bus, Graph via SDK, etc.) | `DefaultAzureCredential` chains MI, env, VS, CLI. Not for arbitrary OAuth flows; does not implement OBO. | ## Contributing diff --git a/coverage-map.md b/coverage-map.md index 2c98350..dc96002 100644 --- a/coverage-map.md +++ b/coverage-map.md @@ -16,7 +16,7 @@ Primary sources for ownership: the chapters themselves under [`docs/`](./docs/), - Example: [`acquisition.md`](./docs/acquisition.md) owns the *credential picker for token requests*; [`credential-patterns/`](./docs/credential-patterns/) owns the *credential type itself* (MI vs FIC vs cert vs secret). Acquisition links to credential-patterns, never re-derives it. - Example: [`validation.md`](./docs/validation.md) owns *how a server validates a JWT*; [`best-practices.md`](./docs/best-practices.md) restates the rule in checklist form but never adds new doctrine. - Example: [`matrix.md`](./docs/matrix.md) owns the *scenario-by-scenario picker*; it links to acquisition / validation / credential-patterns and never re-decides any cell. -- Example: [`sample-setup.md`](./docs/sample-setup.md) owns *this sample's* app registrations and role taxonomy; [`deploy-cloud.md`](./docs/deploy-cloud.md) owns *how those registrations get into dev / ppe / prod*. +- Example: [`sample-setup.md`](./docs/sample-setup.md) owns *this sample's* app registrations and role taxonomy; [`deploy-cloud.md`](./docs/deploy-cloud.md) owns *how those registrations get into ci / ppe / prod*. - A chapter may **demonstrate** a deferred concept in a code sample, but it must not redefine the rule. The rule lives in the owner. --- @@ -25,32 +25,32 @@ Primary sources for ownership: the chapters themselves under [`docs/`](./docs/), One row per concept. The Owner is the single source of truth; siblings link, never re-decide. -| Concept | Owner | Sibling references | -|---|---|---| -| App tokens (S2S / daemon) — `client_credentials`, MI, FIC, cert, secret | [`acquisition.md`](./docs/acquisition.md) §1 | [`matrix.md`](./docs/matrix.md), [`credential-patterns/index.md`](./docs/credential-patterns/index.md), [`best-practices.md`](./docs/best-practices.md) | -| User tokens (delegated) — auth-code + PKCE on the client, validation on the API | [`acquisition.md`](./docs/acquisition.md) §2 | [`validation.md`](./docs/validation.md), [`matrix.md`](./docs/matrix.md) | -| On-Behalf-Of (OBO) flow | [`acquisition.md`](./docs/acquisition.md) §2b | [`matrix.md`](./docs/matrix.md), [`sample-setup.md`](./docs/sample-setup.md) | -| Token caching (MSAL / Microsoft.Identity.Web in-memory + distributed) | [`acquisition.md`](./docs/acquisition.md) | [`best-practices.md`](./docs/best-practices.md), [`aks-shared-infra.md`](./docs/aks-shared-infra.md) | -| Library picks: Azure.Identity vs MSAL.NET vs Microsoft.Identity.Web | [`acquisition.md`](./docs/acquisition.md) §1e | [`README.md`](./README.md) cheat-sheet, [`matrix.md`](./docs/matrix.md) | -| JWT signature, audience, issuer, lifetime validation | [`validation.md`](./docs/validation.md) §1 | [`best-practices.md`](./docs/best-practices.md), [`matrix.md`](./docs/matrix.md) | -| `scp` (delegated scope) vs `roles` (app role) split | [`validation.md`](./docs/validation.md) §2 | [`matrix.md`](./docs/matrix.md), [`best-practices.md`](./docs/best-practices.md), dotnet-guide [ch02 §10](https://github.com/mghabin/dotnet-engineering-guide/blob/main/docs/02-aspnetcore.md#10-authnauthz) | -| v1 vs v2 audiences, token-version detection | [`validation.md`](./docs/validation.md) §3 | [`acquisition.md`](./docs/acquisition.md), [`sample-setup.md`](./docs/sample-setup.md) | -| Multi-tenant `IssuerValidator` (`AadIssuerValidator`) + `tid` allow-list | [`validation.md`](./docs/validation.md) §3 | [`best-practices.md`](./docs/best-practices.md), [`matrix.md`](./docs/matrix.md) | -| App-token `azp` / `appid` allow-list (per-client gate) | [`validation.md`](./docs/validation.md) §4 | [`best-practices.md`](./docs/best-practices.md), dotnet-guide [ch02 §10](https://github.com/mghabin/dotnet-engineering-guide/blob/main/docs/02-aspnetcore.md#10-authnauthz) | -| CAE (Continuous Access Evaluation) and ACRS (claims challenges) wiring | [`validation.md`](./docs/validation.md) §6 | [`best-practices.md`](./docs/best-practices.md) | -| One-page numbered checklist (distillation only; no new doctrine) | [`best-practices.md`](./docs/best-practices.md) | [`README.md`](./README.md), [`matrix.md`](./docs/matrix.md) | -| Scenario-by-scenario decision table | [`matrix.md`](./docs/matrix.md) | All other chapters link here for "which scenario am I in?" | -| Credential picker (MI / FIC / cert / secret) | [`credential-patterns/index.md`](./docs/credential-patterns/index.md) | [`acquisition.md`](./docs/acquisition.md), [`matrix.md`](./docs/matrix.md), [`best-practices.md`](./docs/best-practices.md) | -| Managed Identity — UAMI vs SAMI, IMDS endpoint, `DefaultAzureCredential` | [`credential-patterns/managed-identity.md`](./docs/credential-patterns/managed-identity.md) | [`acquisition.md`](./docs/acquisition.md), [`run-locally.md`](./docs/run-locally.md), [`deploy-cloud.md`](./docs/deploy-cloud.md) | -| Federated Identity Credential (FIC), RFC 8693 Token Exchange, GitHub OIDC subject pinning, `SignedAssertionFromManagedIdentity` (clarified as **not** OIDC-FIC) | [`credential-patterns/federated-identity.md`](./docs/credential-patterns/federated-identity.md) | [`deploy-cloud.md`](./docs/deploy-cloud.md), [`sample-setup.md`](./docs/sample-setup.md), [`acquisition.md`](./docs/acquisition.md) | -| Certificate credential — when acceptable, `EphemeralKeySet`, rotation cadence | [`credential-patterns/cert.md`](./docs/credential-patterns/cert.md) | [`best-practices.md`](./docs/best-practices.md), [`matrix.md`](./docs/matrix.md) | -| Client secret — anti-pattern + the three documented exceptions | [`credential-patterns/client-secret.md`](./docs/credential-patterns/client-secret.md) | [`best-practices.md`](./docs/best-practices.md), [`matrix.md`](./docs/matrix.md) | -| Shared platform doctrine for N services × M products on AKS | [`aks-shared-infra.md`](./docs/aks-shared-infra.md) | [`SCOPE.md`](./SCOPE.md), [`best-practices.md`](./docs/best-practices.md) | -| This sample's app registrations, role taxonomy, audience model | [`sample-setup.md`](./docs/sample-setup.md) | [`run-locally.md`](./docs/run-locally.md), [`deploy-cloud.md`](./docs/deploy-cloud.md), [`acquisition.md`](./docs/acquisition.md) | -| CI/CD, image promotion by SHA, per-env provisioning | [`deploy-cloud.md`](./docs/deploy-cloud.md) | [`environments.md`](./docs/environments.md), [`operations.md`](./docs/operations.md) | -| Operations runbook — what-if previews, branch protection, env teardown, "on fire" | [`operations.md`](./docs/operations.md) | [`deploy-cloud.md`](./docs/deploy-cloud.md), [`environments.md`](./docs/environments.md) | -| Local development — `az login` + `DefaultAzureCredential`, OIDC sign-in browser flow | [`run-locally.md`](./docs/run-locally.md) | [`sample-setup.md`](./docs/sample-setup.md), [`credential-patterns/managed-identity.md`](./docs/credential-patterns/managed-identity.md) | -| Promotion model (dev → ppe → prod), per-env config, production guardrails | [`environments.md`](./docs/environments.md) | [`deploy-cloud.md`](./docs/deploy-cloud.md), [`operations.md`](./docs/operations.md) | +| Concept | Owner | Sibling references | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| App tokens (S2S / daemon) — `client_credentials`, MI, FIC, cert, secret | [`acquisition.md`](./docs/acquisition.md) §1 | [`matrix.md`](./docs/matrix.md), [`credential-patterns/index.md`](./docs/credential-patterns/index.md), [`best-practices.md`](./docs/best-practices.md) | +| User tokens (delegated) — auth-code + PKCE on the client, validation on the API | [`acquisition.md`](./docs/acquisition.md) §2 | [`validation.md`](./docs/validation.md), [`matrix.md`](./docs/matrix.md) | +| On-Behalf-Of (OBO) flow | [`acquisition.md`](./docs/acquisition.md) §2b | [`matrix.md`](./docs/matrix.md), [`sample-setup.md`](./docs/sample-setup.md) | +| Token caching (MSAL / Microsoft.Identity.Web in-memory + distributed) | [`acquisition.md`](./docs/acquisition.md) | [`best-practices.md`](./docs/best-practices.md), [`aks-shared-infra.md`](./docs/aks-shared-infra.md) | +| Library picks: Azure.Identity vs MSAL.NET vs Microsoft.Identity.Web | [`acquisition.md`](./docs/acquisition.md) §1e | [`README.md`](./README.md) cheat-sheet, [`matrix.md`](./docs/matrix.md) | +| JWT signature, audience, issuer, lifetime validation | [`validation.md`](./docs/validation.md) §1 | [`best-practices.md`](./docs/best-practices.md), [`matrix.md`](./docs/matrix.md) | +| `scp` (delegated scope) vs `roles` (app role) split | [`validation.md`](./docs/validation.md) §2 | [`matrix.md`](./docs/matrix.md), [`best-practices.md`](./docs/best-practices.md), dotnet-guide [ch02 §10](https://github.com/mghabin/dotnet-engineering-guide/blob/main/docs/02-aspnetcore.md#10-authnauthz) | +| v1 vs v2 audiences, token-version detection | [`validation.md`](./docs/validation.md) §3 | [`acquisition.md`](./docs/acquisition.md), [`sample-setup.md`](./docs/sample-setup.md) | +| Multi-tenant `IssuerValidator` (`AadIssuerValidator`) + `tid` allow-list | [`validation.md`](./docs/validation.md) §3 | [`best-practices.md`](./docs/best-practices.md), [`matrix.md`](./docs/matrix.md) | +| App-token `azp` / `appid` allow-list (per-client gate) | [`validation.md`](./docs/validation.md) §4 | [`best-practices.md`](./docs/best-practices.md), dotnet-guide [ch02 §10](https://github.com/mghabin/dotnet-engineering-guide/blob/main/docs/02-aspnetcore.md#10-authnauthz) | +| CAE (Continuous Access Evaluation) and ACRS (claims challenges) wiring | [`validation.md`](./docs/validation.md) §6 | [`best-practices.md`](./docs/best-practices.md) | +| One-page numbered checklist (distillation only; no new doctrine) | [`best-practices.md`](./docs/best-practices.md) | [`README.md`](./README.md), [`matrix.md`](./docs/matrix.md) | +| Scenario-by-scenario decision table | [`matrix.md`](./docs/matrix.md) | All other chapters link here for "which scenario am I in?" | +| Credential picker (MI / FIC / cert / secret) | [`credential-patterns/index.md`](./docs/credential-patterns/index.md) | [`acquisition.md`](./docs/acquisition.md), [`matrix.md`](./docs/matrix.md), [`best-practices.md`](./docs/best-practices.md) | +| Managed Identity — UAMI vs SAMI, IMDS endpoint, `DefaultAzureCredential` | [`credential-patterns/managed-identity.md`](./docs/credential-patterns/managed-identity.md) | [`acquisition.md`](./docs/acquisition.md), [`run-locally.md`](./docs/run-locally.md), [`deploy-cloud.md`](./docs/deploy-cloud.md) | +| Federated Identity Credential (FIC), RFC 8693 Token Exchange, GitHub OIDC subject pinning, `SignedAssertionFromManagedIdentity` (clarified as **not** OIDC-FIC) | [`credential-patterns/federated-identity.md`](./docs/credential-patterns/federated-identity.md) | [`deploy-cloud.md`](./docs/deploy-cloud.md), [`sample-setup.md`](./docs/sample-setup.md), [`acquisition.md`](./docs/acquisition.md) | +| Certificate credential — when acceptable, `EphemeralKeySet`, rotation cadence | [`credential-patterns/cert.md`](./docs/credential-patterns/cert.md) | [`best-practices.md`](./docs/best-practices.md), [`matrix.md`](./docs/matrix.md) | +| Client secret — anti-pattern + the three documented exceptions | [`credential-patterns/client-secret.md`](./docs/credential-patterns/client-secret.md) | [`best-practices.md`](./docs/best-practices.md), [`matrix.md`](./docs/matrix.md) | +| Shared platform doctrine for N services × M products on AKS | [`aks-shared-infra.md`](./docs/aks-shared-infra.md) | [`SCOPE.md`](./SCOPE.md), [`best-practices.md`](./docs/best-practices.md) | +| This sample's app registrations, role taxonomy, audience model | [`sample-setup.md`](./docs/sample-setup.md) | [`run-locally.md`](./docs/run-locally.md), [`deploy-cloud.md`](./docs/deploy-cloud.md), [`acquisition.md`](./docs/acquisition.md) | +| CI/CD, image promotion by SHA, per-env provisioning | [`deploy-cloud.md`](./docs/deploy-cloud.md) | [`environments.md`](./docs/environments.md), [`operations.md`](./docs/operations.md) | +| Operations runbook — what-if previews, branch protection, env teardown, "on fire" | [`operations.md`](./docs/operations.md) | [`deploy-cloud.md`](./docs/deploy-cloud.md), [`environments.md`](./docs/environments.md) | +| Local development — `az login` + `DefaultAzureCredential`, OIDC sign-in browser flow | [`run-locally.md`](./docs/run-locally.md) | [`sample-setup.md`](./docs/sample-setup.md), [`credential-patterns/managed-identity.md`](./docs/credential-patterns/managed-identity.md) | +| Promotion model (ci → ppe → prod), per-env config, production guardrails | [`environments.md`](./docs/environments.md) | [`deploy-cloud.md`](./docs/deploy-cloud.md), [`operations.md`](./docs/operations.md) | --- @@ -59,16 +59,16 @@ One row per concept. The Owner is the single source of truth; siblings link, nev When a topic surfaces in more than one chapter, exactly one chapter owns it. The others reference; they do not re-decide. -| Concern | Owner | Why it lives there | -|---|---|---| -| Auth policy shape (delegated `scp` vs app-only `roles` + `azp`) | [`validation.md`](./docs/validation.md) §2 + §4 | The validation pipeline is where the policy is enforced; mirrors dotnet-guide [ch02 §10](https://github.com/mghabin/dotnet-engineering-guide/blob/main/docs/02-aspnetcore.md#10-authnauthz). | -| Multi-tenant validation (`organizations` authority + `IssuerValidator` + `tid` allow-list) | [`validation.md`](./docs/validation.md) §3 | Single decision point; both signature and tenant gating are here. | -| Credential type per host (Azure compute → MI; OIDC issuer → FIC; portable hardware → cert) | [`credential-patterns/index.md`](./docs/credential-patterns/index.md) | The picker is the single owner; per-credential pages own the *how*. | -| `SignedAssertionFromManagedIdentity` (BFF token-exchange) — **not** OIDC-FIC | [`credential-patterns/federated-identity.md`](./docs/credential-patterns/federated-identity.md) | Same chapter as FIC but explicitly disambiguates the two — mistaking them swaps the credential model. | -| Token cache backing store (in-memory vs distributed Redis vs SQL) | [`acquisition.md`](./docs/acquisition.md) | The cache is part of how a token is acquired; sibling chapters link here. | -| App registration shape, audience, role taxonomy for *this sample* | [`sample-setup.md`](./docs/sample-setup.md) | Sample-specific; the doctrine for "what an app reg should look like in general" lives in [`acquisition.md`](./docs/acquisition.md) and [`validation.md`](./docs/validation.md). | -| Per-env app-reg provisioning and FIC creation in CI | [`deploy-cloud.md`](./docs/deploy-cloud.md) | Operational; the *credential type* doctrine is owned by credential-patterns. | -| Local dev credential model (`DefaultAzureCredential` chain) | [`run-locally.md`](./docs/run-locally.md) | Operational; the *credential type* doctrine is owned by credential-patterns. | +| Concern | Owner | Why it lives there | +| ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Auth policy shape (delegated `scp` vs app-only `roles` + `azp`) | [`validation.md`](./docs/validation.md) §2 + §4 | The validation pipeline is where the policy is enforced; mirrors dotnet-guide [ch02 §10](https://github.com/mghabin/dotnet-engineering-guide/blob/main/docs/02-aspnetcore.md#10-authnauthz). | +| Multi-tenant validation (`organizations` authority + `IssuerValidator` + `tid` allow-list) | [`validation.md`](./docs/validation.md) §3 | Single decision point; both signature and tenant gating are here. | +| Credential type per host (Azure compute → MI; OIDC issuer → FIC; portable hardware → cert) | [`credential-patterns/index.md`](./docs/credential-patterns/index.md) | The picker is the single owner; per-credential pages own the *how*. | +| `SignedAssertionFromManagedIdentity` (BFF token-exchange) — **not** OIDC-FIC | [`credential-patterns/federated-identity.md`](./docs/credential-patterns/federated-identity.md) | Same chapter as FIC but explicitly disambiguates the two — mistaking them swaps the credential model. | +| Token cache backing store (in-memory vs distributed Redis vs SQL) | [`acquisition.md`](./docs/acquisition.md) | The cache is part of how a token is acquired; sibling chapters link here. | +| App registration shape, audience, role taxonomy for *this sample* | [`sample-setup.md`](./docs/sample-setup.md) | Sample-specific; the doctrine for "what an app reg should look like in general" lives in [`acquisition.md`](./docs/acquisition.md) and [`validation.md`](./docs/validation.md). | +| Per-env app-reg provisioning and FIC creation in CI | [`deploy-cloud.md`](./docs/deploy-cloud.md) | Operational; the *credential type* doctrine is owned by credential-patterns. | +| Local dev credential model (`DefaultAzureCredential` chain) | [`run-locally.md`](./docs/run-locally.md) | Operational; the *credential type* doctrine is owned by credential-patterns. | --- diff --git a/docs/best-practices.md b/docs/best-practices.md index 6a2fc63..3f200e2 100644 --- a/docs/best-practices.md +++ b/docs/best-practices.md @@ -50,7 +50,7 @@ Cross-reference: see [decision-trees.md Tree 4](decision-trees.md#4-does-this-en - **must** define app roles for S2S; **must not** reuse delegated scopes for app permissions. - **must** grant the minimum app role to the minimum SP (the MI's SP, the worker's app reg) — least privilege. -- **must** use separate app registrations for separate environments (dev/test/prod). **Must not** share secrets across rings. +- **must** use separate app registrations for separate environments (local/ci/test/prod). **Must not** share secrets across rings. - **must** keep an explicit list of provisioned tenants for multi-tenant SaaS; provision via admin consent, deprovision on offboarding. ## Operational diff --git a/docs/cost-zero.md b/docs/cost-zero.md index b018b34..2ccaf4c 100644 --- a/docs/cost-zero.md +++ b/docs/cost-zero.md @@ -6,17 +6,17 @@ This sample is built to cost **~$0/month when idle** so it can sit in a personal ## Idle cost ceiling per env -| Resource | SKU / config | Idle bill | Reason it stays at $0 | -|---|---|---|---| -| Azure Container Apps environment | **Consumption** plan (not Workload Profiles) | $0 | Consumption ACA charges only per-second vCPU + memory + per-request; **no fixed environment fee**. Workload-Profiles plans bill the profile flat-rate even when idle — we deliberately avoid them. | -| ACA app: `apigateway`, `orders-api`, `restaurants-api`, `kitchen-worker` | `minReplicas=0`, `maxReplicas=1`, `co.cooldownPeriod=300` | $0 | At `minReplicas=0` ACA scales to zero after the cooldown — no replicas means no vCPU-seconds billed. First request after idle takes a cold-start hit (~1-3s for a chiseled .NET image). | -| Log Analytics workspace | `PerGB2018`, `dailyQuotaGb=1`, `retentionInDays=30` | $0 | First **5 GB/month** of ingest is free per Azure subscription. The 1 GB daily cap is a hard guard against runaway log spam blowing the free tier. | -| Application Insights | **Workspace-based**, sampling at default | $0 | Workspace-based AI bills via the Log Analytics meter, not separately — covered by the same 5 GB free tier. Classic (non-workspace) AI has its own meter and is **not** free; do not switch back. | -| Key Vault | **Standard** SKU, RBAC, no HSM | ~$0 | Standard KV bills ~$0.03 per 10k operations. With MI-first design (no secrets stored), idle ops ≈ 0. RBAC has no extra charge over access policies. **Premium SKU (HSM-backed)** has a fixed monthly fee — do not enable it for this sample. | -| Container registry | **GHCR** (`ghcr.io/mghabin/ftgo-*`) public | $0 | Public images on GHCR are free with no pull limits for a public repo. We do **not** provision Azure Container Registry — even Basic ACR is ~$5/mo flat. | -| GitHub Actions CI/CD | Public repo, GitHub-hosted runners | $0 | Public repos get unlimited Actions minutes on standard runners. | -| Azure Front Door / Application Gateway / WAF | **Not deployed** | $0 | These have a non-zero hourly base price even with no traffic. Out of scope until prod traffic justifies them. | -| Azure DNS / custom domain | **Not deployed** | $0 | Sample uses the auto-issued `*.azurecontainerapps.io` hostname, which is free. | +| Resource | SKU / config | Idle bill | Reason it stays at $0 | +| ------------------------------------------------------------------------ | --------------------------------------------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Azure Container Apps environment | **Consumption** plan (not Workload Profiles) | $0 | Consumption ACA charges only per-second vCPU + memory + per-request; **no fixed environment fee**. Workload-Profiles plans bill the profile flat-rate even when idle — we deliberately avoid them. | +| ACA app: `apigateway`, `orders-api`, `restaurants-api`, `kitchen-worker` | `minReplicas=0`, `maxReplicas=1`, `co.cooldownPeriod=300` | $0 | At `minReplicas=0` ACA scales to zero after the cooldown — no replicas means no vCPU-seconds billed. First request after idle takes a cold-start hit (~1-3s for a chiseled .NET image). | +| Log Analytics workspace | `PerGB2018`, `dailyQuotaGb=1`, `retentionInDays=30` | $0 | First **5 GB/month** of ingest is free per Azure subscription. The 1 GB daily cap is a hard guard against runaway log spam blowing the free tier. | +| Application Insights | **Workspace-based**, sampling at default | $0 | Workspace-based AI bills via the Log Analytics meter, not separately — covered by the same 5 GB free tier. Classic (non-workspace) AI has its own meter and is **not** free; do not switch back. | +| Key Vault | **Standard** SKU, RBAC, no HSM | ~$0 | Standard KV bills ~$0.03 per 10k operations. With MI-first design (no secrets stored), idle ops ≈ 0. RBAC has no extra charge over access policies. **Premium SKU (HSM-backed)** has a fixed monthly fee — do not enable it for this sample. | +| Container registry | **GHCR** (`ghcr.io/mghabin/ftgo-*`) public | $0 | Public images on GHCR are free with no pull limits for a public repo. We do **not** provision Azure Container Registry — even Basic ACR is ~$5/mo flat. | +| GitHub Actions CI/CD | Public repo, GitHub-hosted runners | $0 | Public repos get unlimited Actions minutes on standard runners. | +| Azure Front Door / Application Gateway / WAF | **Not deployed** | $0 | These have a non-zero hourly base price even with no traffic. Out of scope until prod traffic justifies them. | +| Azure DNS / custom domain | **Not deployed** | $0 | Sample uses the auto-issued `*.azurecontainerapps.io` hostname, which is free. | **Total idle floor: ~$0/month per env.** Three envs idle ⇒ still ~$0. @@ -26,12 +26,12 @@ Per-second vCPU + memory above the [free grant](https://azure.microsoft.com/pric Real-world example bills (rough order-of-magnitude, US East, Nov-2024 list prices): -| Workload | Approx /mo | -|---|---| -| 3 envs idle 24/7 | **~$0** | -| Dev hit ~100 req/day, ppe/prod idle | **<$1** | -| Prod 1 replica per app warm 24/7 (defeats scale-to-zero) | ~$3-5 | -| Sustained 10 req/s prod (autoscale 1-3) | ~$15-25 | +| Workload | Approx /mo | +| -------------------------------------------------------- | ---------- | +| 3 envs idle 24/7 | **~$0** | +| Dev hit ~100 req/day, ppe/prod idle | **<$1** | +| Prod 1 replica per app warm 24/7 (defeats scale-to-zero) | ~$3-5 | +| Sustained 10 req/s prod (autoscale 1-3) | ~$15-25 | ## Required guardrails (don't regress these) @@ -41,7 +41,7 @@ These are enforced today and **must not** be relaxed without a corresponding cos - **Bicep**: `infra/bicep/modules/log-analytics.bicep` sets `properties.workspaceCapping.dailyQuotaGb=1` and `retentionInDays=30`. - **Bicep**: `infra/bicep/modules/key-vault.bicep` pins `sku.name='standard'` and `enableRbacAuthorization=true`. - **Bicep**: no `Microsoft.ContainerRegistry/registries` resource exists — pull comes from GHCR. -- **CD**: only `deploy-dev` runs on push (see [`environments.md`](environments.md#promotion-model)). ppe and prod require `gh workflow run -f environment=...` so we don't accidentally hold replicas warm in higher envs. +- **CD**: only `deploy-ci` runs on push (see [`environments.md`](environments.md#promotion-model)). ppe and prod require `gh workflow run -f environment=...` so we don't accidentally hold replicas warm in higher tiers. - **CD**: `cd.yml` does not provision Front Door / App Gateway / WAF / custom domains. Adding any of these requires updating this page. - **prod RG delete-lock**: prevents accidental teardown of the prod env, *not* a cost guard. Use the teardown procedure below. @@ -50,8 +50,8 @@ These are enforced today and **must not** be relaxed without a corresponding cos If you want the bill to be unconditionally $0 — including the cents from Key Vault metadata storage and LAW retention — delete the resource groups: ```bash -# dev / ppe — no lock -az group delete --name rg-ftgo-dev-eastus --yes --no-wait +# ci / ppe — no lock +az group delete --name rg-ftgo-ci-eastus --yes --no-wait az group delete --name rg-ftgo-ppe-eastus --yes --no-wait # prod — remove the delete-lock first @@ -60,7 +60,7 @@ LOCK_ID=$(az lock list --resource-group rg-ftgo-prod-eastus --query "[?name=='pr az group delete --name rg-ftgo-prod-eastus --yes --no-wait ``` -Re-bootstrapping is idempotent: `./scripts/bootstrap-env.sh ENV=dev && ./scripts/provision-apps.sh ENV=dev` brings the env back. The Entra app regs themselves are tenant-scoped and survive RG deletion (`provision-apps.sh` reconciles them in place). +Re-bootstrapping is idempotent: `./scripts/bootstrap-env.sh ENV=ci && ./scripts/provision-apps.sh ENV=ci` brings the tier back. The Entra app regs themselves are tenant-scoped and survive RG deletion (`provision-apps.sh` reconciles them in place). ## Periodic checks diff --git a/docs/deploy-cloud.md b/docs/deploy-cloud.md index 3ba0bc5..af06a6d 100644 --- a/docs/deploy-cloud.md +++ b/docs/deploy-cloud.md @@ -1,6 +1,6 @@ # Cloud deployment -Deploys the **4 FTGO services** to **Azure Container Apps** in three environments — **dev → ppe → prod** — promoted by GitHub Actions OIDC. Zero stored client secrets, free-tier-friendly, **~$0/mo at idle** (see [`cost-zero.md`](cost-zero.md) for the full breakdown). +Deploys the **4 FTGO services** to **Azure Container Apps** in three environments — **ci → ppe → prod** — promoted by GitHub Actions OIDC. Zero stored client secrets, free-tier-friendly, **~$0/mo at idle** (see [`cost-zero.md`](cost-zero.md) for the full breakdown). ## Architecture @@ -9,7 +9,7 @@ GitHub repo (push to main) │ └─ .github/workflows/cd.yml 1. Build 4 images → ghcr.io/mghabin/ftgo-*:sha-XXX (free, public) - 2. Deploy dev (OIDC, no secrets) ← auto on push + 2. Deploy ci (OIDC, no secrets) ← auto on push 3. Promote to ppe ← manual `gh workflow run cd.yml -f environment=ppe` 4. Promote to prod (required reviewer) ← manual `gh workflow run cd.yml -f environment=prod` @@ -34,7 +34,7 @@ Identity: Each environment needs its CD identity and GitHub Environment created **once** before the workflow can deploy. Run from a workstation logged in to Azure (Owner on the subscription) and `gh` (admin on the repo): ```bash -./scripts/bootstrap-env.sh ENV=dev +./scripts/bootstrap-env.sh ENV=ci ./scripts/bootstrap-env.sh ENV=ppe ./scripts/bootstrap-env.sh ENV=prod # also configures required-reviewer rule ``` @@ -52,13 +52,13 @@ Re-running on an already-bootstrapped env is a no-op. ## First deploy -After bootstrapping `dev`: +After bootstrapping `ci`: ```bash git push origin main ``` -The workflow auto-deploys to dev. Watch progress under **Actions → CD → deploy-dev**. The job summary prints the BFF FQDN and Scalar URL. +The workflow auto-deploys to ci. Watch progress under **Actions → CD → deploy-ci**. The job summary prints the BFF FQDN and Scalar URL. ## Provisioning per-env Entra app registrations @@ -67,7 +67,7 @@ The CD identity is intentionally scoped to ARM only (no Microsoft Graph). Entra > **"Out-of-band" means run once per env, not on every push.** The Microsoft Graph application API is rate-limited and is **not designed for per-commit churn** — re-creating app regs, FICs, and admin-consented permission grants on every CI run risks `429 Too Many Requests`, partial failures that leave half-wired tenants, and an audit trail that drowns the real changes. Source-of-truth for the *resulting* wiring is the env-level GitHub variable `ENTRA_CONFIG_JSON`, which CD reads on every push. ```bash -./scripts/provision-apps.sh ENV=dev +./scripts/provision-apps.sh ENV=ci ./scripts/provision-apps.sh ENV=ppe ./scripts/provision-apps.sh ENV=prod ``` @@ -76,12 +76,12 @@ This: - Cold-bootstraps `azure.bicep` if no container apps exist yet (`entraConfig={}`). - Reads each container app's MI principalId. -- Runs `main.bicep` to (re-)create env-suffixed app regs (`ftgo-dev-apigateway`, ...) and grant `Orders.Process` to the kitchen-worker MI. +- Runs `main.bicep` to (re-)create env-suffixed app regs (`ftgo-ci-apigateway`, ...) and grant `Orders.Process` to the kitchen-worker MI. - Federates the BFF ACA system MI to the BFF app reg (so it can mint client assertions via MI). - Re-deploys `azure.bicep` with a populated `entraConfig` object — env vars now live in the bicep state, no more drift. - Publishes the resolved `entraConfig` JSON as the `ENTRA_CONFIG_JSON` env-level GitHub variable, so subsequent CD redeploys pass the same wiring back into bicep. -After this runs once per env, every `git push origin main` fully deploys + wires **dev** automatically — re-running `provision-apps.sh` is only needed when the Entra app regs themselves change. ppe and prod require a manual `gh workflow run` (see next section). +After this runs once per tier, every `git push origin main` fully deploys + wires **ci** automatically — re-running `provision-apps.sh` is only needed when the Entra app regs themselves change. ppe and prod require a manual `gh workflow run` (see next section). ### `ENTRA_CONFIG_JSON` source-of-truth and drift @@ -94,17 +94,17 @@ After this runs once per env, every `git push origin main` fully deploys + wires **ppe and prod are manual-only** — auto-promotion is intentionally disabled to keep idle Azure spend at ~$0/month. See [`environments.md`](environments.md#why-ppe-and-prod-are-manual) for the design rationale. ```bash -# Promote latest dev SHA to ppe (build → deploy-dev → deploy-ppe) +# Promote latest ci SHA to ppe (build → deploy-ci → deploy-ppe) gh workflow run cd.yml -f environment=ppe -# Promote latest dev SHA to prod (build → deploy-dev → deploy-ppe → deploy-prod, with reviewer gate) +# Promote latest ci SHA to prod (build → deploy-ci → deploy-ppe → deploy-prod, with reviewer gate) gh workflow run cd.yml -f environment=prod # Re-deploy a specific previously-built SHA (rollback path) gh workflow run cd.yml -f environment=prod -f imageTag=sha-abc1234 ``` -The **same image digest** is promoted across envs — no rebuild between dev and prod. +The **same image digest** is promoted across envs — no rebuild between ci and prod. ## Verification @@ -116,12 +116,12 @@ Real teams verify production from telemetry, not curl-in-CI. Use: ## Cost estimate -| Scenario | dev | ppe | prod | Total /mo | -|---|---|---|---|---| -| All envs idle (scale-to-zero, no traffic) | $0 | $0 | $0 | **$0** | -| Dev continuously hit at low rate, ppe/prod idle | <$1 | $0 | $0 | **<$1** | -| Prod 1 replica per service always-on (warm) | $0 | $0 | ~$3-5 | ~$3-5 | -| Sustained 10 req/s prod (scale 1-3) | $0 | $0 | ~$15-25 | ~$15-25 | +| Scenario | ci | ppe | prod | Total /mo | +| ----------------------------------------------- | --- | --- | ------- | --------- | +| All envs idle (scale-to-zero, no traffic) | $0 | $0 | $0 | **$0** | +| Dev continuously hit at low rate, ppe/prod idle | <$1 | $0 | $0 | **<$1** | +| Prod 1 replica per service always-on (warm) | $0 | $0 | ~$3-5 | ~$3-5 | +| Sustained 10 req/s prod (scale 1-3) | $0 | $0 | ~$15-25 | ~$15-25 | The $0 idle floor relies on: @@ -135,17 +135,17 @@ See [`cost-zero.md`](cost-zero.md) for the per-resource breakdown and the config ## File layout -| Path | Purpose | -|---|---| -| `Dockerfile` | Single parameterized multi-service Dockerfile (chiseled, ~95 MB) | -| `infra/bicep/azure.bicep` | Subscription-scope orchestrator (per-env Azure infra) | -| `infra/bicep/azure.{env}.bicepparam` | Per-env parameters | -| `infra/bicep/main.bicep` | Tenant-scope orchestrator (Entra app regs) | -| `infra/bicep/main.{env}.bicepparam` | Per-env Entra app reg params | -| `.github/workflows/cd.yml` | CD pipeline | -| `.github/workflows/cd-cleanup.yml` | Nightly scale-reset for dev/ppe | -| `scripts/bootstrap-env.sh` | One-time per-env bootstrap | -| `scripts/provision-apps.sh` | Per-env Entra app provisioning | +| Path | Purpose | +| ------------------------------------ | ---------------------------------------------------------------- | +| `Dockerfile` | Single parameterized multi-service Dockerfile (chiseled, ~95 MB) | +| `infra/bicep/azure.bicep` | Subscription-scope orchestrator (per-env Azure infra) | +| `infra/bicep/azure.{env}.bicepparam` | Per-env parameters | +| `infra/bicep/main.bicep` | Tenant-scope orchestrator (Entra app regs) | +| `infra/bicep/main.{env}.bicepparam` | Per-env Entra app reg params | +| `.github/workflows/cd.yml` | CD pipeline | +| `.github/workflows/cd-cleanup.yml` | Nightly scale-reset for ci/ppe | +| `scripts/bootstrap-env.sh` | One-time per-env bootstrap | +| `scripts/provision-apps.sh` | Per-env Entra app provisioning | ## See also diff --git a/docs/environments.md b/docs/environments.md index 6cab088..d8be9e5 100644 --- a/docs/environments.md +++ b/docs/environments.md @@ -1,12 +1,43 @@ # Environments -Three environments share one Azure subscription and one Entra tenant: +Four-tier ladder. Three are deployed to Azure; one (`local`) is the +developer-machine tier and never touches the cloud. -| Env | RG | Trigger | Gate | -|---|---|---|---| -| **dev** | `rg-ftgo-dev-eastus` | every push to `main` | none | -| **ppe** | `rg-ftgo-ppe-eastus` | manual `workflow_dispatch` (`environment=ppe` or `prod`) | dev success | -| **prod** | `rg-ftgo-prod-eastus` | manual `workflow_dispatch` (`environment=prod`) | ppe success **and** required reviewer on the `prod` GitHub Environment | +| Tier | Where | `ASPNETCORE_ENVIRONMENT` | Trigger | Gate | Idle cost | +| --------- | -------------------------------------------------- | ------------------------ | -------------------------------------------------------- | ---------------------------------------------------------------------- | ----------------------- | +| **local** | developer laptop (`dotnet run` / `docker compose`) | `Development` | manual | none | $0 | +| **ci** | `rg-ftgo-ci-eastus` | `Staging` | every push to `main` | none | ~$0 (ACA scale-to-zero) | +| **ppe** | `rg-ftgo-ppe-eastus` | `Staging` | manual `workflow_dispatch` (`environment=ppe` or `prod`) | ci success | $0 when not deployed | +| **prod** | `rg-ftgo-prod-eastus` | `Production` | manual `workflow_dispatch` (`environment=prod`) | ppe success **and** required reviewer on the `prod` GitHub Environment | $0 when not deployed | + +> **Why "ci" not "dev"?** "dev" colloquially means "a developer's +> machine", and we already have one of those (the `local` tier). +> Naming the auto-deployed first cloud tier `ci` honestly describes +> what's running there: build artifacts produced by CI, deployed +> without a human gate. See [`glossary.md`](../glossary.md) for the +> ppe definition (Microsoft pre-production environment, analogous to +> "staging" elsewhere). + +> **Runtime env decoupled from tier name.** `ASPNETCORE_ENVIRONMENT` +> is set by Bicep at deploy time, not derived from the tier name. +> Both `ci` and `ppe` set it to `Staging` (so app behaviour matches +> what prod will see — this is the dotnet-engineering-guide §07 +> "config drives behaviour, not env-name branches" rule). Only `prod` +> sets `Production`. + +## The `local` tier + +Not in Bicep, no GitHub Environment, no CI workflow. Configured via: + +- `appsettings.Development.json` (project-local defaults) +- `dotnet user-secrets` (per-developer secrets — never committed) +- `ASPNETCORE_ENVIRONMENT=Development` (set by `dotnet run` automatically) + +See [`run-locally.md`](run-locally.md) for the full local setup. + +Local code can talk to ci-tier APIs (whitelisted developer credentials — +Azure CLI + VS Code public-client appIds — are accepted on the ci tier +only, see `aca-stack.bicep`). It cannot talk to ppe or prod. ## Promotion model @@ -14,7 +45,7 @@ Three environments share one Azure subscription and one Entra tenant: push → build-images (matrix × 4) → tags :sha-XXX, :latest │ ▼ - deploy-dev (auto on push to main) + deploy-ci (auto on push to main) │ ▼ (only if `workflow_dispatch` was invoked with environment=ppe or prod) deploy-ppe (concurrency-group: ppe) @@ -23,22 +54,22 @@ push → build-images (matrix × 4) → tags :sha-XXX, :latest deploy-prod (concurrency-group: prod, env reviewer required) ``` -- **dev is fully automatic** on every push to `main`. No human gate. -- **ppe and prod are manual-only.** Operators promote a known-good SHA via `gh workflow run cd.yml -f environment=ppe` (deploys dev → ppe) or `-f environment=prod` (deploys dev → ppe → prod). This keeps idle Azure spend to ~$0/month — only dev runs continuously between merges; ppe and prod are spun up on demand. -- **Single image digest** is deployed to all three envs — built once, promoted many. +- **ci is fully automatic** on every push to `main`. No human gate. +- **ppe and prod are manual-only.** Operators promote a known-good SHA via `gh workflow run cd.yml -f environment=ppe` (deploys ci → ppe) or `-f environment=prod` (deploys ci → ppe → prod). This keeps idle Azure spend to ~$0/month — only ci runs continuously between merges; ppe and prod are spun up on demand. +- **Single image digest** is deployed to all three Azure tiers — built once, promoted many. - `workflow_dispatch` accepts an `imageTag` input to redeploy a previously-built SHA without rebuilding. - `cancel-in-progress: false` on ppe/prod so a follow-up dispatch never interrupts a running deploy. > **Single-digest implication.** Because the *same* image digest flows -> dev → ppe → prod, a regression caught in dev **blocks ppe and prod -> for the same SHA**. There is no "skip dev, ship a hotfix straight to +> ci → ppe → prod, a regression caught in ci **blocks ppe and prod +> for the same SHA**. There is no "skip ci, ship a hotfix straight to > prod" path — by design, prod can only ever run an image that ppe ran -> and ppe can only ever run one that dev ran. Plan rollbacks +> and ppe can only ever run one that ci ran. Plan rollbacks > accordingly: `gh workflow run cd.yml -f environment=prod -f imageTag=sha-` -> reuses an *older* digest that already passed all three envs; it does +> reuses an *older* digest that already passed all three tiers; it does > **not** re-build. Avoid making "trivial" prod-only doc/config changes > in CD config without bumping the SHA — they will not deploy until -> dev rebuilds. +> ci rebuilds. ## Why ppe and prod are manual @@ -47,27 +78,27 @@ push → build-images (matrix × 4) → tags :sha-XXX, :latest - **prod is the gate that matters.** A bad SHA reaching ppe is a paged on-call event; a bad SHA reaching prod is a customer-impacting incident. The required reviewer on the `prod` GitHub Environment is the last human checkpoint. - **Adjust per organisation.** If your org runs ppe continuously (active soak testing, partner integration), drop the `if: github.event_name == 'workflow_dispatch'` guards on the `deploy-ppe` job in `cd.yml` and accept the cost. The trade-off is **deliberate** in this sample: speed for $0 idle. -## Per-env configuration +## Per-tier configuration What lives where: -| Layer | Source | Per-env? | -|---|---|---| -| Image build (registry, tag) | `cd.yml` | no | -| Azure infra | `infra/bicep/azure.bicep` + `azure.{env}.bicepparam` | yes | -| Entra app regs | `infra/bicep/main.bicep` + `main.{env}.bicepparam` | yes | -| ACA scale, ingress, env vars | `container-app.bicep` (driven by `aca-stack.bicep`) | shape only — values uniform | -| Application Insights connection | injected by Bicep at deploy time | yes | -| `AZURE_CLIENT_ID`, `AZURE_SUBSCRIPTION_ID` | GitHub Environment **variables** | yes | -| `AZURE_TENANT_ID` | repo **secret** | no (single tenant) | +| Layer | Source | Per-tier? | +| ------------------------------------------ | ----------------------------------------------------- | --------------------------- | +| Image build (registry, tag) | `cd.yml` | no | +| Azure infra | `infra/bicep/azure.bicep` + `azure.{tier}.bicepparam` | yes | +| Entra app regs | `infra/bicep/main.bicep` + `main.{tier}.bicepparam` | yes | +| ACA scale, ingress, env vars | `container-app.bicep` (driven by `aca-stack.bicep`) | shape only — values uniform | +| Application Insights connection | injected by Bicep at deploy time | yes | +| `AZURE_CLIENT_ID`, `AZURE_SUBSCRIPTION_ID` | GitHub Environment **variables** | yes | +| `AZURE_TENANT_ID` | repo **secret** | no (single tenant) | -The CD identity per env (`ftgo-{env}-cd-mi`) is scoped to its own resource group only — no cross-env access. +The CD identity per tier (`ftgo-{tier}-cd-mi`) is scoped to its own resource group only — no cross-tier access. -## Adding a 4th environment (e.g., `staging`) +## Adding a 5th tier (e.g., `staging`) 1. Add `infra/bicep/azure.staging.bicepparam` and `infra/bicep/main.staging.bicepparam`. 1. `./scripts/bootstrap-env.sh ENV=staging`. -1. Add a `deploy-staging` job to `cd.yml`, modeled on `deploy-ppe`, with `needs:` set to the upstream env you want it promoted from. +1. Add a `deploy-staging` job to `cd.yml`, modeled on `deploy-ppe`, with `needs:` set to the upstream tier you want it promoted from. 1. `./scripts/provision-apps.sh ENV=staging` after the first deploy. ## Production guardrails @@ -79,13 +110,13 @@ The CD identity per env (`ftgo-{env}-cd-mi`) is scoped to its own resource group ## Cleanup -Nightly at 03:00 UTC, `cd-cleanup.yml` resets every dev and ppe container app to `min=0/max=3`. This is a defensive measure against forgotten always-on overrides; it never touches prod. For complete teardown of an environment, see [`operations.md`](operations.md#teardown). +Nightly at 03:00 UTC, `cd-cleanup.yml` resets every ci and ppe container app to `min=0/max=3`. This is a defensive measure against forgotten always-on overrides; it never touches prod. For complete teardown of a tier, see [`operations.md`](operations.md#teardown). -To tear down an env entirely: +To tear down a tier entirely: ```bash -az group delete --name rg-ftgo-dev-eastus --yes --no-wait -gh api -X DELETE repos/OWNER/REPO/environments/dev +az group delete --name rg-ftgo-ci-eastus --yes --no-wait +gh api -X DELETE repos/OWNER/REPO/environments/ci ``` The federated credential and the user-assigned MI go away with the resource group. @@ -99,3 +130,4 @@ The federated credential and the user-assigned MI go away with the resource grou - Progressive delivery patterns — [martinfowler.com/articles/cd-pipeline-patterns.html](https://martinfowler.com/articles/cd-pipeline-patterns.html) - Blue/Green deployment (Fowler) — [martinfowler.com/bliki/BlueGreenDeployment.html](https://martinfowler.com/bliki/BlueGreenDeployment.html) - infra-engineering-guide ch03 (CI/CD — progressive delivery, blue/green & canary) — [github.com/mghabin/infra-engineering-guide/blob/main/docs/03-ci-cd.md](https://github.com/mghabin/infra-engineering-guide/blob/main/docs/03-ci-cd.md) +- ASP.NET Core environments — [learn.microsoft.com/aspnet/core/fundamentals/environments](https://learn.microsoft.com/aspnet/core/fundamentals/environments) diff --git a/docs/matrix.md b/docs/matrix.md index 8a3a5c0..474a5b2 100644 --- a/docs/matrix.md +++ b/docs/matrix.md @@ -2,44 +2,44 @@ One row per realistic backend scenario. "Validation" assumes the receiving API uses Microsoft.Identity.Web defaults from [validation.md](validation.md). -| # | Scenario | Token type | Acquisition lib | Credential | Server validation must check | Common pitfalls | -|---|---|---|---|---|---|---| -| 1 | ASP.NET Core API receives request from SPA / mobile | User | n/a (client acquires) | n/a | `scp`, audience = API client ID (v2) / App ID URI (v1), issuer per tenant | Forgetting `RequiredScope`; treating missing `scp` as allow | -| 2 | Same API calls Microsoft Graph **as the user** | User (OBO) | Microsoft.Identity.Web (`IDownstreamApi`) | API's own: MI / FIC / cert | downstream Graph validates user token | Using app token for user data; missing distributed token cache when scaled out | -| 3 | Worker on App Service calls Graph as itself | App | Azure.Identity (Graph SDK) **or** MSAL.NET | **System or User-assigned MI** | `roles`, `azp`=MI client id, `idtyp=app` | Granting app roles to the wrong SP; not allow-listing `azp` | -| 4 | Worker on App Service calls **your own API** as itself | App | MSAL.NET (`AcquireTokenForClient`) with MI assertion, **or** `ManagedIdentityCredential.GetTokenAsync` | MI | `roles`, `azp` allow-list | Requesting wrong scope (`/.default` vs custom) | -| 5 | Worker on AKS calls a downstream API | App | MSAL.NET with `WithClientAssertion`, or `WorkloadIdentityCredential` | **FIC** (Azure Workload Identity add-on) | same as #4 | Missing federated credential subject; clock skew on projected token | -| 6 | GitHub Actions job calls Entra-protected API | App | MSAL.NET with `WithClientAssertion` using `ACTIONS_ID_TOKEN_REQUEST_*` | **FIC** on app reg, no secret | same as #4 | FIC subject mismatch (`repo:org/repo:ref:refs/heads/main`) | -| 7 | On-prem worker calls Graph | App | MSAL.NET | **Certificate** (KV-backed) → `WithCertificate(cert)` (add `sendX5C: true` only for SNI / x5c cert-rollover) | `roles`, `azp` | Cert in source tree; no rotation; cargo-culting `sendX5C` when not needed | -| 8 | Legacy daemon, MI/FIC not possible | App | MSAL.NET | **Client secret** (KV) | `roles`, `azp` | Long-lived secret; secret in env var on dev box | -| 9 | API in Azure calls Storage / Key Vault / Cosmos | App (Azure resource) | **Azure.Identity** (`DefaultAzureCredential`) on the SDK client | MI in prod, CLI/VS in dev | n/a (Azure RBAC, not your API) | Wrapping `TokenCredential` in your own cache; using account keys | -| 10 | Multi-tenant SaaS API | User and/or App | Microsoft.Identity.Web | API's own: MI / FIC / cert | `IssuerValidator` with **tenant allow-list**; `tid` matches issuer; `roles`/`scp` (see [validation.md §3](validation.md#3-issuer-audience-v1-vs-v2-single-vs-multi-tenant)) | `TenantId: "common"` with no tenant filter (open door); admin-consent flow forgotten | -| 11 | API exposed to both users and apps | Both | Microsoft.Identity.Web | — | **Two separate named policies on two separate `[Authorize]` attributes** — never one OR-claims policy. Per [dotnet-engineering-guide ch02 §10](https://github.com/mghabin/dotnet-engineering-guide/blob/main/docs/02-aspnetcore.md#10-authnauthz) and [decision-trees.md Tree 4](decision-trees.md#4-does-this-endpoint-accept-delegated-app-only-or-both). | One `[Authorize]` policy that silently accepts either | -| 12 | Cross-tenant S2S (your app calling a partner tenant) | App | MSAL.NET | **Multi-tenant app reg + cert/FIC** (MI cannot do this) | partner validates `roles` granted in *their* tenant | Trying to use MI; partner hasn't consented your app | -| 13 | API receives token from another API (pass-through pattern) | App or User | n/a (token already inbound) | n/a | **Validate, don't cache**: full signature/audience/issuer/`roles`+`azp` (or `scp`) checks per [validation.md](validation.md). The intermediate API **must not** re-mint or cache the inbound token; if it needs to call further downstream as the same user, use **OBO** ([acquisition.md §2b](acquisition.md#2b-calling-a-downstream-api-as-that-user-obo)) — never replay the raw token. | Forwarding the raw inbound token to a downstream API; trusting the upstream's claims without re-validating signature | +| # | Scenario | Token type | Acquisition lib | Credential | Server validation must check | Common pitfalls | +| --- | ---------------------------------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- | +| 1 | ASP.NET Core API receives request from SPA / mobile | User | n/a (client acquires) | n/a | `scp`, audience = API client ID (v2) / App ID URI (v1), issuer per tenant | Forgetting `RequiredScope`; treating missing `scp` as allow | +| 2 | Same API calls Microsoft Graph **as the user** | User (OBO) | Microsoft.Identity.Web (`IDownstreamApi`) | API's own: MI / FIC / cert | downstream Graph validates user token | Using app token for user data; missing distributed token cache when scaled out | +| 3 | Worker on App Service calls Graph as itself | App | Azure.Identity (Graph SDK) **or** MSAL.NET | **System or User-assigned MI** | `roles`, `azp`=MI client id, `idtyp=app` | Granting app roles to the wrong SP; not allow-listing `azp` | +| 4 | Worker on App Service calls **your own API** as itself | App | MSAL.NET (`AcquireTokenForClient`) with MI assertion, **or** `ManagedIdentityCredential.GetTokenAsync` | MI | `roles`, `azp` allow-list | Requesting wrong scope (`/.default` vs custom) | +| 5 | Worker on AKS calls a downstream API | App | MSAL.NET with `WithClientAssertion`, or `WorkloadIdentityCredential` | **FIC** (Azure Workload Identity add-on) | same as #4 | Missing federated credential subject; clock skew on projected token | +| 6 | GitHub Actions job calls Entra-protected API | App | MSAL.NET with `WithClientAssertion` using `ACTIONS_ID_TOKEN_REQUEST_*` | **FIC** on app reg, no secret | same as #4 | FIC subject mismatch (`repo:org/repo:ref:refs/heads/main`) | +| 7 | On-prem worker calls Graph | App | MSAL.NET | **Certificate** (KV-backed) → `WithCertificate(cert)` (add `sendX5C: true` only for SNI / x5c cert-rollover) | `roles`, `azp` | Cert in source tree; no rotation; cargo-culting `sendX5C` when not needed | +| 8 | Legacy daemon, MI/FIC not possible | App | MSAL.NET | **Client secret** (KV) | `roles`, `azp` | Long-lived secret; secret in env var on dev box | +| 9 | API in Azure calls Storage / Key Vault / Cosmos | App (Azure resource) | **Azure.Identity** (`DefaultAzureCredential`) on the SDK client | MI in prod, CLI/VS in dev | n/a (Azure RBAC, not your API) | Wrapping `TokenCredential` in your own cache; using account keys | +| 10 | Multi-tenant SaaS API | User and/or App | Microsoft.Identity.Web | API's own: MI / FIC / cert | `IssuerValidator` with **tenant allow-list**; `tid` matches issuer; `roles`/`scp` (see [validation.md §3](validation.md#3-issuer-audience-v1-vs-v2-single-vs-multi-tenant)) | `TenantId: "common"` with no tenant filter (open door); admin-consent flow forgotten | +| 11 | API exposed to both users and apps | Both | Microsoft.Identity.Web | — | **Two separate named policies on two separate `[Authorize]` attributes** — never one OR-claims policy. Per [dotnet-engineering-guide ch02 §10](https://github.com/mghabin/dotnet-engineering-guide/blob/main/docs/02-aspnetcore.md#10-authnauthz) and [decision-trees.md Tree 4](decision-trees.md#4-does-this-endpoint-accept-delegated-app-only-or-both). | One `[Authorize]` policy that silently accepts either | +| 12 | Cross-tenant S2S (your app calling a partner tenant) | App | MSAL.NET | **Multi-tenant app reg + cert/FIC** (MI cannot do this) | partner validates `roles` granted in *their* tenant | Trying to use MI; partner hasn't consented your app | +| 13 | API receives token from another API (pass-through pattern) | App or User | n/a (token already inbound) | n/a | **Validate, don't cache**: full signature/audience/issuer/`roles`+`azp` (or `scp`) checks per [validation.md](validation.md). The intermediate API **must not** re-mint or cache the inbound token; if it needs to call further downstream as the same user, use **OBO** ([acquisition.md §2b](acquisition.md#2b-calling-a-downstream-api-as-that-user-obo)) — never replay the raw token. | Forwarding the raw inbound token to a downstream API; trusting the upstream's claims without re-validating signature | ## Quick "which credential" by host -| Host | Default | Fallback | -|---|---|---| -| App Service / Functions / Container Apps | System or User-assigned MI | FIC (rare) | -| AKS | **FIC** via Azure Workload Identity | UAMI assigned to node pool (legacy) | -| VM / VMSS | System-assigned MI | UAMI | -| Azure Arc-enabled server | MI via Arc | Cert | -| GitHub Actions / Azure DevOps | **FIC** (OIDC) | Secret in vault (avoid) | -| Other clouds (EKS/GKE) | **FIC** via OIDC | Cert | -| On-prem / dev box (prod) | Cert in KV | Secret in KV (avoid) | -| Local dev | `DefaultAzureCredential` (CLI/VS) | — | +| Host | Default | Fallback | +| ---------------------------------------- | ----------------------------------- | ----------------------------------- | +| App Service / Functions / Container Apps | System or User-assigned MI | FIC (rare) | +| AKS | **FIC** via Azure Workload Identity | UAMI assigned to node pool (legacy) | +| VM / VMSS | System-assigned MI | UAMI | +| Azure Arc-enabled server | MI via Arc | Cert | +| GitHub Actions / Azure DevOps | **FIC** (OIDC) | Secret in vault (avoid) | +| Other clouds (EKS/GKE) | **FIC** via OIDC | Cert | +| On-prem / dev box (prod) | Cert in KV | Secret in KV (avoid) | +| Local dev | `DefaultAzureCredential` (CLI/VS) | — | ## "Which library" by call target Decisive picks — these libraries are not interchangeable. Pick by call target, not preference. -| You're calling… | Library | Why | -|---|---|---| -| Azure resource SDK (Storage, KV, Cosmos, Service Bus, Graph SDK using `TokenCredential`) | **Azure.Identity** | Designed for `TokenCredential`-aware Azure SDKs; integrates with MI / `DefaultAzureCredential` chain. | -| Your own / 3rd-party Entra-protected REST API from a worker | **MSAL.NET** | Arbitrary OAuth flows incl. `client_credentials`, `WithClientAssertion` (FIC), and OBO. Not bound to ASP.NET Core. | -| Downstream API from inside an ASP.NET Core API (incl. OBO) | **Microsoft.Identity.Web** | Wraps MSAL.NET + JwtBearer wiring; `ITokenAcquisition` / `IDownstreamApi` / distributed token cache integration. | +| You're calling… | Library | Why | +| ---------------------------------------------------------------------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| Azure resource SDK (Storage, KV, Cosmos, Service Bus, Graph SDK using `TokenCredential`) | **Azure.Identity** | Designed for `TokenCredential`-aware Azure SDKs; integrates with MI / `DefaultAzureCredential` chain. | +| Your own / 3rd-party Entra-protected REST API from a worker | **MSAL.NET** | Arbitrary OAuth flows incl. `client_credentials`, `WithClientAssertion` (FIC), and OBO. Not bound to ASP.NET Core. | +| Downstream API from inside an ASP.NET Core API (incl. OBO) | **Microsoft.Identity.Web** | Wraps MSAL.NET + JwtBearer wiring; `ITokenAcquisition` / `IDownstreamApi` / distributed token cache integration. | If you find yourself using MSAL.NET to call Azure Storage, or `Azure.Identity` to do OBO, you've picked the wrong library — re-derive from the table. diff --git a/docs/operations.md b/docs/operations.md index 212ddcd..9fa7fcb 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -6,11 +6,11 @@ push-to-main → CD pipeline. ## Environments -| Env | RG (eastus) | KV soft-delete | Purge protection | Delete lock | -|------|---------------------------|----------------|------------------|-------------| -| dev | `rg-ftgo-dev-eastus` | 7d | off | none | -| ppe | `rg-ftgo-ppe-eastus` | 7d | off | none | -| prod | `rg-ftgo-prod-eastus` | 90d | **on** | **CanNotDelete** | +| Env | RG (eastus) | KV soft-delete | Purge protection | Delete lock | +| ------ | --------------------------- | ---------------- | ------------------ | ---------------- | +| ci | `rg-ftgo-ci-eastus` | 7d | off | none | +| ppe | `rg-ftgo-ppe-eastus` | 7d | off | none | +| prod | `rg-ftgo-prod-eastus` | 90d | **on** | **CanNotDelete** | The prod RG has a `Microsoft.Authorization/locks` deployed with `level: CanNotDelete` (see `infra/bicep/azure.bicep`). Deletes — including @@ -21,7 +21,7 @@ CanNotDelete` (see `infra/bicep/azure.bicep`). Deletes — including Preview a deploy without making changes: ```bash -WHAT_IF=1 ENV=dev IMAGE_TAG=preview ./scripts/provision-apps.sh +WHAT_IF=1 ENV=ci IMAGE_TAG=preview ./scripts/provision-apps.sh ``` Behavior: @@ -63,7 +63,7 @@ The drift job fails if anyone changes ruleset settings in the GitHub UI without ## Nightly cost-safety `cd-cleanup.yml` runs at 03:00 UTC and scales every container app in -`rg-ftgo-{dev,ppe}-eastus` down to `min=0 max=3`. Prod is excluded +`rg-ftgo-{ci,ppe}-eastus` down to `min=0 max=3`. Prod is excluded intentionally. Manual run: ```bash @@ -78,11 +78,11 @@ deploy re-asserts it after cleanup runs. ```bash # Confirm what will go -az resource list --resource-group "rg-ftgo-dev-eastus" --query '[].name' -o tsv +az resource list --resource-group "rg-ftgo-ci-eastus" --query '[].name' -o tsv # Delete the RG (Key Vault enters soft-delete for 7 days; same-name # re-provision in that window must use --recover, not create). -az group delete --name "rg-ftgo-dev-eastus" --yes --no-wait +az group delete --name "rg-ftgo-ci-eastus" --yes --no-wait ``` For prod: don't. If genuinely required, this is a multi-person decision @@ -96,7 +96,7 @@ az lock delete --name ftgo-prod-rg-delete-lock --resource-group rg-ftgo-prod-eas ```bash gh workflow run cd.yml \ - -f environment=dev \ + -f environment=ci \ -f imageTag=$(git rev-parse --short HEAD) ``` @@ -113,8 +113,8 @@ app revision logs: ```bash az containerapp logs show \ - --name ftgo-dev-apigateway-eus \ - --resource-group rg-ftgo-dev-eastus \ + --name ftgo-ci-apigateway-eus \ + --resource-group rg-ftgo-ci-eastus \ --type system --follow ``` @@ -171,7 +171,7 @@ the workflow still fails with that code, check that ## Provisioning a brand-new env -1. Create the matching GitHub Environment (`dev`/`ppe`/`prod`) with +1. Create the matching GitHub Environment (`ci`/`ppe`/`prod`) with the standard env vars/secrets (`AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, `AZURE_SUBSCRIPTION_ID`). 2. Create the federated credential on the bootstrap app reg for that @@ -181,6 +181,89 @@ the workflow still fails with that code, check that 4. The script writes `vars.ENTRA_CONFIG_JSON` for the env so future CD runs are fully declarative. +## Renaming a deployment tier (one-shot live cutover) + +This runbook is the **only safe order** for renaming a tier (e.g. +`dev` → `ci`, as the `local + ci + ppe + prod` rename did). The +non-obvious bit is OIDC: the GitHub OIDC token's `sub` claim is +`repo:OWNER/REPO:environment:`, which is matched verbatim +against the federated-identity-credential subject on the bootstrap +app reg. Rename the GH environment **before** the FIC and the next +`azure/login@…` call fails with `AADSTS70021`. + +Worked example: cut over from `dev` → `ci`. + +1. **Codebase first** (no live changes). Land the rename PR (see + the Phase A + B commits on `refactor/env-rename-dev-to-ci`). + The PR alone does **not** break the live `dev` env — it's all + string changes; nothing redeploys until step 5. +1. **Provision the new tier alongside the old.** Don't tear down + `rg-ftgo-dev-eastus` yet — you want a fallback if the new + FIC misbehaves. + + ```bash + ./scripts/bootstrap-env.sh ENV=ci + ./scripts/provision-apps.sh ENV=ci + ``` + + This creates `rg-ftgo-ci-eastus`, the `ci` GitHub Environment + with the standard env vars/secrets, and a fresh FIC with subject + `repo:OWNER/REPO:environment:ci`. (`ENV=dev` is still accepted as + a deprecated alias by both scripts — see the warning they print.) +1. **Migrate the env-scoped GitHub variable** `ENTRA_CONFIG_JSON` + from `dev` to `ci`. GitHub does not let you rename env-scoped + variables, so: + + ```bash + gh variable get ENTRA_CONFIG_JSON --env dev > /tmp/entra.json + gh variable set ENTRA_CONFIG_JSON --env ci --body "$(cat /tmp/entra.json)" + ``` + + The `appId`s inside the JSON are unchanged — the underlying app + regs (`ftgo-dev-apigateway`, etc.) survive the rename. Their + display names are cosmetic; you can rename them later via + `az ad app update --id --display-name ftgo-ci-…` for + consistency. +1. **Smoke-test the new tier in isolation.** Trigger a + `workflow_dispatch` against `environment=ci`: + + ```bash + gh workflow run cd.yml -f environment=ci + ``` + + Wait for green. Then run the **positive** Restaurants happy-path + probe (the test that actually proves the auth-policy fix works + end-to-end, not just rejects a bad token): + + ```bash + USER_TOKEN=$(az account get-access-token \ + --resource api:// \ + --query accessToken -o tsv) + BFF_FQDN=$(az containerapp show -g rg-ftgo-ci-eastus \ + -n ftgo-ci-apigateway-eus \ + --query properties.configuration.ingress.fqdn -o tsv) + curl -fsS -H "Authorization: Bearer $USER_TOKEN" \ + "https://${BFF_FQDN}/api/checkout/via-s2s-multitenant" + # → 200 with body proving roles=["Restaurants.Read.All"], azp=BFF appId + ``` +1. **Tear down the old `dev` tier** *only after* a green ci probe: + + ```bash + az group delete --name rg-ftgo-dev-eastus --yes --no-wait + gh api -X DELETE repos/OWNER/REPO/environments/dev + ``` + + The dev FIC, dev UAMI, and the env-scoped `ENTRA_CONFIG_JSON` + variable all go away with the GH environment / RG. The + `provision-apps.sh ENV=dev` deprecation alias can be dropped from + `scripts/{bootstrap-env,provision-apps}.sh` in a follow-up PR + once nobody is running stale runbooks. + +**Rollback:** the old `dev` tier is intact through step 4. If `ci` +fails the probe, run nothing — keep both tiers warm, debug, re-deploy +`ci`. If `ci` is fundamentally broken, revert the rename PR; the +`dev` env is untouched. + ## When something is on fire - **`/scalar/v1` returns AADSTS900021** — `vars.ENTRA_CONFIG_JSON` is diff --git a/docs/run-locally.md b/docs/run-locally.md index 6ead50a..cfa9251 100644 --- a/docs/run-locally.md +++ b/docs/run-locally.md @@ -1,15 +1,21 @@ # Run / develop the sample locally -The sample no longer provisions a separate set of `ftgo-local-*` app -registrations. **Local development uses your own Azure CLI / VS Code -identity** to call the cloud-deployed APIs — exactly the pattern you'd -use in a real project (`DefaultAzureCredential` falls through to -`AzureCliCredential` on a laptop and to `ManagedIdentityCredential` in -Azure, with no code change). +`local` is the **first** tier in this sample's SDLC ladder +(`local → ci → ppe → prod`, see [`environments.md`](./environments.md)) — +not an afterthought. It's where every developer starts. **No Azure +resources, no GitHub Environment, no per-developer app +registration.** The sample does not provision a separate set of +`ftgo-local-*` app regs because real-world local development should +use your own Azure CLI / VS Code identity to talk to the existing +**ci** cloud APIs — exactly the pattern you'd use in a real project +(`DefaultAzureCredential` falls through to `AzureCliCredential` on a +laptop and to `ManagedIdentityCredential` in Azure, with no code +change). `ASPNETCORE_ENVIRONMENT=Development` so the local runtime +matches the project conventions. For the full credential demos (cert, FIC, secret, MI), use the cloud -**dev** environment — those flows are realistic only when the workload -runs as a real service principal anyway. +**ci** tier — those flows are realistic only when the workload runs +as a real service principal anyway. ## You need @@ -36,11 +42,11 @@ Unit tests stub all Entra interactions and run offline. ## 3. Hit the cloud APIs from your laptop -After deploying the cloud **dev** env (see +After deploying the cloud **ci** env (see [`deploy-cloud.md`](deploy-cloud.md)), the script -`scripts/provision-apps.sh ENV=dev` whitelists the well-known **Azure CLI** +`scripts/provision-apps.sh ENV=ci` whitelists the well-known **Azure CLI** (`04b07795-…`) and **VS Code** (`aebc6443-…`) public client IDs as -allowed callers on `ftgo-dev-orders-api` and `ftgo-dev-restaurants-api`. +allowed callers on `ftgo-ci-orders-api` and `ftgo-ci-restaurants-api`. > **Dev only.** ppe and prod do not whitelist these public clients — > they only accept tokens from the real workload identities (BFF + workers). @@ -58,21 +64,21 @@ allowed callers on `ftgo-dev-orders-api` and `ftgo-dev-restaurants-api`. > Identity / FIC ([`credential-patterns/managed-identity.md`](credential-patterns/managed-identity.md), > [`credential-patterns/federated-identity.md`](credential-patterns/federated-identity.md)). > Whitelisting the Azure CLI app id (`04b07795-…`) on a resource -> therefore **MUST** stay scoped to dev — promoting it to prod would +> therefore **MUST** stay scoped to ci — promoting it to prod would > mean accepting tokens from any signed-in developer's laptop as if > they were the workload itself. Avoid. Acquire a user token and call the API: ```bash -ORDERS_APPID=$(az ad app list --filter "displayName eq 'ftgo-dev-orders-api'" --query "[0].appId" -o tsv) +ORDERS_APPID=$(az ad app list --filter "displayName eq 'ftgo-ci-orders-api'" --query "[0].appId" -o tsv) TOKEN=$(az account get-access-token --resource "api://${ORDERS_APPID}" --query accessToken -o tsv) -ORDERS_FQDN=$(az containerapp show -g rg-ftgo-dev-eastus -n ftgo-dev-orders-api-eus --query properties.configuration.ingress.fqdn -o tsv) +ORDERS_FQDN=$(az containerapp show -g rg-ftgo-ci-eastus -n ftgo-ci-orders-api-eus --query properties.configuration.ingress.fqdn -o tsv) curl -H "Authorization: Bearer $TOKEN" "https://${ORDERS_FQDN}/api/orders/system" ``` -The same pattern works for `ftgo-dev-restaurants-api`. +The same pattern works for `ftgo-ci-restaurants-api`. ## 4. OIDC sign-in (browser flow) @@ -80,7 +86,7 @@ OIDC sign-in needs a confidential client with a registered redirect URI, which only the deployed BFF has. Test it in the cloud: ``` -https://ftgo-dev-apigateway-eus..eastus.azurecontainerapps.io/scalar/v1 +https://ftgo-ci-apigateway-eus..eastus.azurecontainerapps.io/scalar/v1 ``` ## 5. Telemetry — Aspire Dashboard locally (optional) @@ -102,11 +108,11 @@ service locally that you wish to export traces from. ## What about the credential demos (cert / FIC / secret / MI)? -| Pattern | Where it's real | -|--------------------------------|-------------------------------------------------------------------| -| **MI** (`Ftgo.Kitchen.Worker`) | Cloud dev — runs as the Container App's system MI; calls Orders API with role `Orders.Process` | -| **FIC** (`wi-demo.yml`) | GitHub Actions workflow — OIDC token exchange, no stored secret | -| **Cert / secret** | Documented in [`docs/credential-patterns/`](credential-patterns/) — not deployed (cert is a niche on-prem/HSM tool, secret is an anti-pattern) | +| Pattern | Where it's real | +| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| **MI** (`Ftgo.Kitchen.Worker`) | Cloud ci — runs as the Container App's system MI; calls Orders API with role `Orders.Process` | +| **FIC** (`wi-demo.yml`) | GitHub Actions workflow — OIDC token exchange, no stored secret | +| **Cert / secret** | Documented in [`docs/credential-patterns/`](credential-patterns/) — not deployed (cert is a niche on-prem/HSM tool, secret is an anti-pattern) | The MI demo runs on every cloud deploy. The FIC demo runs on its own schedule via `wi-demo.yml`. Both are production-grade. diff --git a/docs/sample-setup.md b/docs/sample-setup.md index fffd3ba..4369255 100644 --- a/docs/sample-setup.md +++ b/docs/sample-setup.md @@ -10,23 +10,23 @@ auth shapes are taught against recognisable, business-meaningful names. ## Projects -| Project | Port | Auth shape demonstrated | -|------------------------------|------|----------------------------------------------------------------------| -| `Ftgo.ApiGateway` | 7101 | BFF: validates user tokens; calls Orders via OBO + S2S; calls Restaurants via S2S | -| `Ftgo.Orders.Api` | 7102 | Single-tenant resource: user (`scp=orders.read`) **or** app (`roles=Orders.Process` + `azp` allow-list) | -| `Ftgo.Restaurants.Api` | 7103 | Multi-tenant resource: app-only (`roles=Restaurants.Read.All`) with tenant allow-list `IssuerValidator` | -| `Ftgo.Kitchen.Worker` | — | App token via **Managed Identity** (the one cloud-worker pattern that matters in 2026) | -| `Ftgo.Auth` / `Ftgo.Auth.Client` | — | The one-line `AddEntraAuth(...)` / `AddEntraAuthClient(...)` libraries | +| Project | Port | Auth shape demonstrated | +| -------------------------------- | ------ | ------------------------------------------------------------------------------------------------------- | +| `Ftgo.ApiGateway` | 7101 | BFF: validates user tokens; calls Orders via OBO + S2S; calls Restaurants via S2S | +| `Ftgo.Orders.Api` | 7102 | Single-tenant resource: user (`scp=orders.read`) **or** app (`roles=Orders.Process` + `azp` allow-list) | +| `Ftgo.Restaurants.Api` | 7103 | Multi-tenant resource: app-only (`roles=Restaurants.Read.All`) with tenant allow-list `IssuerValidator` | +| `Ftgo.Kitchen.Worker` | — | App token via **Managed Identity** (the one cloud-worker pattern that matters in 2026) | +| `Ftgo.Auth` / `Ftgo.Auth.Client` | — | The one-line `AddEntraAuth(...)` / `AddEntraAuthClient(...)` libraries | For cert / FIC / client-secret patterns, see [`docs/credential-patterns/`](./credential-patterns/) — documented as references, not deployed. ## App registrations -Create three app registrations per env. Provisioning is declarative via the **Microsoft.Graph Bicep extension** at `infra/bicep/main.bicep`, run through `scripts/provision-apps.sh ENV=dev` — apps, service principals, scopes/roles and admin-consented permissions are created idempotently: +Create three app registrations per env. Provisioning is declarative via the **Microsoft.Graph Bicep extension** at `infra/bicep/main.bicep`, run through `scripts/provision-apps.sh ENV=ci` — apps, service principals, scopes/roles and admin-consented permissions are created idempotently: -1. **ftgo-dev-bff** (single-tenant) — OIDC web client + OBO. Uses `SignedAssertionFromManagedIdentity` so it has **no stored secret/cert**. Consumes `orders.read` delegated scope. -2. **ftgo-dev-orders-api** (single-tenant) — exposes scope `orders.read` and app role `Orders.Process`. -3. **ftgo-dev-restaurants-api** (multi-tenant, `signInAudience: AzureADMultipleOrgs`) — exposes app role `Restaurants.Read.All`. **Why multi-tenant here** while `ftgo-dev-orders-api` stays single-tenant: the sample deliberately demonstrates *both* validation shapes side by side. Restaurants is the canonical multi-tenant SaaS resource — it forces the `AadIssuerValidator` + `tid` allow-list pattern owned by [`validation.md` §3](./validation.md#3-issuer-audience-v1-vs-v2-single-vs-multi-tenant). Orders stays single-tenant so the simpler issuer-pinned default ([`validation.md` §3 — Single-tenant](./validation.md#single-tenant)) and the `azp` allow-list ([`validation.md` §4](./validation.md#4-app-token-specific-checks)) read cleanly without multi-tenant noise. The architectural rationale lives in those sections; this page only declares which app reg demonstrates which. +1. **ftgo-ci-bff** (single-tenant) — OIDC web client + OBO. Uses `SignedAssertionFromManagedIdentity` so it has **no stored secret/cert**. Consumes `orders.read` delegated scope. +2. **ftgo-ci-orders-api** (single-tenant) — exposes scope `orders.read` and app role `Orders.Process`. +3. **ftgo-ci-restaurants-api** (multi-tenant, `signInAudience: AzureADMultipleOrgs`) — exposes app role `Restaurants.Read.All`. **Why multi-tenant here** while `ftgo-ci-orders-api` stays single-tenant: the sample deliberately demonstrates *both* validation shapes side by side. Restaurants is the canonical multi-tenant SaaS resource — it forces the `AadIssuerValidator` + `tid` allow-list pattern owned by [`validation.md` §3](./validation.md#3-issuer-audience-v1-vs-v2-single-vs-multi-tenant). Orders stays single-tenant so the simpler issuer-pinned default ([`validation.md` §3 — Single-tenant](./validation.md#single-tenant)) and the `azp` allow-list ([`validation.md` §4](./validation.md#4-app-token-specific-checks)) read cleanly without multi-tenant noise. The architectural rationale lives in those sections; this page only declares which app reg demonstrates which. `Ftgo.Kitchen.Worker` has **no app registration** — it authenticates as its system-assigned Container App MI, which is granted `Orders.Process` directly via `permission-grants.bicep`. @@ -35,12 +35,12 @@ For each API app reg, set manifest `requestedAccessTokenVersion = 2` so ## Permissions / role grants -| Caller | Callee | Permission | -|---------------------------------------|-------------------------|---------------------------------------------------------------------| -| ftgo-dev-bff (delegated) | ftgo-dev-orders-api | scope `orders.read` (admin-consented) | -| ftgo-dev-bff (app) | ftgo-dev-orders-api | role `Orders.Process` | -| ftgo-dev-bff (app) | ftgo-dev-restaurants-api| role `Restaurants.Read.All` (consented in each provisioned tenant) | -| Kitchen.Worker container-app MI | ftgo-dev-orders-api | role `Orders.Process` (granted by `permission-grants.bicep` `miAppRoleGrant`) | +| Caller | Callee | Permission | +| ---------------------------------------- | -------------------------- | ----------------------------------------------------------------------------- | +| ftgo-ci-bff (delegated) | ftgo-ci-orders-api | scope `orders.read` (admin-consented) | +| ftgo-ci-bff (app) | ftgo-ci-orders-api | role `Orders.Process` | +| ftgo-ci-bff (app) | ftgo-ci-restaurants-api | role `Restaurants.Read.All` (consented in each provisioned tenant) | +| Kitchen.Worker container-app MI | ftgo-ci-orders-api | role `Orders.Process` (granted by `permission-grants.bicep` `miAppRoleGrant`) | Set `appRoleAssignmentRequired = true` on the resource APIs so only allow-listed callers receive `roles`. diff --git a/glossary.md b/glossary.md index f03910e..08714fd 100644 --- a/glossary.md +++ b/glossary.md @@ -50,6 +50,10 @@ Server-side façade that performs the OIDC sign-in on behalf of a browser SPA, h Entra mechanism that lets a resource API revoke or re-validate a token between issuance and expiry by responding 401 with a `claims` challenge; the client re-acquires a CAE-aware token. Long-lived (24h) tokens become safe because revocation is near-real-time. Owned in [`docs/validation.md`](./docs/validation.md) §6. [Continuous access evaluation](https://learn.microsoft.com/entra/identity/conditional-access/concept-continuous-access-evaluation). +### ci (deployment tier) + +The first **cloud** tier in this sample's ladder (`local → ci → ppe → prod`). Auto-deployed to `rg-ftgo-ci-eastus` on every push to `main` with no human gate. The honest name for this tier is `ci` — not `dev` — because "dev" colloquially means "a developer's machine" and we already have one of those (the `local` tier). `ASPNETCORE_ENVIRONMENT=Staging` so app behaviour matches what `ppe` and `prod`'s equivalent settings will see. Idle cost ~$0/month thanks to ACA scale-to-zero. Documented in [`docs/environments.md`](./docs/environments.md). + ### Conditional Access Entra policy engine that evaluates signals (user, device, location, risk, app) at sign-in time and during *CAE* re-evaluation, then grants, blocks, or steps up the session (MFA, compliant device). App developers consume CA via *ACRS* claims challenges; CA *authoring* is out of scope for this guide. [Conditional Access overview](https://learn.microsoft.com/entra/identity/conditional-access/overview). @@ -100,6 +104,14 @@ Optional Entra access-token claim added when configured via an [optional claims --- +## L + +### local (deployment tier) + +The first tier in this sample's SDLC ladder (`local → ci → ppe → prod`). Refers to a developer's machine running the services via `dotnet run` or `docker compose`. **No Azure resources, no GitHub Environment, no CI workflow.** Configured via `appsettings.Development.json`, `dotnet user-secrets`, and `ASPNETCORE_ENVIRONMENT=Development` (which `dotnet run` sets automatically). Documented in [`docs/run-locally.md`](./docs/run-locally.md) and [`docs/environments.md`](./docs/environments.md). Local code can call ci-tier APIs because `aca-stack.bicep` whitelists the well-known Azure CLI / VS Code public-client appIds on the ci tier only — see the `ciPublicClients` variable. + +--- + ## M ### Managed Identity (MI) — system-assigned (SAMI), user-assigned (UAMI) @@ -132,6 +144,14 @@ Identity layer on top of OAuth 2.0 ([RFC 6749](https://www.rfc-editor.org/rfc/rf --- +## P + +### ppe (Pre-Production Environment) + +Microsoft jargon for the deployment tier between `ci` and `prod`, sometimes called "staging" or "preprod" in other organisations. Manual-only (`workflow_dispatch`), promoted from a `ci`-blessed image digest. Used for soak testing, partner integration, and cross-tenant validation before promoting to `prod`. `ASPNETCORE_ENVIRONMENT=Staging`. Origin: the Microsoft Office / Azure Engineering convention. + +--- + ## R ### RequiredScope / RequiredScopeOrAppPermission diff --git a/infra/bicep/azure.bicep b/infra/bicep/azure.bicep index f1ce43d..dee4f3a 100644 --- a/infra/bicep/azure.bicep +++ b/infra/bicep/azure.bicep @@ -6,7 +6,7 @@ extension az targetScope = 'resourceGroup' @description('Logical environment name; controls resource-group, naming, and ASPNETCORE_ENVIRONMENT.') -@allowed([ 'dev', 'ppe', 'prod' ]) +@allowed([ 'ci', 'ppe', 'prod' ]) param environmentName string @description('Azure region for every resource. Defaults to eastus (largest free quota).') @@ -147,7 +147,7 @@ module kvRbac 'modules/key-vault-rbac.bicep' = { // Prod safety net: prevent accidental `az group delete` / portal-delete of the // entire RG. CanNotDelete still lets ARM perform in-place updates but blocks -// destructive operations until the lock is removed. Dev/PPE intentionally have +// destructive operations until the lock is removed. ci/PPE intentionally have // no lock so cd-cleanup and tear-down flows stay simple. resource rgDeleteLock 'Microsoft.Authorization/locks@2020-05-01' = if (environmentName == 'prod') { name: 'ftgo-prod-rg-delete-lock' diff --git a/infra/bicep/azure.dev.bicepparam b/infra/bicep/azure.ci.bicepparam similarity index 71% rename from infra/bicep/azure.dev.bicepparam rename to infra/bicep/azure.ci.bicepparam index 2d6a96b..85723d2 100644 --- a/infra/bicep/azure.dev.bicepparam +++ b/infra/bicep/azure.ci.bicepparam @@ -1,13 +1,13 @@ -// Cloud-dev provisioning of the FTGO Azure stack into rg-ftgo-dev-eastus. +// Cloud-CI provisioning of the FTGO Azure stack into rg-ftgo-ci-eastus. // Deploy: // IMAGE_TAG=sha-abc1234 \ // az deployment sub create --location eastus \ // --template-file infra/bicep/azure.bicep \ -// --parameters infra/bicep/azure.dev.bicepparam +// --parameters infra/bicep/azure.ci.bicepparam using 'azure.bicep' -param environmentName = 'dev' +param environmentName = 'ci' param location = readEnvironmentVariable('LOCATION', 'eastus') param imageTag = readEnvironmentVariable('IMAGE_TAG', 'latest') param containerRegistry = readEnvironmentVariable('CONTAINER_REGISTRY', 'ghcr.io/mghabin') diff --git a/infra/bicep/bootstrap.bicep b/infra/bicep/bootstrap.bicep index e2b9e0a..4f11bad 100644 --- a/infra/bicep/bootstrap.bicep +++ b/infra/bicep/bootstrap.bicep @@ -6,7 +6,7 @@ extension az targetScope = 'subscription' @description('Logical environment name; controls RG/MI naming and the federated subject.') -@allowed([ 'dev', 'ppe', 'prod' ]) +@allowed([ 'ci', 'ppe', 'prod' ]) param environmentName string @description('Azure region. Defaults to eastus (largest free quota).') diff --git a/infra/bicep/bootstrap.dev.bicepparam b/infra/bicep/bootstrap.ci.bicepparam similarity index 65% rename from infra/bicep/bootstrap.dev.bicepparam rename to infra/bicep/bootstrap.ci.bicepparam index d9d593c..36f8432 100644 --- a/infra/bicep/bootstrap.dev.bicepparam +++ b/infra/bicep/bootstrap.ci.bicepparam @@ -1,9 +1,9 @@ -// Day-0 bootstrap parameters for the dev environment. -// Deploy via: ./scripts/bootstrap-env.sh ENV=dev +// Day-0 bootstrap parameters for the ci environment. +// Deploy via: ./scripts/bootstrap-env.sh ENV=ci using 'bootstrap.bicep' -param environmentName = 'dev' +param environmentName = 'ci' param location = readEnvironmentVariable('LOCATION', 'eastus') param githubOwner = readEnvironmentVariable('GH_OWNER', 'mghabin') param githubRepo = readEnvironmentVariable('GH_REPO', 'entra-auth-patterns-dotnet') diff --git a/infra/bicep/main.bicep b/infra/bicep/main.bicep index 01b2079..15d15f7 100644 --- a/infra/bicep/main.bicep +++ b/infra/bicep/main.bicep @@ -16,9 +16,9 @@ param tenantId string @maxLength(16) param prefix string = 'ftgo' -@description('Logical environment name. Always suffixed onto the prefix (ftgo-dev-*, ftgo-ppe-*, ftgo-prod-*) so each env owns an isolated set of app regs.') -@allowed([ 'dev', 'ppe', 'prod' ]) -param environmentName string = 'dev' +@description('Logical deployment-tier name. Suffixed onto the prefix (ftgo-ci-*, ftgo-ppe-*, ftgo-prod-*) so each tier owns an isolated set of app regs. "local" is a documented fourth tier (no Azure resources) and is therefore not in this list.') +@allowed([ 'ci', 'ppe', 'prod' ]) +param environmentName string = 'ci' @description('OIDC redirect URI registered on the BFF for local-dev sign-in.') param apiGatewayRedirectUri string = 'https://localhost:7101/signin-oidc' diff --git a/infra/bicep/main.dev.bicepparam b/infra/bicep/main.ci.bicepparam similarity index 75% rename from infra/bicep/main.dev.bicepparam rename to infra/bicep/main.ci.bicepparam index 8de399b..b0269d8 100644 --- a/infra/bicep/main.dev.bicepparam +++ b/infra/bicep/main.ci.bicepparam @@ -1,4 +1,4 @@ -// Entra app-reg provisioning for the cloud-DEV environment (suffixes display names with -dev). +// Entra app-reg provisioning for the cloud-CI environment (suffixes display names with -ci). // // The redirect URIs need to point at the BFF's ACA FQDN, which we don't know // until azure.bicep has finished deploying (the FQDN folds in cae.defaultDomain, @@ -16,6 +16,6 @@ using 'main.bicep' param tenantId = readEnvironmentVariable('AZURE_TENANT_ID') param prefix = readEnvironmentVariable('FTGO_PREFIX', 'ftgo') -param environmentName = 'dev' -param apiGatewayRedirectUri = readEnvironmentVariable('FTGO_GATEWAY_REDIRECT_URI', 'https://ftgo-dev-apigateway-eus.eastus.azurecontainerapps.io/signin-oidc') -param scalarRedirectUri = readEnvironmentVariable('FTGO_SCALAR_REDIRECT_URI', 'https://ftgo-dev-apigateway-eus.eastus.azurecontainerapps.io/scalar/v1') +param environmentName = 'ci' +param apiGatewayRedirectUri = readEnvironmentVariable('FTGO_GATEWAY_REDIRECT_URI', 'https://ftgo-ci-apigateway-eus.eastus.azurecontainerapps.io/signin-oidc') +param scalarRedirectUri = readEnvironmentVariable('FTGO_SCALAR_REDIRECT_URI', 'https://ftgo-ci-apigateway-eus.eastus.azurecontainerapps.io/scalar/v1') diff --git a/infra/bicep/main.ppe.bicepparam b/infra/bicep/main.ppe.bicepparam index 724c0e2..7f4385b 100644 --- a/infra/bicep/main.ppe.bicepparam +++ b/infra/bicep/main.ppe.bicepparam @@ -1,5 +1,5 @@ // Entra app-reg provisioning for the cloud-PPE environment (suffixes display names with -ppe). -// See main.dev.bicepparam for the rationale on FTGO_GATEWAY_REDIRECT_URI / FTGO_SCALAR_REDIRECT_URI. +// See main.ci.bicepparam for the rationale on FTGO_GATEWAY_REDIRECT_URI / FTGO_SCALAR_REDIRECT_URI. using 'main.bicep' diff --git a/infra/bicep/main.prod.bicepparam b/infra/bicep/main.prod.bicepparam index 58bc7ae..db2a490 100644 --- a/infra/bicep/main.prod.bicepparam +++ b/infra/bicep/main.prod.bicepparam @@ -1,5 +1,5 @@ // Entra app-reg provisioning for the cloud-PROD environment (suffixes display names with -prod). -// See main.dev.bicepparam for the rationale on FTGO_GATEWAY_REDIRECT_URI / FTGO_SCALAR_REDIRECT_URI. +// See main.ci.bicepparam for the rationale on FTGO_GATEWAY_REDIRECT_URI / FTGO_SCALAR_REDIRECT_URI. using 'main.bicep' diff --git a/infra/bicep/modules/aca-stack.bicep b/infra/bicep/modules/aca-stack.bicep index 58196a4..398ba56 100644 --- a/infra/bicep/modules/aca-stack.bicep +++ b/infra/bicep/modules/aca-stack.bicep @@ -1,7 +1,7 @@ metadata name = 'aca-stack' metadata description = 'Iterates over the FTGO service list and instantiates one container-app per service. Returns a key-keyed map of fqdn/principalId/name for downstream RBAC and outputs.' -@description('Logical environment name (dev/ppe/prod).') +@description('Logical deployment-tier name (ci/ppe/prod). "local" tier is not deployed and is not valid here.') param environmentName string @description('Azure region for the apps.') @@ -58,12 +58,13 @@ param services array = [ // without AzureAd:TenantId). provision-apps.sh either populates everything or sends `{}`. var hasEntra = contains(entraConfig, 'tenantId') -// Microsoft public-client appIds — pre-registered, well-known. Whitelisted ONLY in dev so -// developer laptops can call dev cloud APIs via DefaultAzureCredential / AzureCliCredential. -// ppe and prod accept tokens only from real workload identities (BFF + workers). +// Microsoft public-client appIds — pre-registered, well-known. Whitelisted ONLY in the ci +// tier so developer laptops can call ci cloud APIs via DefaultAzureCredential / +// AzureCliCredential while iterating against real Azure resources. ppe and prod accept +// tokens only from real workload identities (BFF + workers). // Azure CLI: 04b07795-8ddb-461a-bbee-02f9e1bf7b46 // Visual Studio Code: aebc6443-996d-45c2-90f0-388ff96faa56 -var devPublicClients = environmentName == 'dev' ? [ +var ciPublicClients = environmentName == 'ci' ? [ '04b07795-8ddb-461a-bbee-02f9e1bf7b46' 'aebc6443-996d-45c2-90f0-388ff96faa56' ] : [] @@ -79,12 +80,12 @@ var apiGatewayEnv = hasEntra ? [ { name: 'DownstreamApis__Restaurants__AppPermissionScopes__0', value: 'api://${entraConfig.restaurantsApiAppId}/.default' } ] : [] -// Build OrdersApi allow-list: BFF first (index 0), then dev public clients (1..N), then -// kitchen-worker MI clientId (last). Order is stable across envs because dev-only entries -// only ever appear in dev. +// Build OrdersApi allow-list: BFF first (index 0), then ci public clients (1..N), then +// kitchen-worker MI clientId (last). Order is stable across envs because ci-only entries +// only ever appear in the ci tier. var ordersApiAllowedClientsBase = hasEntra ? concat( [ entraConfig.bffAppId ], - devPublicClients, + ciPublicClients, [ entraConfig.kitchenWorkerMiClientId ] ) : [] var ordersApiEnv = hasEntra ? concat([ @@ -97,7 +98,7 @@ var ordersApiEnv = hasEntra ? concat([ var restaurantsApiAllowedClientsBase = hasEntra ? concat( [ entraConfig.bffAppId ], - devPublicClients + ciPublicClients ) : [] var restaurantsApiEnv = hasEntra ? concat([ { name: 'AzureAd__TenantId', value: entraConfig.tenantId } diff --git a/infra/bicep/modules/app-registrations.bicep b/infra/bicep/modules/app-registrations.bicep index 9de4f2a..4078748 100644 --- a/infra/bicep/modules/app-registrations.bicep +++ b/infra/bicep/modules/app-registrations.bicep @@ -24,7 +24,7 @@ targetScope = 'tenant' @maxLength(36) param tenantId string -@description('Prefix applied to every app registration display name (e.g. "ftgo-dev" → "ftgo-dev-orders-api"). Cloud envs pass "ftgo-{env}".') +@description('Prefix applied to every app registration display name (e.g. "ftgo-ci" → "ftgo-ci-orders-api"). Cloud envs pass "ftgo-{env}".') @minLength(2) @maxLength(24) param prefix string diff --git a/infra/bicep/modules/container-app.bicep b/infra/bicep/modules/container-app.bicep index 618f410..9773b9e 100644 --- a/infra/bicep/modules/container-app.bicep +++ b/infra/bicep/modules/container-app.bicep @@ -3,7 +3,7 @@ metadata description = 'Single Azure Container App (Consumption) with system-ass extension az -@description('Container App resource name (e.g. ftgo-dev-apigateway-eus).') +@description('Container App resource name (e.g. ftgo-ci-apigateway-eus).') param appName string @description('Lowercased short service name (e.g. apigateway, orderservice). Used as the container name and image suffix.') @@ -22,19 +22,19 @@ param image string @secure() param appInsightsConnectionString string -@description('Logical environment name (dev/ppe/prod). Capitalized into ASPNETCORE_ENVIRONMENT.') +@description('Logical deployment-tier name (ci/ppe/prod). Capitalized into ASPNETCORE_ENVIRONMENT.') param environmentName string @description('Tags applied to the container app.') param tags object = {} -@description('CPU cores per replica. Default 0.25 keeps dev cost-bounded; bump to 0.5+ for ppe/prod via parameter override.') +@description('CPU cores per replica. Default 0.25 keeps ci cost-bounded; bump to 0.5+ for ppe/prod via parameter override.') param cpu string = '0.25' -@description('Memory per replica. Default 0.5Gi keeps dev cost-bounded; bump to 1.0Gi+ for ppe/prod via parameter override.') +@description('Memory per replica. Default 0.5Gi keeps ci cost-bounded; bump to 1.0Gi+ for ppe/prod via parameter override.') param memory string = '0.5Gi' -@description('Maximum replicas the HTTP/CPU scaler is allowed to spin up. Default 1 caps dev at a known-tiny worst case (4 apps × 1 replica × 0.25 vCPU ≈ \$16/mo if pinned 24/7); ppe/prod should override.') +@description('Maximum replicas the HTTP/CPU scaler is allowed to spin up. Default 1 caps ci at a known-tiny worst case (4 apps × 1 replica × 0.25 vCPU ≈ \$16/mo if pinned 24/7); ppe/prod should override.') @minValue(1) @maxValue(30) param maxReplicas int = 1 @@ -139,7 +139,7 @@ resource app 'Microsoft.App/containerApps@2024-10-02-preview' = { scale: { // Workers (no ingress) historically defaulted to min=1 because there's no HTTP // scaler to wake them on demand. That keeps a vCPU pinned 24/7 (~$2.4/mo per - // worker on dev tier) for a probe-once-and-exit pattern. Switching to min=0: + // worker on ci tier) for a probe-once-and-exit pattern. Switching to min=0: // the worker runs once on revision creation/update (executes the probe, exits), // then stays at 0 replicas until the next deploy. CPU-utilization scaler stays // wired so it can scale 0→N if the process ever does sustained work. diff --git a/scripts/bootstrap-env.sh b/scripts/bootstrap-env.sh index 45774f2..b76b11f 100755 --- a/scripts/bootstrap-env.sh +++ b/scripts/bootstrap-env.sh @@ -16,7 +16,7 @@ # semantics upsert. # # Usage: -# ./scripts/bootstrap-env.sh ENV=dev +# ./scripts/bootstrap-env.sh ENV=ci # ./scripts/bootstrap-env.sh ENV=ppe # ./scripts/bootstrap-env.sh ENV=prod # @@ -36,13 +36,16 @@ for arg in "$@"; do case "$arg" in ENV=*) ENV="${arg#ENV=}" ;; -h|--help) sed -n '2,24p' "$0" | sed 's/^# \?//'; exit 0 ;; - *) echo "unknown arg: $arg (expected ENV=dev|ppe|prod)" >&2; exit 2 ;; + *) echo "unknown arg: $arg (expected ENV=ci|ppe|prod)" >&2; exit 2 ;; esac done case "$ENV" in - dev|ppe|prod) ;; - *) echo "ERROR: ENV must be one of dev|ppe|prod (got '${ENV}')." >&2; exit 2 ;; + ci|ppe|prod) ;; + dev) + echo "WARNING: ENV=dev is a deprecated alias for ENV=ci (renamed in env-rename PR). Translating; please update your callers." >&2 + ENV=ci ;; + *) echo "ERROR: ENV must be one of ci|ppe|prod (got '${ENV}')." >&2; exit 2 ;; esac for tool in az gh jq; do @@ -157,7 +160,7 @@ Environment ${ENV} bootstrapped: - Federated subject: ${FIC_SUBJECT} Next: - - Push to main (auto-deploys dev → ppe → prod), or + - Push to main (auto-deploys ci → ppe → prod), or - gh workflow run cd.yml -f environment=${ENV} ============================================================ EOF diff --git a/scripts/provision-apps.sh b/scripts/provision-apps.sh index 8f2ea40..4c7ab59 100755 --- a/scripts/provision-apps.sh +++ b/scripts/provision-apps.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# scripts/provision-apps.sh — single deployment entrypoint for cloud envs (dev|ppe|prod). +# scripts/provision-apps.sh — single deployment entrypoint for cloud envs (ci|ppe|prod). # # Run this MANUALLY (requires Owner at root scope to write app regs + repo admin to write # GitHub vars) on a fresh env or whenever Entra app regs change. CD redeploys then pick @@ -28,9 +28,9 @@ # IMAGE_TAG: optional; defaults to `latest`. # # Usage: -# ./scripts/provision-apps.sh ENV=dev -# IMAGE_TAG=sha-abc1234 ./scripts/provision-apps.sh ENV=dev -# LOCATION=westeurope ./scripts/provision-apps.sh ENV=dev # override region (default: eastus) +# ./scripts/provision-apps.sh ENV=ci +# IMAGE_TAG=sha-abc1234 ./scripts/provision-apps.sh ENV=ci +# LOCATION=westeurope ./scripts/provision-apps.sh ENV=ci # override region (default: eastus) # WHAT_IF=1 ./scripts/provision-apps.sh ENV=ppe # preview only; no resource changes # # Prereqs: bash 4+, az CLI logged in, gh CLI authenticated, jq. @@ -48,13 +48,16 @@ for arg in "$@"; do ENV=*) ENV="${arg#ENV=}" ;; IMAGE_TAG=*) IMAGE_TAG="${arg#IMAGE_TAG=}" ;; -h|--help) sed -n '2,38p' "$0" | sed 's/^# \?//'; exit 0 ;; - *) echo "unknown arg: $arg (expected ENV=dev|ppe|prod [IMAGE_TAG=...])" >&2; exit 2 ;; + *) echo "unknown arg: $arg (expected ENV=ci|ppe|prod [IMAGE_TAG=...])" >&2; exit 2 ;; esac done case "$ENV" in - dev|ppe|prod) ;; - *) echo "ERROR: ENV must be one of dev|ppe|prod (got '${ENV}')." >&2; exit 2 ;; + ci|ppe|prod) ;; + dev) + echo "WARNING: ENV=dev is a deprecated alias for ENV=ci (renamed in env-rename PR). Translating; please update your callers." >&2 + ENV=ci ;; + *) echo "ERROR: ENV must be one of ci|ppe|prod (got '${ENV}')." >&2; exit 2 ;; esac for tool in az jq gh; do