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]}'
Four routes to a protected branchHuman collaborators are the visible route. Automation with write access is usually larger and less reviewed. Bypass lists let named actors skip the rules entirely, and administrative override is a route that exists whether or not anyone has used it.Collaboratorsvisiblereviewed on joiningAutomationapps, keys, tokensrarely reviewedBypass listsskips the rulesadded during incidentsAdmin overridealways existsshould be loggedan audit that covers only the first column answers the wrong question

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.

Which paths need ownership enforced rather than suggestedAnything that controls what runs with repository credentials, touches infrastructure, or changes data irreversibly needs an owning team's review as a gate. Ordinary application code is better served by routing than by enforcement.What does a change to this path let someone do?run code with our credentialsEnforce ownershipworkflows, actionschange infrastructure or dataEnforce ownershipinfra, migrationschange application behaviourRoute, do not gatereview as usualgating everything produces approvals that are formalities; gating the top two keeps them meaningful

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.

How access drifts, and where the audit catches itAccess is granted for a reason, the reason ends, and nothing removes the grant. Each step is individually sensible and the accumulation is not, which is why the only effective control is a periodic audit against what is actually used.Granteda real reasonat the timeReason endsnobody removes itAccumulatesyears of grantsAuditagainst actual usethe third box is the normal state of any repository older than a year

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

ControlEffectWhere to set it
Organisation rulesetApplies policy by repository patternOrganisation settings
required_signaturesRefuses unsigned commitsRuleset rule
non_fast_forwardBlocks force-pushesRuleset rule
deletionBlocks branch deletionRuleset rule
require_code_owner_reviewOwnership becomes a gateRuleset, plus CODEOWNERS
permissions: {}Denies all token scopes by defaultWorkflow file
Deploy key read_onlyRead-only automation accessRepository keys
Bypass actorsRoute around every ruleKeep empty; audit monthly

Troubleshooting Jump to heading

SymptomLikely causeFix
Rules configured but a push got throughThe pusher is in a bypass listAudit bypass actors
Ruleset does not apply to a repositoryRepository name condition excludes itCheck the condition patterns
Required owner review never requestedPath pattern does not matchTest with git check-attr-style path probes
Automation broke after tightening tokensJob needed a scope that was removedGrant per job, not per workflow
Deploy key has write access nobody wantedCreated writable by defaultRecreate as read-only
Nobody can say who can pushNo audit has been runRun 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.