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.

Infrastructure-as-code provider against a reconcile scriptAn infrastructure-as-code provider covers many resource types, shows a plan before applying and tracks state, at the cost of learning and operating the tool. A small reconcile script is easy to read and run but covers only what you write, and must be careful about deletions.IaC providerReconcile scriptcoverageteams, repos, rules, morewhat you writeplan before applybuilt inyou build itstate handlingmanagedstateless difflearning costmoderatelowstart with the script if the tool would be new to the team; switch when coverage matters

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 }}" }
An access change, from proposal to appliedAn engineer opens a pull request editing a repository's access file. The plan job compares desired and actual state and comments the diff. A code owner approves. On merge the apply job makes the change through the API, and the next drift check finds nothing to report.engineerplan jobcode ownerapply jobPR: platform β†’ writeplan: +1 team permissionapprovemergeapply via APIthe plan comment is what the approver actually reviews

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
Adopting permissions as code safelyThe first week exports current state into files and reviews them. The next weeks run plan-only drift reports while unexplained grants are removed through pull requests. Apply is enabled once the plan is empty, and automatic reversion follows after a quiet month.Exportfiles mirror realityweek 1Clean upPRs remove odd grantsweeks 2–4Plan is emptyfiles = realityweek 5Apply on mergechanges via PRweek 5Auto-revertdrift undoneweek 9enforcement comes last, after the files are known to be right

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.