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