Repository Access Control & Policy Jump to heading
Every control in this section — signed commits, verification gates, provenance, scanning — assumes something about who can push what and where. If a single long-lived token can force-push to the default branch, none of the rest holds, and organisations reach that state gradually: a bypass granted during an incident, a deploy key made writable for convenience, a service account added to a team for one task. The controls are still configured and the guarantee is gone. This part of Commit Signing & Git Supply-Chain Security is about keeping access aligned with the policy everyone believes is in force.
Prerequisites Jump to heading
The Question Every Control Depends On Jump to heading
Before adding anything, answer this one: who or what can currently change the default branch, and by which routes. Most organisations discover at least one route nobody remembered.
# Humans and teams with write access
gh api repos/:owner/:repo/collaborators --jq '.[] | select(.permissions.push) | .login'
gh api repos/:owner/:repo/teams --jq '.[] | "\(.slug) \(.permission)"' # Automation: apps, deploy keys and tokens
gh api repos/:owner/:repo/installations --jq '.installations[]? | .app_slug' 2>/dev/null
gh api repos/:owner/:repo/keys --jq '.[] | "\(.title) read_only=\(.read_only)"' # Bypass lists, which are the route people forget
gh api repos/:owner/:repo/rulesets --jq '.[] | {name, bypass: [.bypass_actors[]?.actor_type]}' Step 1 — Express Policy as Rulesets, Not Per-Repository Settings Jump to heading
Settings configured repository by repository drift immediately. Organisation-level rulesets apply by pattern and are one thing to review.
# What applies today, across the organisation
gh api orgs/acme/rulesets --jq '.[] | "\(.name)\t\(.target)\t\(.enforcement)"' {
"name": "default-branch-protection",
"target": "branch",
"enforcement": "active",
"conditions": { "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] } },
"rules": [
{ "type": "deletion" },
{ "type": "non_fast_forward" },
{ "type": "required_signatures" },
{ "type": "pull_request",
"parameters": { "required_approving_review_count": 1,
"dismiss_stale_reviews_on_push": true,
"require_code_owner_review": true } }
],
"bypass_actors": []
} # Verification: the ruleset applies to the repositories you expect
gh api orgs/acme/rulesets/$ID --jq '.conditions.repository_name' An empty bypass_actors list is the setting that makes the rest meaningful. Every entry added to it is a route around everything above it, which is why it belongs in the audit rather than in the configuration you write once.
Step 2 — Scope Credentials to One Job Jump to heading
A token that can do everything is a token whose compromise costs everything.
# Deny by default at the workflow level, grant per job
permissions: {}
jobs:
test:
permissions:
contents: read
release:
permissions:
contents: write
packages: write
id-token: write # Verification: what could each job actually do?
gh api repos/:owner/:repo/actions/permissions/workflow --jq '.default_workflow_permissions' The same reasoning applies to deploy keys, machine accounts and personal tokens — the details are in scoping deploy keys and tokens and limiting workflow permissions per job.
Step 3 — Make Ownership a Gate Where It Matters Jump to heading
CODEOWNERS is advisory unless a rule requires it, and the paths where it matters are usually a small subset.
# Sensitive paths deserve their own rule
cat >> .github/CODEOWNERS <<'OWN'
/.github/workflows/ @acme/platform-security
/infra/ @acme/platform
/**/migrations/ @acme/data
OWN # Verification: a change to a workflow requires the named reviewers
gh pr view --json reviewRequests --jq '.reviewRequests[].slug' Workflow files deserve particular attention: whoever can change a workflow can change what runs with the repository’s credentials, which makes .github/workflows/ one of the highest-privilege paths in any repository.
Step 4 — Audit the Routes, Not the Intentions Jump to heading
Configuration says what should be true. An audit says what is.
# Who could push to the default branch right now, by any route?
gh api repos/:owner/:repo/collaborators --jq '.[] | select(.permissions.push) | .login' > /tmp/humans.txt
gh api repos/:owner/:repo/keys --jq '.[] | select(.read_only | not) | .title' > /tmp/keys.txt
gh api repos/:owner/:repo/rulesets --jq '.[].bypass_actors[]? | "\(.actor_type):\(.actor_id)"' > /tmp/bypass.txt
wc -l /tmp/humans.txt /tmp/keys.txt /tmp/bypass.txt # And who has actually pushed, which is the more revealing number
git log --format='%cn' origin/main --since='6 months ago' | sort | uniq -c | sort -rn # Verification: everyone in the first list should be explicable The gap between “can push” and “has pushed” is where access accumulates. A service account that has not pushed in a year still has the access it was granted, and the full procedure is in auditing who can push to protected branches.
Step 5 — Make Exceptions Visible and Temporary Jump to heading
Every control acquires exceptions. The failure is not granting them; it is granting them permanently and silently.
# Record the exception where it can be reviewed
cat >> access-exceptions.md <<'DOC'
## release-bot bypass on acme/app default branch
Granted: 2026-09-18 by the platform team, for INC-4471
Reason: The release job must push a version bump commit.
Expires: 2026-10-18 — replaced by a signed-commit release path.
Review: platform team, monthly access review.
DOC # And alert when it is used
gh api repos/:owner/:repo/rulesets/$ID/history --jq '.[] | "\(.created_at) \(.actor.login)"' | head SAFETY WARNING — an entry in a bypass list is a permanent, unlogged route around every rule the ruleset expresses, and organisations routinely discover entries added years earlier for a reason nobody remembers. Grant bypasses with an expiry date recorded outside the tool, alert on their use, and remove them in the monthly review rather than when someone notices.
Making the Policy Survive an Incident Jump to heading
Access controls are removed under pressure more often than they are defeated. The pattern is consistent: something is broken, the fix is blocked by a rule, and the fastest route is to relax the rule “temporarily”. Whether the control survives depends entirely on whether a legitimate fast path existed beforehand.
That path should be narrow, documented and loud. A named break-glass role that a small number of people can assume, which alerts when used and is reviewed afterwards, converts the moment from a configuration change made in a hurry into a procedure with a record. What it must not be is a bypass entry added to a ruleset at two in the morning, because that entry outlives the incident by years and nobody is looking for it.
The second thing that keeps policy intact is making the rules serve the work rather than obstruct it. A required review that cannot be satisfied at three in the morning because the only owner is asleep produces a bypass every time; an owning team with several members does not. Rules that are satisfiable under realistic conditions are rules that stay enabled, and reviewing them for that property is more valuable than adding another one.
Finally, be honest in the audit about what the controls do not cover. Repository access is one boundary among several: a compromised developer machine, a compromised CI runner, a package registry credential and a cloud role are all routes to the same outcome that no branch protection touches. Listing them explicitly is worth doing, because an audit that reports “access is controlled” without naming its scope invites a confidence nobody should have.
Configuration Reference Jump to heading
| Control | Effect | Where to set it |
|---|---|---|
| Organisation ruleset | Applies policy by repository pattern | Organisation settings |
required_signatures | Refuses unsigned commits | Ruleset rule |
non_fast_forward | Blocks force-pushes | Ruleset rule |
deletion | Blocks branch deletion | Ruleset rule |
require_code_owner_review | Ownership becomes a gate | Ruleset, plus CODEOWNERS |
permissions: {} | Denies all token scopes by default | Workflow file |
Deploy key read_only | Read-only automation access | Repository keys |
| Bypass actors | Route around every rule | Keep empty; audit monthly |
Troubleshooting Jump to heading
| Symptom | Likely cause | Fix |
|---|---|---|
| Rules configured but a push got through | The pusher is in a bypass list | Audit bypass actors |
| Ruleset does not apply to a repository | Repository name condition excludes it | Check the condition patterns |
| Required owner review never requested | Path pattern does not match | Test with git check-attr-style path probes |
| Automation broke after tightening tokens | Job needed a scope that was removed | Grant per job, not per workflow |
| Deploy key has write access nobody wanted | Created writable by default | Recreate as read-only |
| Nobody can say who can push | No audit has been run | Run the enumeration in Step 4 |
Frequently Asked Questions Jump to heading
Should administrators be subject to the rules? Jump to heading
Yes, with a documented break-glass path. An administrator exempt by default means the strongest accounts are the least constrained, which inverts the intent. Making administrators subject to the same rules and giving them an explicit, logged override keeps the capability while making its use visible.
How often should access be reviewed? Jump to heading
Monthly for bypasses and automation, quarterly for human access, and immediately on offboarding. The automation list is the one that drifts fastest and gets reviewed least, because nobody leaves and nothing prompts a look — the procedure is in offboarding a developer from every repository.
Do these controls help against a compromised developer machine? Jump to heading
Partially. Required review means a single compromised account cannot land a change alone, and signed commits mean the commit must be signed by a key that machine holds. Neither prevents an attacker with a live session from approving their own change if they also control a second account, which is why review requirements and account security are complementary rather than alternatives.
What about repositories that are not on a forge with rulesets? Jump to heading
The same policy is expressible with server-side hooks, which cannot be bypassed and are harder to audit centrally. The mapping is in mirroring local hook checks in server-side policy, and the enforcement properties are stronger even though the management is more work.
How do these controls interact with a monorepo? Jump to heading
They apply to the repository, which in a monorepo means they apply to everything at once — and that is both the strength and the awkwardness. A single ruleset covers every team’s code, which removes the drift that separate repositories accumulate, and it also means one policy has to suit teams with genuinely different needs.
The resolution is to keep the repository-wide rules minimal and to express the per-area requirements through ownership rather than through protection. Signed commits, no force-pushes and one review are reasonable everywhere. Two reviews on infrastructure, a named security team on workflow files and a data team on migrations are path-specific and belong in the ownership file, where they can differ per directory without needing a different ruleset. That split keeps the organisation-wide policy short enough to defend and the local requirements precise enough to be useful.
Related Jump to heading
- Designing Branch Protection Rulesets for an Org — policy that applies by pattern rather than per repository.
- Scoping Deploy Keys and Tokens — least privilege for the automation nobody reviews.
- Auditing Who Can Push to Protected Branches — enumerating every route, including the forgotten ones.
- Enforcing CODEOWNERS Review on Sensitive Paths — turning ownership into a gate where it matters.
- Limiting Workflow Permissions Per Job — shrinking what a compromised job can reach.