Granting temporary elevated access with expiry Jump to heading

Someone always needs more access than usual for a while: an engineer running a repository migration needs admin rights, an incident responder needs to push a fix past a broken required check, a contractor needs write access for a two-week engagement. The access is granted in a hurry and removed β€” in theory β€” when the work is done. In practice it accumulates. A year later the audit finds eleven people with admin on the payments repository and nobody remembers why. The fix is to make every elevation carry an expiry that is enforced by automation, not memory, and to record what was done with it. This page builds that, within repository access control and policy.

When to use this approach Jump to heading

  • People are occasionally granted admin, maintain or bypass rights for a specific task.
  • An access review has found elevated permissions nobody could justify.
  • Incidents sometimes require bypassing branch protection, and you want that to be possible but bounded.
  • You already audit who can push to protected branches, as in auditing who can push to protected branches, and want fewer surprises in the results.

Step 1 β€” Grant through a team, never to an individual directly Jump to heading

Direct collaborator grants are hard to find and harder to expire. Create one team per kind of elevation per repository, keep it empty by default, and add people to it when they need the access.

# A standing, normally-empty team that holds admin on one repository
gh api -X POST "orgs/$ORG/teams" -f name="payments-admin-temporary" -f privacy=closed
gh api -X PUT "orgs/$ORG/teams/payments-admin-temporary/repos/$ORG/payments" -f permission=admin

# Verification: the team exists, holds admin, and has no members
gh api "orgs/$ORG/teams/payments-admin-temporary/members" --jq 'length'   # 0
Direct grants against a temporary teamA direct collaborator grant attaches the permission to a person on one repository, where it is easy to forget. A normally-empty team holds the permission permanently, and elevation becomes adding a person to the team with an expiry, which is easy to list and easy to remove.Direct collaborator grantNormally-empty teamwhere it shows upper-repo settingsone team listdefault statewhatever was leftemptyremovalremember to do itautomated by expiryaudit questionwho has admin?who was in the team?the permission is permanent; membership is what expires

Step 2 β€” Record each grant with a reason and an expiry Jump to heading

Keep a small file in a policy repository listing current elevations. Granting access is a pull request to that file; the merge adds the person to the team. The file is the source of truth, and the team is made to match it.

# access/elevations.yml
- user: priya
  team: payments-admin-temporary
  reason: "Repository migration to new org, ticket OPS-2291"
  approved_by: maria
  expires: 2026-10-16
- user: contractor-jlee
  team: web-write-temporary
  reason: "Accessibility engagement, SOW-118"
  approved_by: tom
  expires: 2026-10-31

Requiring an approver other than the requester, enforced through code-owner review on this file, is what turns it from a list into a control.

Step 3 β€” Reconcile teams with the file, and expire automatically Jump to heading

A scheduled job compares each team’s members with the file. Members not in the file, or whose entry has expired, are removed. Entries in the file whose user is not yet a member are added. Run it every hour so expiry is close to exact.

#!/bin/sh
# reconcile-elevations.sh
set -eu
today=$(date +%F)
yq -r '.[] | [.team, .user, .expires] | @tsv' access/elevations.yml > /tmp/wanted
for team in $(cut -f1 /tmp/wanted | sort -u) $(gh api "orgs/$ORG/teams" --paginate --jq '.[].slug' | grep -- '-temporary$'); do
  current=$(gh api "orgs/$ORG/teams/$team/members" --paginate --jq '.[].login')
  for u in $current; do
    exp=$(awk -F'\t' -v t="$team" -v u="$u" '$1==t && $2==u {print $3}' /tmp/wanted)
    if [ -z "$exp" ] || [ "$exp" \< "$today" ]; then
      gh api -X DELETE "orgs/$ORG/teams/$team/memberships/$u" && echo "removed $u from $team"
    fi
  done
  awk -F'\t' -v t="$team" -v d="$today" '$1==t && $3>=d {print $2}' /tmp/wanted | while read -r u; do
    echo "$current" | grep -qx "$u" || gh api -X PUT "orgs/$ORG/teams/$team/memberships/$u" -f role=member
  done
done
The life of one elevationA request is opened as a pull request with a reason and expiry, approved by someone else, and merged. The reconcile job adds the person to the team within the hour. On the expiry date the same job removes them, whether or not anyone remembers.RequestPR to elevations.ymlApprovecode owner, not selfGrantreconcile adds memberUseaudited actionsExpirereconcile removesremoval needs no human, which is the whole point
# Verification: an entry dated yesterday is removed on the next run
gh api "orgs/$ORG/teams/payments-admin-temporary/members" --jq '.[].login'

Step 4 β€” Bound break-glass bypasses the same way Jump to heading

Incident responders sometimes need to merge past a failing required check. Rather than letting admins bypass rules permanently, create a ruleset bypass list that contains only the temporary team, and give the team members only during an incident.

# Ruleset bypass actors: only the incident team, only via pull request
gh api "repos/$ORG/payments/rulesets/$RULESET_ID" --jq '.bypass_actors'
# [{"actor_type":"Team","actor_id":<incident-team-id>,"bypass_mode":"pull_request"}]

bypass_mode: pull_request means the bypass still requires opening a pull request, which leaves a reviewable record of exactly what was merged under emergency rules.

Step 5 β€” Review what the elevated access was used for Jump to heading

Expiry limits how long access lasts; the audit log shows what it was used for. After each elevation expires, pull the relevant audit events and attach them to the original request.

gh api "orgs/$ORG/audit-log?phrase=actor:priya+repo:$ORG/payments+created:>=2026-10-02" \
  --jq '.[] | [.created_at, .action, (.ref // .name // "")] | @tsv'
An incident bypass from start to reviewAn incident starts and the responder is added to the incident team through an expedited request. They merge a fix through a bypass pull request. The entry expires the next morning and the reconcile job removes them. The audit events are attached to the incident record at the review.Incidentchecks broken09:12Grantexpires tomorrow09:20Bypass PRfix merged09:41Expiredremoved by reconcilenext dayAudit attachedwhat was donereviewthe bypass happened, was bounded and is documented β€” which is all an auditor asks for

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

What if someone needs access at 3am and nobody can approve? Jump to heading

Pre-approve an incident path: a small on-call group may merge elevation requests for the incident team with a short expiry β€” hours, not days β€” and the request is reviewed retrospectively. The automation still removes access on schedule.

Can people be added to the team directly through the web interface? Jump to heading

They can, but the next reconcile run removes anyone not in the file. That makes the file the only lasting way to grant access, which is the intent. Alert on removals so a mistaken manual grant is noticed.

Does this replace regular access reviews? Jump to heading

No, but it makes them shorter. Standing permissions still need periodic review; temporary ones no longer accumulate, so the review covers far fewer surprises.