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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/cd-cleanup.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ permissions:
jobs:
scale-down:
runs-on: ubuntu-latest
timeout-minutes: 10
# Per-matrix-env GitHub Environment so each iteration uses its own
# AZURE_CLIENT_ID / AZURE_SUBSCRIPTION_ID vars + secrets. Without this,
# both ci and ppe would resolve from whatever single environment was
Expand Down
11 changes: 8 additions & 3 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ jobs:
build-images:
if: github.event_name == 'push' || github.event.inputs.imageTag == ''
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
packages: write
Expand Down Expand Up @@ -153,6 +154,7 @@ jobs:
always() &&
(needs.build-images.result == 'success' || needs.build-images.result == 'skipped')
runs-on: ubuntu-latest
timeout-minutes: 45
outputs:
imageTag: ${{ steps.tag.outputs.imageTag }}
steps:
Expand Down Expand Up @@ -184,7 +186,8 @@ jobs:
with:
environment: ci
image_tag: ${{ needs.resolve-image-tag.outputs.imageTag }}
secrets: inherit
secrets:
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}

deploy-ppe:
needs: [resolve-image-tag, deploy-ci]
Expand All @@ -200,7 +203,8 @@ jobs:
with:
environment: ppe
image_tag: ${{ needs.resolve-image-tag.outputs.imageTag }}
secrets: inherit
secrets:
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}

deploy-prod:
needs: [resolve-image-tag, deploy-ppe]
Expand All @@ -215,4 +219,5 @@ jobs:
with:
environment: prod
image_tag: ${{ needs.resolve-image-tag.outputs.imageTag }}
secrets: inherit
secrets:
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ concurrency:
jobs:
build-test:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

Expand Down Expand Up @@ -75,6 +76,7 @@ jobs:
dependency-review:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
pull-requests: write
Expand All @@ -87,6 +89,7 @@ jobs:

bicep-lint:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

Expand Down Expand Up @@ -149,6 +152,7 @@ jobs:
# they merge.
actionlint:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

Expand All @@ -163,6 +167,7 @@ jobs:
# allowlists known false positives (Azure built-in RBAC role GUIDs).
gitleaks:
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
steps:
Expand All @@ -178,6 +183,7 @@ jobs:

markdownlint:
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
steps:
Expand All @@ -191,6 +197,7 @@ jobs:

link-check:
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
issues: write
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/deploy-env.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,18 @@ on:
apigateway_redirect:
description: BFF OIDC redirect URI (for app-reg consent verification).
value: ${{ jobs.deploy.outputs.apigateway_redirect }}
secrets:
AZURE_TENANT_ID:
description: Entra tenant id for OIDC federated login. Repo-level secret.
required: true

permissions:
contents: read

jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 30
environment: ${{ inputs.environment }}
concurrency:
group: cd-deploy-${{ inputs.environment }}
Expand Down
21 changes: 5 additions & 16 deletions .github/workflows/pinact.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,7 @@
name: pinact

# Validates every third-party GitHub Action used in this repo is pinned to a
# *commit SHA* (not a mutable tag, and not an annotated-tag-object SHA either).
# Replaces the previous bespoke /tmp/pin.py script that mishandled annotated
# tags and produced unverifiable SHAs (see PR #79 follow-up).
#
# Tool: https://github.com/suzuki-shunsuke/pinact (industry standard).
# Action: https://github.com/suzuki-shunsuke/pinact-action
# Validate every 3rd-party GitHub Action is pinned to a commit SHA (not a mutable tag).
# Tool: https://github.com/suzuki-shunsuke/pinact

on:
pull_request:
Expand All @@ -23,21 +18,15 @@ permissions:
contents: read

jobs:
verify:
name: Verify Action pins
pinact:
name: pinact
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: suzuki-shunsuke/pinact-action@cf51507d80d4d6522a07348e3d58790290eaf0b6 # v2.0.0
with:
# Validate-only: do not auto-push fix commits.
# PRs from same-repo branches don't get contents:write on GITHUB_TOKEN,
# and on main, failing CI loudly is preferable to a silent "pinact" bot
# commit (author should run `pinact run --fix` locally instead).
skip_push: "true"
# --check: fail if any action is unpinned (mutable ref).
# --verify: fail if pinned SHA does not match the version comment
# (catches the annotated-tag-object-SHA bug fixed in PR #87).
pinact_opts: --check --verify
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
1 change: 1 addition & 0 deletions .github/workflows/repo-rulesets.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ permissions:
jobs:
drift:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

Expand Down
1 change: 1 addition & 0 deletions .github/workflows/scorecard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ concurrency:
jobs:
analyze:
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
# Required for SARIF upload to code-scanning.
security-events: write
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/wi-demo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ permissions:
jobs:
acquire-token:
runs-on: ubuntu-latest
timeout-minutes: 5
environment: demo
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
Expand Down
14 changes: 14 additions & 0 deletions DOCTRINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,20 @@ Everything else — language, framework, IaC, CI/CD, observability, FinOps, supp

---

## Authorization-policy doctrine (canonical)

Source files reference this section instead of repeating the rationale inline.

**The two-named-policy rule.** Resource APIs that accept both delegated and app callers expose **two** named policies (`*Delegated` for users, `*App` for S2S) — never an `OR` policy and never a single policy that ORs `scp` with `roles`. Each policy is mutually exclusive: a token carrying both `scp` and `roles` is rejected by both. Multi-tenant app-only resources expose one `*App` policy combining the role check, the `azp` allow-list, and `scp`-rejection.

**Never `[Authorize(Roles = "...")]` on Microsoft.Identity.Web JWT schemes.** Those schemes set `MapInboundClaims = false` (defense-in-depth — see `EntraAuthServiceCollectionExtensions`), so the framework's role check looks for `ClaimTypes.Role` while Entra emits the role claim under the short name `roles`. The check silently fails: depending on what other `[Authorize]` attributes are present, this either locks everyone out or — worse — locks no one out. Always use the named-policy attributes (`[Authorize(Policy = OrdersAuthorizationPolicies.App)]`).

**Why `azp` allow-list is required for app tokens.** `roles` proves *what* the caller wants to do; `azp` proves *who* the caller is. Without `azp` checking, any tenant admin who consents your app's app-role to *their own* malicious client can call you. The allow-list pins the set of client appIds you trust at the workload level, not the consent level.

References: dotnet-engineering-guide ch02 §10, [`docs/decision-trees.md`](./docs/decision-trees.md) Tree 4, [`docs/validation.md`](./docs/validation.md) §4.

---

## Sources

- Microsoft.Identity.Web — [learn.microsoft.com/entra/identity-platform/microsoft-identity-web](https://learn.microsoft.com/entra/identity-platform/microsoft-identity-web)
Expand Down
16 changes: 3 additions & 13 deletions infra/bicep/modules/aca-stack.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -29,22 +29,12 @@ param tags object = {}
@description('Resolved Entra wiring (tenantId, app reg appIds, kitchen-worker MI clientId, downstream FQDNs). Empty `{}` on cold deploy → no Entra env vars are injected and apps fall back to appsettings.json placeholders. Populated by scripts/provision-apps.sh after Entra app regs and worker MI are known.')
param entraConfig object = {}

// REQUIRED ORDER: services[0]=apiGateway, services[1]=ordersApi,
// services[2]=restaurantsApi, services[3]=kitchenWorker.
// The `services` output below indexes containerApps[] positionally because
// Bicep does not allow for-expressions inside `toObject(...)` for output
// values (BCP138) AND vars cannot reference module outputs (BCP182). Until
// that limitation is lifted (tracked as a follow-up to migrate to a true
// keyed-map output), callers MUST preserve both the length AND the order
// of the default array — overriding with a different ordering will silently
// produce a mis-keyed `services` map (e.g. apiGateway.fqdn pointing at the
// orders-api ingress). Bicep has no `assert` keyword, so this constraint
// is enforced by convention + review, not at template-evaluation time.
@description('Service definitions. project = csproj folder name; shortName = lowercase image/name suffix; key = stable Bicep map key (camelCase) used to dispatch env vars and build the output map; isWebApp = whether to expose HTTP ingress + /health/live + /health/ready probes. REQUIRED ORDER: [0]=apiGateway, [1]=ordersApi, [2]=restaurantsApi, [3]=kitchenWorker — the `services` output is positionally keyed against this ordering. Do NOT reorder or resize without also rewriting the `services` output map below.')
// REQUIRED ORDER: see `requiredOrder` in `services` param metadata below.
// The output map is positionally keyed; reordering breaks downstream wiring.
@description('Service definitions. project = csproj folder; shortName = lowercase image suffix; key = camelCase Bicep map key; isWebApp = expose HTTP ingress + probes. Position-keyed against the `services` output — preserve order and length.')
@metadata({
requiredOrder: [ 'apiGateway', 'ordersApi', 'restaurantsApi', 'kitchenWorker' ]
requiredLength: 4
orderingConstraint: 'The `services` output below is positionally keyed against this array (containerApps[0]=apiGateway, etc). Reordering or resizing produces a silently mis-keyed output — do not override unless you also rewrite the output map.'
})
param services array = [
{ project: 'Ftgo.ApiGateway', shortName: 'apigateway', key: 'apiGateway', isWebApp: true }
Expand Down
17 changes: 5 additions & 12 deletions infra/bicep/modules/app-registrations.bicep
Original file line number Diff line number Diff line change
@@ -1,18 +1,11 @@
metadata name = 'app-registrations'
metadata description = 'Provisions the 3 FTGO Entra app registrations + service principals (BFF, Orders API, Restaurants API) and exposes deterministic scope/role IDs. Workers run as Managed Identity and do not need their own app reg.'

// IMPORTANT: bffMiClientId must NEVER be passed as empty on a re-run after the
// initial warm deploy. The FIC resource depends on this value as its 'subject'.
// If you re-run with an empty bffMiClientId, the FIC will be deleted, causing
// the BFF to lose its trust relationship with the BFF UAMI. The bootstrap
// flow (scripts/provision-apps.sh) must always pass the populated value.
//
// Cold-deploy semantics (bffMiClientId == '') are intentional and safe ONLY
// before the BFF Container App + its system-assigned MI exist. Once the FIC
// has been created, every subsequent tenant-scope deploy MUST resolve and
// pass the BFF MI clientId or the Microsoft.Graph extension will reconcile
// the FIC out of existence and break SignedAssertionFromManagedIdentity for
// the BFF until the next provision-apps.sh run.
// IMPORTANT: bffMiClientId must NEVER be empty after the BFF MI exists — the
// Microsoft.Graph extension would reconcile the FIC out of existence and break
// SignedAssertionFromManagedIdentity. Cold deploy passes '' intentionally;
// scripts/provision-apps.sh handles the resolve+rerun. Recovery: re-run
// provision-apps.sh. Full procedure: docs/operations.md § "CD identity (UAMI) recovery".

// Scope/role IDs are deterministic GUIDs of (tenantId, app, value) so callers' references survive re-deploys.

Expand Down
20 changes: 2 additions & 18 deletions infra/bicep/modules/cd-bootstrap.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -37,24 +37,8 @@ resource fic 'Microsoft.ManagedIdentity/userAssignedIdentities/federatedIdentity
}
}

// Deterministic GUID → idempotent re-runs (same scope+principal+role → same name).
//
// NOTE: Role assignments are scoped to the UAMI's principalId at creation.
// If you delete and recreate the CD UAMI with the same name, the principalId
// changes; the OLD role assignments will linger and the deployment will fail
// with 'role assignment already exists'. Manually delete the old assignments
// before re-running. See operations.md §<add-section> for the recovery
// procedure.
//
// Concretely: `mi.id` (resourceId) stays stable across delete+recreate
// because it is derived from name+RG, so `guid(rg.id, mi.id, roleId)` —
// which we use as the assignment name — also stays stable. ARM keys role
// assignments by name (immutable principalId on the existing record), so
// the redeploy attempts to update an immutable field and 409s.
//
// Recovery: `az role assignment delete --assignee <old-principalId>` (or
// `az role assignment list --scope <rg> --query "[?principalId=='<old>'].id"
// | xargs -n1 az role assignment delete --ids`) then re-run bootstrap.bicep.
// Deterministic name → idempotent re-runs. Recovery procedure for the
// "principalId changed after MI recreate" 409 → docs/operations.md § "CD identity (UAMI) recovery".
resource roleAssignments 'Microsoft.Authorization/roleAssignments@2022-04-01' = [for roleId in roleIds: {
name: guid(resourceGroup().id, mi.id, roleId)
properties: {
Expand Down
2 changes: 1 addition & 1 deletion infra/bicep/modules/container-app.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ param extraEnvVars array = []

var aspNetCoreEnvironment = '${toUpper(substring(environmentName, 0, 1))}${substring(environmentName, 1)}'

resource app 'Microsoft.App/containerApps@2024-10-02-preview' = {
resource app 'Microsoft.App/containerApps@2025-07-01' = {
name: appName
location: location
tags: tags
Expand Down
2 changes: 1 addition & 1 deletion infra/bicep/modules/container-apps-environment.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ resource law 'Microsoft.OperationalInsights/workspaces@2023-09-01' existing = {
name: logAnalyticsWorkspaceName
}

resource cae 'Microsoft.App/managedEnvironments@2024-10-02-preview' = {
resource cae 'Microsoft.App/managedEnvironments@2025-07-01' = {
name: name
location: location
tags: tags
Expand Down
2 changes: 1 addition & 1 deletion infra/bicep/modules/key-vault-rbac.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ param principalIds array
// Reference: https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#key-vault-secrets-user
var keyVaultSecretsUserRoleId = '4633458b-17de-408a-b874-0445c86b69e6'

resource kv 'Microsoft.KeyVault/vaults@2024-04-01-preview' existing = {
resource kv 'Microsoft.KeyVault/vaults@2024-11-01' existing = {
name: keyVaultName
}

Expand Down
2 changes: 1 addition & 1 deletion infra/bicep/modules/key-vault.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ param softDeleteRetentionInDays int = 7
@description('When true, the vault cannot be purged before retention expires (irreversible). Required for prod compliance; off in non-prod so churned environments do not pile up undeletable vaults.')
param enablePurgeProtection bool = false

resource kv 'Microsoft.KeyVault/vaults@2024-04-01-preview' = {
resource kv 'Microsoft.KeyVault/vaults@2024-11-01' = {
name: name
location: location
tags: tags
Expand Down
12 changes: 12 additions & 0 deletions nuget.config
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources>
<packageSourceMapping>
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
</packageSourceMapping>
</configuration>
30 changes: 7 additions & 23 deletions scripts/bootstrap-env.sh
Original file line number Diff line number Diff line change
@@ -1,29 +1,13 @@
#!/usr/bin/env bash
# scripts/bootstrap-env.sh — one-time per-environment bootstrap for the cloud CD pipeline.
# bootstrap-env.sh — one-time per-tier bootstrap (RG + UAMI + FIC + RBAC + GH env).
# Idempotent. See docs/operations.md § "Provisioning a brand-new tier".
#
# Azure side (declarative): infra/bicep/bootstrap.bicep deploys
# 1. Resource group rg-ftgo-${ENV}-eastus
# 2. User-assigned MI ftgo-${ENV}-cd-mi
# 3. Federated credential github-${ENV} (subject: repo:OWNER/REPO:environment:ENV)
# 4. RBAC Contributor on the RG (+ User Access Admin for prod)
#
# GitHub side (imperative — outside Azure ARM):
# 5. GitHub Environment ${ENV} (with required reviewer for prod)
# 6. GH env vars AZURE_CLIENT_ID, AZURE_SUBSCRIPTION_ID
# 7. Repo secret AZURE_TENANT_ID (one-time, shared across envs)
#
# Idempotent: safe to re-run. Bicep deployment uses deterministic names; gh PUT
# semantics upsert.
#
# Usage:
# ./scripts/bootstrap-env.sh ENV=ci
# ./scripts/bootstrap-env.sh ENV=ppe
# ./scripts/bootstrap-env.sh ENV=prod
#
# Prereqs: bash 4+, az CLI logged in to the target subscription with Owner role,
# gh CLI authenticated to the repo, jq.
# Usage: ./scripts/bootstrap-env.sh ENV=ci|ppe|prod
# Prereqs: bash 4+, az CLI (Owner), gh CLI, jq.

set -euo pipefail
IFS=$'\n\t'
trap 'echo "FATAL: $(basename "$0") failed on line $LINENO" >&2; exit 1' ERR

if (( BASH_VERSINFO[0] < 4 )); then
echo "ERROR: bash 4+ required (you have ${BASH_VERSION})." >&2
Expand All @@ -35,7 +19,7 @@ ENV=""
for arg in "$@"; do
case "$arg" in
ENV=*) ENV="${arg#ENV=}" ;;
-h|--help) sed -n '2,24p' "$0" | sed 's/^# \?//'; exit 0 ;;
-h|--help) sed -n '2,7p' "$0" | sed 's/^# \?//'; exit 0 ;;
*) echo "unknown arg: $arg (expected ENV=ci|ppe|prod)" >&2; exit 2 ;;
esac
done
Expand Down
Loading
Loading