Skip to content

OSPO Hardening Updates — Design

Date: 2026-06-01 Status: Draft (pending spec review) Triggered by: SAP OSPO Hardening Controls report, Controls 5 + 7

Background

OSPO's Hardening Controls dashboard flagged two findings on SAP-samples/sap-devs-cli:

IDControlStatusDescription
5Restrict main / release branches with Branch Restrictions❌ Action RequiredMain/release branches should be protected with branch protection rules or rulesets.
7Access Restrictions⚠️ WarningRepository access should follow least privilege with limited admin count.

The repo has a ruleset on main (.github/rulesets/main-protection.json), but it grants bypass_mode: "always" to the Admin role. OSPO's checker treats "admins always bypass" as effectively unprotected — anyone with admin can push directly to main, skipping the PR + status-check gates documented in CLAUDE.md under "OSPO compliance."

A separate audit shows the repo carries 25 admin collaborators, well above OSPO's least-privilege threshold of 3–5. The two findings are coupled: while admins can bypass the ruleset, the size of the admin set determines how many people can effectively rewrite main.

Goals

  • G1. Move Control 5 from "Action Required" to "Pass" by removing admin bypass on the main ruleset.
  • G2. Keep the existing automation (release pipeline, scheduled news-sync) working without granting blanket bypass.
  • G3. Provide the project owner a per-account demotion plan for Control 7 — without executing demotions in this change.
  • G4. Document the rollback path so any unintended breakage is one revert away.

Non-goals

  • Demoting any admin in this PR. The drafted list is for the project owner to act on (via OSPO ticket or org-admin tooling).
  • Tightening any rule beyond what Control 5 demands (no required_signatures, no required_linear_history, no review-thread-resolution requirement). Each adds friction without addressing the flagged finding.
  • Touching the release pipeline. It runs as workflow_dispatch and pushes a tag (not to main), which is unaffected by branch rulesets.

Design

Change 1 — Ruleset bypass scope

Edit .github/rulesets/main-protection.json:

diff
   "bypass_actors": [
-    {"actor_id": 5, "actor_type": "RepositoryRole", "bypass_mode": "always"}
+    {"actor_id": 15368, "actor_type": "Integration", "bypass_mode": "pull_request"}
   ]
FieldValueMeaning
actor_id: 15368GitHub Actions integration IDThe well-known, stable ID for the github-actions[bot]
actor_type: "Integration"GitHub App / integration classDistinct from RepositoryRole (humans by role)
bypass_mode: "pull_request"Narrow bypassBot can merge a PR that doesn't satisfy rules; cannot push directly to main

After this change:

  • No human, including admins, can git push origin main. Every change goes through a PR with ≥1 approver and a passing test status check.
  • github-actions[bot] PRs can self-merge without an approver, but the test status check still has to pass (it is a required_status_checks rule, not a bypass-eligible one).
  • Direct push to main from any actor is impossible — the integration's bypass is scoped to PR-merge, not push.

The ruleset JSON in this repo is a declarative source of truth, not auto-applied. The owner (or an org admin) imports it via the GitHub UI ("Import a ruleset") or the API. Updating the file is the engineering deliverable; applying it is an operational step.

Change 2 — news-sync workflow refactor

.github/workflows/news-sync.yml currently does git commit && git push directly to main after fetching the news episode index. With Change 1 applied, this fails: the bot's bypass is "pull_request" only, not "always".

Operational prerequisite: the repository setting Settings → General → Pull Requests → Allow auto-merge must be enabled. gh pr merge --auto is a no-op (errors out) if this setting is off. This is a one-time toggle, applied alongside the ruleset import.

Workflow-level changes:

  1. Expand the top-level permissions: block so gh pr create / gh pr merge can call the PRs API:

    yaml
    permissions:
      contents: write
      pull-requests: write
  2. Add a concurrency: block to serialize overlapping runs. Without this, a manual workflow_dispatch triggered while the cron run is mid-flight will force-push to the same bot branch and invalidate the in-flight auto-merge queue:

    yaml
    concurrency:
      group: news-sync
      cancel-in-progress: false
  3. Replace the final commit-and-push step with a PR-based flow using only the built-in gh CLI (no third-party action introduced):

    yaml
    - name: Open or update PR if changed
      env:
        GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      run: |
        git config user.name "github-actions[bot]"
        git config user.email "github-actions[bot]@users.noreply.github.com"
        if git diff --quiet content/packs/base/news-episodes.json 2>/dev/null; then
          echo "No changes."
          exit 0
        fi
        BRANCH="bot/news-sync-update"
        git checkout -B "$BRANCH"
        git add content/packs/base/news-episodes.json
        git commit -m "chore: update news episode index"
        git push -f origin "$BRANCH"
        EXISTING=$(gh pr list --head "$BRANCH" --json number -q '.[0].number // empty' 2>/dev/null || echo "")
        if [ -z "$EXISTING" ]; then
          gh pr create \
            --title "chore: update news episode index" \
            --body  "Automated update of \`content/packs/base/news-episodes.json\`." \
            --base  main --head "$BRANCH"
        else
          echo "PR #$EXISTING already open; new commit pushed to $BRANCH"
        fi
        gh pr merge --auto --squash "$BRANCH"

    gh pr merge accepts the branch name directly, so no fragile output-parsing of gh pr create. Calling gh pr merge --auto on every run (whether we just opened the PR or refreshed an existing one) is idempotent — if auto-merge is already queued, it's a no-op.

Behaviour:

  • A stable bot branch (bot/news-sync-update) is force-pushed each run. One PR, refreshed in place, until merged or closed.
  • gh pr merge --auto --squash queues the merge to fire once the test check passes. No human approval required (Change 1's narrow bypass).
  • The required test status check (defined in .github/workflows/ci.yml) runs on the git push -f event because ci.yml triggers on both push and pull_request. The required_status_checks rule matches by check-run name, so the push-triggered test run satisfies the gate even though GITHUB_TOKEN-authored PRs don't fire pull_request-triggered runs. This design depends on ci.yml keeping push as a trigger — a future maintainer who removes it must either re-add it or switch news-sync to a PAT/GitHub App token.
  • If a PR is already open against the same branch (e.g., test failure left it open), the next run just refreshes the branch and re-arms auto-merge.
  • The cron cadence stays at 2x/day. Net user-visible change: the news index updates land via reviewable PR commits instead of opaque direct pushes.

Change 3 — Admin demotion draft (Control 7)

The current admin roster (25 accounts) classified for the project owner:

BucketLoginsCountRecommendation
Project leads — keep adminqmacro, jung-thomas, ajmaradiaga3Keep
Org-mandated — cannot demoteSAP-OSPO-ADMIN, sap-ospo-bot, SebastianWolf-SAP, nicoschoenteich, christianneu, dellagustin-sap6Verify with OSPO; out of repo-owner reach
Inherited / unclear — propose demote to maintainakula86, ihrigb, vipinvkmenon, Sygyzmundovych, KevinRiedelsheimer, rbrainey, thecodester, ajinkyapatil8190, Shegox, rich-heilman, noravth, sheenamk, PoojaGidaveer, btbernard, ajaysoreng, neelamegams16Demote unless confirmed active maintainer

If the bottom bucket is fully demoted, the admin count drops from 25 to ~9 (3 leads + 6 org-mandated). If the org-mandated set can also be pruned via OSPO, the count approaches the 3–5 threshold OSPO checks for.

No demotions execute as part of this change. The list is for the project owner to confirm per-account and act on via the GitHub UI or gh api.

Data flow

text
┌─────────────────┐
│   Cron 2x/day   │
└────────┬────────┘

┌─────────────────┐
│ news-sync       │
│ workflow        │
└────────┬────────┘
         │ git push -f origin bot/news-sync-update    (allowed — feature branch)
         │ gh pr create --base main

┌─────────────────────────────────┐
│ PR (bot-authored, auto-merge)   │
│  ─ test status check runs       │
│  ─ no human approval needed     │
│    (bypass: Integration 15368)  │
└────────┬────────────────────────┘
         │ checks pass + gh pr merge --auto

┌─────────────────┐
│   main updated  │
└─────────────────┘

Human-authored PRs follow the same path but without the bypass — they need 1 approving review and a passing test check.

Error handling & rollback

Deployment ordering matters. The two repo changes must land in this order:

  1. Merge the workflow refactor (Change 2) to main.
  2. Enable Settings → General → Pull Requests → Allow auto-merge.
  3. Import the new ruleset (Change 1) via the GitHub UI or gh api.

If steps are reversed (ruleset imported before the workflow lands), the next scheduled news-sync run fails with a ruleset violation on git push origin main. No data loss — just a failed run until the workflow change merges.

This PR is the last admin-bypassable merge before the new posture takes effect. The very PR that introduces these changes cannot itself benefit from the bot's narrow PR-merge bypass (the bypass is only in force after the ruleset is imported). It must merge under the current admin-bypass ruleset, which is fine.

ScenarioHandling
Ruleset breaks an unforeseen workflowgit revert the ruleset commit and re-import via the GitHub UI / gh api. The old (admin-bypass) state is one revert away.
news-sync PR can't auto-merge (test fails on the bot's PR)PR stays open; next cron run force-pushes to the same branch, refreshing the diff. A human can intervene. No data loss.
bot/news-sync-update branch grows stale during a long test outageForce-push semantics keep the branch current; the PR auto-updates.
Two news-sync runs overlapThe concurrency: news-sync block serializes them — second run waits for the first to finish.
GitHub Actions Integration ID changes (~unprecedented)Update actor_id in the ruleset.
Release pipeline regressionUnaffected — release pushes a tag, not to main. Tags are not gated by branch rulesets.

Testing plan

Verification is empirical (the ruleset only takes effect server-side after import):

  1. Direct push blocked — apply the new ruleset, attempt git push origin main from a maintainer clone. Must fail with a ruleset violation.
  2. PR approval enforced — open a PR with no approvers; the GitHub UI "Merge" button must be greyed out for humans.
  3. Bot auto-merge worksgh workflow run news-sync.yml (or wait for the next cron). Verify: PR opens, test check runs, PR auto-merges, main reflects the update.
  4. Stale-PR behaviour — manually fail the test check on a bot PR (e.g., temporarily push a broken commit to the same branch); confirm the PR stays open and the next run refreshes it.
  5. Release pipeline unchangedgh workflow run release.yml -f version=<next> on a test version. Tag pushes successfully; release artifacts upload.

Open questions

None blocking. Items the project owner needs to follow up on after this change merges:

  • File an OSPO ticket to prune the org-mandated admin bucket if least-privilege thresholds need to be met.
  • Decide per-account on the 16 "Inherited / unclear" admins listed above.

Files touched