Skip to content

Safer GitHub administration through IssueOps

Use GitHub Actions to promote and demote admins—it’s like sudo for GitHub!

Artwork: Tim Peacock

Photo of Niek Palm
Philips logo

Niek Palm // Principal Software Engineer, Philips

The ReadME Project amplifies the voices of the open source community: the maintainers, developers, and teams whose contributions move the world forward every day.

If you’ve spent much time working with Linux or Unix, you may have seen this message:

We trust you have received the usual lecture from the local System Administrator. It usually boils down to these three things:

1) Respect the privacy of others.

2) Think before you type.

3) With great power comes great responsibility.

It’s the warning shown to a user the first time you use the sudo command to run tasks as an administrator. It might sound melodramatic at first, but given the far-ranging abilities sudo grants, it’s appropriate.

sudo exists to help save administrators from themselves. When you have to deliberately invoke these superpowers, you’re less likely to use them by mistake and, for example, delete an important directory or user account. It’s an approach known as “Just-In-Time” (JIT) administration and it’s become the standard for controlling elevated privileges in operating systems.

For better or worse, however, the sudo approach is not always the norm at the application level. As a GitHub organization admin, you do not explicitly switch with sudo or any similar command to an admin view. If you’re a GitHub org admin, you likely use the same user account that you use for day-to-day work, like developing code, creating pull requests, reviewing pull requests, and so forth. There’s no easy way to differentiate between the access you have as a normal member of a repository and the full access you are granted as an administrator. That can create problems.

There are many guard rails imposed on non-admin contributors to a repository. Depending on your organization’s policies, if you want to make a change as a non-admin, you might have to create a fork or branch, then create a pull request to make a change. However, as an org admin, you can do anything within that repository—including operations that could cause great harm to a project—because the guard rails don’t apply to you.

Think about the power that lies with your personal access token. Wouldn’t it be much better to be an admin only for admin tasks, and a normal user otherwise?

If you’d like the same peace of mind you experience as a Linux admin as a GitHub org admin, there are ways to apply JIT administration to GitHub. In this Guide, we will show you how to use GitHub Actions to create a sudo-style privilege elevation system within your GitHub organization. 


In this Guide, you will learn:

  1. How to grant Just-In-Time (JIT) access to GitHub org admins.

  2. How to automate JIT access with IssueOps and GitHub Actions.

  3. Which security considerations you should take into account when automating admin promotion and demotion.


Just-In-Time admin with IssueOps

One way to solve this challenge is by using two separate user accounts. One user is an admin, and the other a normal org member. Still, mistakes could be made simply by logging into the wrong account. In the Linux world, sudo solves this problem by providing JIT administration privileges.

Although, as of this writing, GitHub does not provide the equivalent of sudo out of the box, the GitHub Service team open sourced an Action and JavaScript CLI that automates the promotion and demotion of org members to and from admin status based on the JIT principle.

We are going to use this admin support action to automate JIT promotion and demotion with “IssueOps”—in other words, by automating the process with GitHub Issues and GitHub Actions, with optional approvals before automation kicks in.

Promotion and demotion workflow

The admin-support action implements a simple promotion/demotion process. This process requires a repository where users will file issues to request admin access. Only those users who are allowed to request these escalated permissions should have access to the repository, which means it has to be ⚠️ private ⚠️

The following diagram illustrates the process of promotion and demotion:

GitHub admin-support action promotion/demotion workflow diagram

A user with access to the admin repository creates an issue to request admin access. Next, an action starts and handles the issue to promote the user. Once the admin task is finished, the user closes the issue. The closure of the issue starts the workflow to demote the user back to a normal member. As long as the issue is open, the user will have admin access. As a fallback, a scheduled workflow ensures admin users are always demoted back to standard members after a set period of time. You can set the time-out in the issue anywhere between one and eight hours.

Our fork of the admin support action

The admin support action requires administration permissions for your org and will execute highly privileged operations on our GitHub org. Although this action was created by GitHub Services, the best practice is that you audit the source code and libraries to ensure they work the way you expect and won’t interfere with any other processes you have in place, and lock the action on the Git SHA so that you know you’re always running an unmodified version of the action. You should also generate the execution bundle yourself to ensure that it matches the sources. There are many ways to safeguard this. We chose a simple two-step approach that we incorporated into our own fork of the action. First, we extend the standard CI workflow for pull request builds and add a step to check whether the distribution is up to date and, if not, update the branch of the pull request.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
- name: Update dist
  env:
    GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
  run: |
    gh pr checkout ${{ github.event.pull_request.number }}
    git config --global user.name "action-bot"
    git config --global user.email "action-bot@040code.github.io"
    DIFF=$(git diff-index HEAD dist/)
    if [ "$DIFF" ]
    then
      git commit -m "chore: Update action dist" dist/index.js
      git push
    else
      echo "Distribution is up-to-date."
    fi

Second, we add a release workflow based on release-please. This release workflow creates a branch and pull request for the next release. By providing a token on behalf of an App, instead of the default GitHub Action token, we ensure builds on the created release pull request are triggered. The app has read/write access to the contents and pull requests of the repository. Later in the post, we cover app creation as well.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
name: Release
on:
  push:
    branches:
      - main
jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: philips-software/app-token-action@v1.1.2
        id: token
        with:
          app_id: ${{ secrets.APP_ID }}
          app_base64_private_key: ${{ secrets.APP_PRIVATE_KEY_BASE64 }}
          auth_type: installation
      - name: Release
        uses: google-github-actions/release-please-action@v3
        with:
          release-type: simple
          token: ${{ steps.token.outputs.token }}

We have now ensured that the runtime matches the sources for our releases. For each upcoming release, the release-please action maintains a pull request. On this pull request, the standard CI is triggered, with our extension. This extension checks and, if needed, updates the runtime. When the pull request has merged, a release is created with an up-to-date distribution.

update-dist

Those are the only changes in our fork. We try to keep the sources as close as possible to the upstream. Now we can implement the promotion and demotion in a second repository using this action.

New release screenshot

Action setup

Now that we’ve discussed the working of the action, it’s time to set up the actual workflows. First, we need to create a repository to handle the issues for requesting admin promotion. Remember, this repository must be private and you should only grant access to members in your org who are allowed to become an admin. Create a new repository, for example via the GitHub CLI:

gh repo create my-org/admins --private

We have already discussed the process for promotion and demotion. This process requires three different workflows:

  • A promotion workflow that acts on issue creation

  • A demotion workflow that acts on issue closure

  • A timeout workflow to ensure a user is demoted after a maximum duration that acts on a schedule (cron)

We want to hear from you! Join us on GitHub Discussions.

PAT versus GitHub App token

The admin support action documentation suggests using a PAT token, but we prefer to use a GitHub App token instead because the scope of the old PAT token was far too wide. Although you can limit the scope of the newer version of the PAT token in much the same way you can limit an app token, PAT tokens also impose rate limits. That may not be a problem for this use case, but could create other challenges. In addition, using a GitHub App token saves you a license seat. However, you’ll need to make a few modifications to the admin support action to use it with an app token.

Create the admin support GitHub App

We’ll start by registering a new GitHub App. Go to your org developer’s settings and create a GitHub App. Set the following settings for the new app:

  • Disable the webhook

  • Repository permissions: Contents read/write,Issues read/write

  • Organization permissions: Administration read/write, Members read/write

Save your app, download its SSH key, and make a note of the GitHub App ID. The last step is to install the GitHub App to the created repository to manage admins. This grants it access to the repository.

Repository configuration

The admin support action requires a configuration file, config.yml, in the root of the repository. We set up the admin promotion/demotion for a single org, but if needed, you can set up the action for multiple orgs:

1
2
3
4
5
org: 040code
repository: admins
supportedOrgs:
  - 040code
reportPath: reports

Add a similar file to your admin’s repository. 

The automation we are going to run requires the use of labels. The example workflows use the actions-ecosystem/action-add-labels action. This action is outdated, so we use simple gh CLI commands instead, which means we have to create the labels first. You can create the labels with this simple script:

1
2
3
4
for l in "automation-running", "user-promoted", "promotion-error", "user-demoted", "manual-demotion", "automatic-demotion"
do
  gh label create $l
done

Finally, the workflow needs to get a token for the GitHub App. This requires you to define the following two secrets. First, add the ID of the App as APP_ID. Next, add the base64 encoded string of the private ssh key as APP_PRIVATE_KEY_BASE64. You can convert the ssh key to a base encoded string by running cat key-file.pem | base64 | pbcopy.

Issue template

The admin support action processes issues, so we'll create a template to make it easier to create these issues in a consistent format. Note that issue templates are not supported in private repositories for orgs on the free plan. Create the directory .github/ISSUE_TEMPLATE and create an issue template yaml file.

1
2
3
4
5
6
7
8
9
10
11
12
---
name: Request administrator permission in the organization
about: Allows the support team to request temporary admin permission in an organization
title: Request administrator permission
labels: ''
assignees: ''
---
Organization: my-org
Description:
Duration: 2
Ticket: 0

As you can see, several fields are required. You can use this setup for multiple organizations, but for now we’ll only use one. The Duration can be set to a maximum of eight hours, but we will set the default to two hours. The Tickets field refers to an external ticket system, so we can ignore it since we use GitHub Issues.

Promotion workflow

The promotion workflow is triggered once an issue is created. After parsing the issue, the workflow promotes the user to admin status. This and the following workflows use a third-party action based on version. Be aware that it is mutable, so we strongly recommend that you lock your actions with SHA instead of a tag.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
name: Promotion workflow
on:
  issues:
    types: [opened]
jobs:
  promote-workflow:
    name: Promote @${{ github.event.issue.user.login }} to admin
    runs-on: ubuntu-latest
    permissions:
      issues: write
      contents: read
    env:
      GH_TOKEN: ${{ github.token }}
    steps:
      - uses: actions/checkout@v3
      - uses: philips-software/app-token-action@v1.1.2 #1
        id: token
        with:
          app_id: ${{ secrets.APP_ID }}
          app_base64_private_key: ${{ secrets.APP_PRIVATE_KEY_BASE64 }}
          auth_type: installation
      - name: Add label automation-running
        if: always()
        run: gh issue edit --add-label automation-running ${{ github.event.issue.number }}
      - name: Checkout repository
        uses: actions/checkout@v3
      - name: Parse the issue submitted
        id: issue_parser
        uses: 040code/admin-support-issueops-actions/admin-support-cli@v1.0.0
        with:
          action: "parse_issue"
          issue_number: ${{ github.event.issue.number }}
          ticket: ${{ github.event.issue.number }}
      - name: Parse issue parser output
        id: parse_issue_output
        run: |
          target_org=$(echo ${{toJSON(steps.issue_parser.outputs.output)}} | jq -r .target_org)
          echo "target_org=$target_org" >> $GITHUB_OUTPUT
      - name: Grant admin access #2
        id: grant_admin
        uses: 040code/admin-support-issueops-actions/admin-support-cli@v1.0.0
        with:
          action: "promote_demote"
          username: ${{ github.event.issue.user.login }}
          target_org: ${{ steps.parse_issue_output.outputs.target_org }}
          role: "admin"
          admin_token: ${{ steps.token.outputs.token }}
      - name: Add a comment on the issue
        uses: actions/github-script@v6
        if: success()
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }} #2
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: `✅   We have executed the request and now the user **@${{github.event.issue.user.login}}** is an admin on ${{steps.parse_issue_output.outputs.target_org}}. When you finish the operations required by the support ticket, close this issue to demote your permissions.
              <sub>
                Find details of the automation <a href="https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${{github.run_id}}">here</a>.
              </sub>
              `
            })
      - name: Add label user-promoted
        if: success()
        run: gh issue edit --add-label user-promoted ${{ github.event.issue.number }}
      - name: Add label promotion-error
        if: failure()
        run: gh issue edit --add-label promotion-error ${{ github.event.issue.number }}
      - name: Close issue if the promotion fails
        uses: actions/github-script@v6
        if: failure()
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            github.rest.issues.update({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              state: 'closed'
            })
            github.rest.issues.lock({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo
            })
      - name: Remove label automation-running
        if: always()
        run: gh issue edit --remove-label automation-running ${{ github.event.issue.number }}

The workflow should be easy to read, but let's discuss the most interesting details. Since we are using a GitHub App for executing API calls to promote users, we first need to obtain a token for the app. We use the app-token-action to do this. For all API calls that don’t need the admin token, we use the repo-level secret injected by GitHub Actions, aka GITHUB_TOKEN. After parsing the issue and setting labels, the user is granted access. Here we use the token from the GitHub App. Once the user is promoted, a comment is made on the issue to inform the user.

We can already test our promotion. Create an issue based on the template. You should soon see the issue updated as below:

request screenshot

Demotion

The next step is to dethrone the user and revoke the admin privileges. The second workflow activates when the issue is closed.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
name: Demote a user
on:
  issues:
    types: [closed]
jobs:
  demote-workflow:
    name: Demoting a user for closing an issue
    runs-on: ubuntu-latest
    permissions:
      issues: write
      contents: write
    env:
      GH_TOKEN: ${{ github.token }}
      DEMOTION_ERROR_NOTIFY: "@npalm"
    steps:
      - name: Checkout repository
        uses: actions/checkout@v3
      - uses: philips-software/app-token-action@v1.1.2
        id: token
        with:
          app_id: ${{ secrets.APP_ID }}
          app_base64_private_key: ${{ secrets.APP_PRIVATE_KEY_BASE64 }}
          auth_type: installation
      - name: Add label automation-running
        if: always()
        run: gh issue edit --add-label automation-running ${{ github.event.issue.number }}
      - name: Parse the issue submitted
        id: issue_parser
        uses: 040code/admin-support-issueops-actions/admin-support-cli@v1.0.0
        with:
          action: "parse_issue"
          issue_number: ${{ github.event.issue.number }}
          ticket: ${{ github.event.issue.number }}
      - name: Parse issue_parser json output
        id: parse_issue_output
        run: |
          target_org=$(echo ${{toJSON(steps.issue_parser.outputs.output)}} | jq -r .target_org)
          description=$(echo ${{toJSON(steps.issue_parser.outputs.output)}} | jq -r .description)
          duration=$(echo ${{toJSON(steps.issue_parser.outputs.output)}} | jq -r .duration)
          echo "target_org=$target_org" >> $GITHUB_OUTPUT
          echo "description=$description" >> $GITHUB_OUTPUT
          echo "duration=$duration" >> $GITHUB_OUTPUT
      - name: Demote user
        id: demote_admin
        uses: 040code/admin-support-issueops-actions/admin-support-cli@v1.0.0
        continue-on-error: true
        with:
          action: "promote_demote"
          username: ${{ github.event.issue.user.login }}
          target_org: ${{ steps.parse_issue_output.outputs.target_org }}
          role: "member"
          admin_token: ${{ steps.token.outputs.token }}
      - name: Add a comment on the issue to confirm the demotion
        uses: actions/github-script@v6
        if: success()
        with:
          github-token: ${{secrets.GITHUB_TOKEN}}
          script: |
            await github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: `✅ &nbsp; We have executed the request and now the user **@${{github.event.issue.user.login}}** has been demoted from ${{steps.parse_issue_output.outputs.target_org}}. \n\n This issue will be locked to avoid new interactions
              <sub>
                Find details of the automation <a href="https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${{github.run_id}}">here</a>.
              </sub>
              `
            })
            await github.rest.issues.lock({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo
            })
      - name: Add a comment to notify the team that this automation failed
        uses: actions/github-script@v6
        if: failure()
        with:
          github-token: ${{secrets.GITHUB_TOKEN}}
          script: |
            await github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: `Demoting the user has failed. ${{env.DEMOTION_ERROR_NOTIFY}} have a look to make sure the user is left in a correct state.
              <sub>
                Find details of the automation <a href="https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${{github.run_id}}">here</a>.
              </sub>
              `
            })
      - name: Add labels user-demoted, manual-demotion
        if: ${{ success() && github.event.sender.login == github.event.issue.user.login }}
        run: |
          gh issue edit --add-label user-demoted ${{ github.event.issue.number }}
          gh issue edit --add-label manual-demotion ${{ github.event.issue.number }}
      - name: Add labels user-demoted, manual-demotion
        if: ${{ success() && github.event.sender.login != github.event.issue.user.login }}
        run: |
          gh issue edit --add-label user-demoted ${{ github.event.issue.number }}
          gh issue edit --add-label automatic-demotion ${{ github.event.issue.number }}
      - name: Remove label user-promoted
        if: success()
        run: gh issue edit --remove-label user-promoted ${{ github.event.issue.number }}
      - name: Remove label automation-running
        if: always()
        run: gh issue edit --remove-label automation-running ${{ github.event.issue.number }}

The demotion workflow is similar to the promotion workflow, but is its opposite. After getting a token and parsing the issue, the user is reverted back to a normal org member. Again, a comment is made on the issue to inform the user. In the case of failure, a comment is made to trigger a notification to a predefined user. The admin support action also supports retrieving the relevant parts from the audit log, and writing them as an audit record in the repository.

Demoting the user by closing the issue looks like this:

Demotion screenshot

Timeout

The timeout is a safeguard. We run a scheduled workflow every hour to check the open issues for any admin users who have exceeded the maximum duration set in the config.yml file. This workflow will close the issue, triggering the default demotion workflow:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
name: Provisioning check to see if a user needs to be demoted
on:
  schedule:
    - cron: "0 * * * *"
  workflow_dispatch:
jobs:
  provisioning-check:
    name: Close issues with expired duration
    runs-on: ubuntu-latest
    steps:
      - uses: philips-software/app-token-action@v1.1.2
        id: token
        with:
          app_id: ${{ secrets.APP_ID }}
          app_base64_private_key: ${{ secrets.APP_PRIVATE_KEY_BASE64 }}
          auth_type: installation
      - name: Checkout repository
        uses: actions/checkout@v3
      - name: Run through all the issues and close them if they are expired
        id: issue_parser
        uses: 040code/admin-support-issueops-actions/admin-support-cli@v1.0.0
        with:
          action: "check_auto_demotion"
          ticket: ${{ github.event.issue.number }}
          # Require a non default action token, otherwise it won't trigger a job on issue close
          admin_token: ${{ steps.token.outputs.token }}

This workflow does not need much explanation. As mentioned, it checks open issues on an hourly basis, but nothing holds you back from running the workflow more frequently.

Always have a backup plan

If GitHub Actions is down, your promotion/demotion workflows will break. An alternative approach might be hooking into the GitHub events with a webhook, but then you would still be dependent on the GitHub eventing system. So we recommend you keep a backup admin user available (one that normally is not used but is available for disaster recovery).

Promoting JIT administration as the industry standard

With the implementation of the JIT admin feature, our org admins are no longer automatically assigned the role of admin. Instead, they are promoted to admin status only when necessary to perform specific tasks. This prompts us to question the need for repository admins to have constant admin privileges. Is this level of access truly necessary? Shouldn't we apply a similar mechanism to regulate their admin privileges as well? By adopting a more granular approach to granting admin access, we can ensure that only the required individuals have the necessary privileges, minimizing the risk of unauthorized access and potential security breaches. Is it not time to re-evaluate how we manage admin access?

Though we’d prefer to see a sudo-style admin feature baked right into GitHub, this solution provided by GitHub via the admin support action makes it possible to do JIT administration via GitHub Actions. We’ve had great success in our organization with this approach and hope you’ll consider the implications of leaving admin privileges on by default and the benefits of this JIT approach to administration. We’d love to see the JIT approach become an industry standard not just for Linux administration, but all types of application administration as well.

About Philips

Founded in 1891, Philips has innovated, manufactured, and sold a wide variety of products throughout its history, including light bulbs, radios, television sets, electric shavers, and toothbrushes. But over the past decade, Philips has transformed to be a focused leader in health technology with a mission to improve people’s health and well-being through meaningful innovation. The company, which has more than 70,000 employees worldwide, has set an ambitious goal of improving 2.5 billion lives a year by 2030, including 400 million in underserved communities.

From connected, smart oral care that contributes to preventative cardiac care to informatics, precision diagnostics, and image-guided therapy solutions, technology at Philips means one thing—improving lives.

Niek is a Principal Software Engineer in the Philips Software Center of Excellence. He supports businesses in the goal of building better software and engineering practices. Niek is closely involved in shaping the future of software within Philips by driving DevOps culture transformation. He is playing a key role in driving the InnerSource community in Philips to build faster, better software together. As public speaker, blogger, open source maintainer and book reviewer, he advocates and shares his expertise on key areas as Cloud, DevOps and Software Craftsmanship.

More stories

Finish your projects

Aaron Francis // PlanetScale

About The
ReadME Project

Coding is usually seen as a solitary activity, but it’s actually the world’s largest community effort led by open source maintainers, contributors, and teams. These unsung heroes put in long hours to build software, fix issues, field questions, and manage communities.

The ReadME Project is part of GitHub’s ongoing effort to amplify the voices of the developer community. It’s an evolving space to engage with the community and explore the stories, challenges, technology, and culture that surround the world of open source.

Follow us:

Nominate a developer

Nominate inspiring developers and projects you think we should feature in The ReadME Project.

Support the community

Recognize developers working behind the scenes and help open source projects get the resources they need.

Thank you! for subscribing