Managing repository permissions as code Jump to heading
Repository permissions are configured one click at a time, by many people, over years. Nobody can say why the data team has write access to the billing service, or which of forty repositories still let admins bypass review. Each answer requires clicking through settings pages, and the answer is out of date by the following week. Declaring permissions in files fixes the visibility problem β the desired state is readable in one place β and running a reconcile job fixes the drift problem: whatever someone changes by hand is reported, then reverted. Changes to access become pull requests with reviewers and history. This page sets that up, within repository access control and policy.
When to use this approach Jump to heading
- Your organisation has more than a handful of repositories and teams.
- Access reviews involve exporting settings by hand or taking screenshots.
- You have found permissions nobody could explain, or rules that differed between similar repositories.
- You want access changes reviewed like code changes, with an approver and a record.
Step 1 β Choose a reconcile tool, or a small script Jump to heading
Infrastructure-as-code tools have providers for forges and handle state, planning and drift detection. A short script against the API is enough for teams that only need team-to-repository permissions. Either way, the shape is the same: files describe desired state, a job compares it with reality and applies the difference.
Step 2 β Describe the desired state in files Jump to heading
Keep one file per repository or per team, whichever matches how people think about ownership. Name the permission explicitly and avoid wildcards that hide what is granted.
# access/repos/payments.yml
repository: payments
visibility: private
teams:
payments-eng: maintain
platform: write
security: triage
payments-admin-temporary: admin # normally empty, see temporary elevations
default_branch: main
rulesets: [standard-protected-main, signed-commits-required] # access/rulesets/standard-protected-main.yml
target: branch
include: ["~DEFAULT_BRANCH"]
rules:
- pull_request: { required_approving_review_count: 1, require_code_owner_review: true }
- required_status_checks: { strict: true, contexts: [build, test, signatures] }
- non_fast_forward: {}
- deletion: {}
bypass_actors: [] Rulesets defined once and referenced by name keep similar repositories identical, which is most of the value. Designing them is covered in designing branch protection rulesets for an org.
Step 3 β Plan on pull request, apply on merge Jump to heading
Every change to the access files runs a plan that shows exactly what would change, posted to the pull request. Merging runs the apply. Nobody applies from a laptop.
on:
pull_request: { paths: ["access/**"] }
push: { branches: [main], paths: ["access/**"] }
jobs:
reconcile:
runs-on: ubuntu-latest
environment: ${{ github.event_name == 'push' && 'access-apply' || 'access-plan' }}
steps:
- uses: actions/checkout@v4
- run: ./access/reconcile.sh ${{ github.event_name == 'push' && '--apply' || '--plan' }} | tee plan.txt
env: { GH_TOKEN: "${{ secrets.ACCESS_ADMIN_TOKEN }}" }
- if: github.event_name == 'pull_request'
run: gh pr comment "${{ github.event.number }}" --body-file plan.txt
env: { GH_TOKEN: "${{ github.token }}" } The apply token is powerful, so keep it in an environment that only runs on the main branch, following the pattern in protecting CI signing keys with environment secrets.
Step 4 β Detect and revert drift Jump to heading
People will still change settings through the web interface, sometimes for good reason during an incident. A scheduled run in plan mode reports any difference between files and reality. Decide per resource whether drift is reverted automatically or only reported.
# Nightly drift check: plan only, alert on any non-empty plan
./access/reconcile.sh --plan > drift.txt
if [ -s drift.txt ]; then
gh issue create --title "Access drift detected $(date +%F)" --body-file drift.txt --label access-drift
fi Automatic reversion suits permissions, where an unreviewed grant is exactly what you want undone. Reporting suits rulesets during the first months, while you learn which manual changes were legitimate and should be written back into the files.
Step 5 β Import what exists before enforcing anything Jump to heading
Turning on reversion against an empty set of files would remove everyoneβs access. Generate the initial files from current state, review them as a large first pull request, and only then enable apply.
# Export current team permissions for every repository into starter files
gh repo list "$ORG" --limit 1000 --json name --jq '.[].name' | while read -r r; do
{
echo "repository: $r"
echo "teams:"
gh api "repos/$ORG/$r/teams" --jq '.[] | " \(.slug): \(.permission)"'
} > "access/repos/$r.yml"
done Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
What about individual collaborators outside teams? Jump to heading
Declare them in the files too, or β better β move them into teams. Individual grants are the hardest to audit, and permissions as code is a good moment to retire them.
Who should own the access repository? Jump to heading
A small group, typically platform or security, as code owners for the whole directory, with repository-owning teams able to propose changes to their own files. Approval stays with the owners.
Does this cover organisation-wide settings? Jump to heading
It can, and it should eventually: default repository permissions, who may create repositories, and organisation-level rulesets. Start with repositories and teams, where most of the drift is.
Related Jump to heading
- Repository Access Control & Policy β the parent topic.
- Granting Temporary Elevated Access with Expiry β the time-bounded counterpart to standing permissions.
- Auditing Who Can Push to Protected Branches β checking that the declared state means what you think.
- Bulk Updating Repository Settings with the API β the API techniques a reconcile script uses.