Skip to content

fix(security): Restaurants named-policy + ruleset CODEOWNER gate + scorecard pin refresh#102

Merged
mghabin merged 2 commits intomainfrom
fix/restaurants-policy-and-ruleset-hardening
May 1, 2026
Merged

fix(security): Restaurants named-policy + ruleset CODEOWNER gate + scorecard pin refresh#102
mghabin merged 2 commits intomainfrom
fix/restaurants-policy-and-ruleset-hardening

Conversation

@mghabin
Copy link
Copy Markdown
Owner

@mghabin mghabin commented May 1, 2026

Greenfield-audit findings (3 parallel audits: C#, infra, docs) — one PR per real finding.

What this fixes

1. Restaurants API silent role-check bypass (security)

RestaurantsController used [Authorize(Roles="Restaurants.Read.All")] + [RequireClientApp]. Doctrine sets MapInboundClaims = false so the JWT roles claim is preserved verbatim — but [Authorize(Roles=...)] resolves against ClaimTypes.Role, not "roles". The role check silently never matched. Switched to the named-policy pattern via the existing AddAppPolicy helper (same shape Ftgo.Orders.Api uses).

Added 6 end-to-end tests including 2 explicit regression guards that would catch any future re-introduction of [Authorize(Roles=...)] under MapInboundClaims=false.

2. Ruleset PR-approval gate + pinact required check (supply-chain)

  • required_approving_review_count: 0 → 1
  • require_code_owner_review: false → true
  • Added Repository Admin role (actor_id=5) to bypass_actors so the solo-maintainer gh pr merge --admin --squash flow keeps working
  • Added pinact to required_status_checks

3. scorecard.yml SHA refresh

  • actions/checkout v5.0.1 → v6.0.2
  • actions/upload-artifact v4.6.2 → v7.0.1
  • github/codeql-action/upload-sarif v3.28.0 → v4.35.2

Verification

  • dotnet test: 66/66 (was 60, +6 new Restaurants policy tests)
  • dotnet format --verify-no-changes: clean
  • actionlint scorecard.yml: clean

Out of scope (audit explicitly classified LOW)

  • SCOPE.md anti-patterns section, best-practices.md threat-model bullets, shellcheck SC2155 in provision-apps.sh, cd-cleanup.yml unsafe loop. Defer to a follow-up.

…orecard pin refresh

Greenfield-audit findings shipped in one PR.

# Restaurants API: silent role-check bypass under MapInboundClaims=false

RestaurantsController used [Authorize(Roles="Restaurants.Read.All")] +
[RequireClientApp]. Doctrine sets MapInboundClaims=false (so the JWT
'roles' claim is preserved verbatim and not remapped to ClaimTypes.Role),
so [Authorize(Roles=...)] silently never matches: the framework looks up
ClaimTypes.Role on the identity, but the claim's type is the short name
'roles'. The endpoint either always-403'd, or — worse — relied on
[RequireClientApp] alone, letting any allow-listed app read regardless
of whether it had been granted the role.

Switched to the named-policy pattern via the existing AddAppPolicy helper
(same shape Ftgo.Orders.Api uses for its app surface). Added 6 end-to-end
tests including two explicit regression guards
(RestaurantsApp_Rejects_AllowListedAppWithoutRequiredRole +
NoRolesClaim) that would catch a future re-introduction of
[Authorize(Roles=...)] under MapInboundClaims=false.

# .github/rulesets/main.json: PR-approval gate + pinact required check

Bumped required_approving_review_count 0 -> 1 with
require_code_owner_review: true, and added the Repository Admin role
(actor_id=5) to bypass_actors so the solo-maintainer self-merge
workflow (gh pr merge --admin --squash) keeps working. Added 'pinact'
to required_status_checks. Did not add 'scorecard' as required since
it is scheduled+push-to-main (not run on every PR).

# .github/workflows/scorecard.yml: action SHA refresh

Brought scorecard.yml in line with the rest of the workflows:
  - actions/checkout v5.0.1 -> v6.0.2
  - actions/upload-artifact v4.6.2 -> v7.0.1
  - github/codeql-action/upload-sarif v3.28.0 -> v4.35.2

# Test results

dotnet test: 66/66 (was 60, +6 new Restaurants policy tests).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@mghabin
Copy link
Copy Markdown
Owner Author

mghabin commented May 1, 2026

Applied updated ruleset to live via gh api -X PUT /repos/.../rulesets/15679789. Re-running drift check.

The default secrets.GITHUB_TOKEN does not have admin scope so the rulesets
API hides the bypass_actors field from it. After applying a ruleset that
declares bypass_actors via an admin PAT (locally), the next CI drift run
sees bypass_actors in the committed file but not in the API response and
reports false-positive drift forever.

Strip bypass_actors from both sides of the diff. The committed file still
documents intent — verify the live state out-of-band with an admin PAT
(e.g. gh api /repos/{owner}/{repo}/rulesets/{id} --jq .bypass_actors).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@mghabin mghabin merged commit 20f082c into main May 1, 2026
12 checks passed
@mghabin mghabin deleted the fix/restaurants-policy-and-ruleset-hardening branch May 1, 2026 12:53
mghabin added a commit that referenced this pull request May 1, 2026
…st-class tier (#103)

## Why

The auto-deployed first cloud tier was named **`dev`**, which conflated
two things:

1. The tier where developer-machine work happens (a laptop, no
   Azure resources, `dotnet run`).
2. The tier where CI auto-deploys every push to \`main\` without a
   human gate (an Azure RG, real cloud APIs, public ingress).

Calling them both \"dev\" hid what was actually running where. The
honest
name for #2 is **\`ci\`** (built and deployed by CI, no human gate),
and #1 is its own first-class tier called **\`local\`** (no Azure, no
GH Environment, configured via \`appsettings.Development.json\` +
\`dotnet user-secrets\`).

Final ladder: **\`local → ci → ppe → prod\`**. \`ppe\` (Microsoft jargon
for \"pre-production environment\", analogous to \"staging\" elsewhere)
is unchanged.

## What this PR does

**Phase A — codebase rename** (commit \`dbd6537\`):

- \`infra/bicep/{azure,main,bootstrap}.dev.bicepparam\` →
\`.ci.bicepparam\`
- \`@allowed([ 'dev', 'ppe', 'prod' ])\` → \`@allowed([ 'ci', 'ppe',
'prod' ])\` in main/azure/bootstrap.bicep
- \`devPublicClients\` var → \`ciPublicClients\` (gated on
\`environmentName == 'ci'\`)
- \`scripts/{bootstrap-env,provision-apps}.sh\`: default ENV is now
\`ci\`; \`ENV=dev\` still accepted with a deprecation warning
- \`.github/workflows/{cd,cd-cleanup,deploy-env,ci}.yml\`:
\`deploy-dev\` job → \`deploy-ci\`, matrix env list, default
workflow_dispatch input

**Phase B — docs sweep** (commit \`cb6021e\`):

- \`docs/environments.md\` rewritten as a **4-tier table** (local / ci /
ppe / prod) with explicit \`ASPNETCORE_ENVIRONMENT\` mapping per tier
and idle cost
- \`docs/run-locally.md\` opening promotes \`local\` to the documented
first tier
- \`glossary.md\` gains entries for \`ci\`, \`local\`, \`ppe\` with
primary-source citations
- Mechanical rename across operations.md, cost-zero.md, deploy-cloud.md,
sample-setup.md, matrix.md, best-practices.md, README.md, DOCTRINE.md,
coverage-map.md

**Phase C — live cutover runbook** (commit \`6b39dfe\`):

- \`docs/operations.md\` gains a \"Renaming a deployment tier (one-shot
live cutover)\" runbook with the **OIDC-safe step order** (provision new
tier → migrate \`ENTRA_CONFIG_JSON\` → smoke test → tear down old). The
non-obvious bit is FIC subjects: the GitHub OIDC token's \`sub\` claim
is matched **verbatim** against the FIC subject, so renaming the GH
environment before the FIC fails with \`AADSTS70021\`.

**Plus** (commit \`f35c5d2\`): README's stale references to the deleted
credential-patterns demo (\`orderservice\` / \`restaurantservice\`) are
updated to the live app reg names (\`orders-api\` /
\`restaurants-api\`).

## Live cutover not yet executed

The PR is **codebase-only**. Live Azure / GitHub changes (provisioning
\`rg-ftgo-ci-eastus\`, migrating \`ENTRA_CONFIG_JSON\` to a new \`ci\`
GH
env, updating FIC subjects, tearing down \`rg-ftgo-dev-eastus\`) are
deferred to a one-shot operation following the runbook in this PR
(\`docs/operations.md\` → \"Renaming a deployment tier\" section). Until
that happens, the \`dev\` tier still works because:

- \`scripts/{bootstrap-env,provision-apps}.sh\` accept \`ENV=dev\` as a
deprecated alias and translate it to \`ENV=ci\` (with a stderr warning).
- The existing \`dev\` GitHub Environment is unchanged. The auto-CD
  push trigger will fail to find the \`ci\` env after this PR merges
  until the cutover runs — that's intentional, so the cutover is
  done deliberately, not as a side-effect of merge.

## Verification

- ✅ \`dotnet test EntraAuthPatterns.slnx\` → 66/66
- ✅ \`az bicep build\` on every \`.bicep\` and every \`.bicepparam\`
(BCP427 on the params is environment-variable interpolation in CI,
expected)
- ✅ \`actionlint .github/workflows/*.yml\` clean
- ✅ \`markdownlint-cli2\` (CI's pinned version) clean
- ✅ The positive-path Restaurants 200 test
(\`RestaurantsApp_Allows_AppTokenWithRoleAndAllowListedAzp\` in PR #102)
still passes — that addresses the \"we never tested 200\" gap I
previously surfaced
- 🟡 Live cloud probe deferred to the cutover (see runbook)

## Out of scope (deliberate)

- Renaming \`ppe\` → \`staging\` (PPE is Microsoft jargon, kept by
preference)
- Adding ephemeral PR-preview environments (separate doctrine decision)
- Cosmetic rename of the live app reg display names
\`ftgo-dev-{apigateway,orders-api,restaurants-api}\` → \`ftgo-ci-…\`.
The appIds (the only thing \`ENTRA_CONFIG_JSON\` references) are
unchanged. Optional follow-up.

---------

Co-authored-by: mghabin <81494213+MohammadGhabin@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant