GitHub Access Cleanup: Avoiding Post-Departure Surprises

Vjosa Fusha
mossfinance
Published in
5 min readDec 7, 2023

Managing access to vital development tools like GitHub is crucial to any organization’s operations in today's fast-paced tech landscape. However, the departure of an engineer from a company often triggers a series of administrative tasks that can be both time-consuming and costly. Lingering Github access can also be a risk to the company.

Companies usually rely on manual management of GitHub memberships because paying Single Sign-On (SSO) through GitHub Enterprise can be expensive for companies. (Please see The SSO Wall of Shame). The “manual step” could be removing members from the UI using ClickOps or manually creating pull requests to remove people in case some form of infrastructure-as-code is used.

Is there something better that we can do to automate this?

In this article, we’ll explore how to use Terraform, GitHub Actions, and the Google Directory API to automate offboarding GitHub memberships efficiently.

Before you start, ensure that you have the following in place:

1. A GitHub organization where you want to manage memberships.

2. Basic knowledge of GitHub Actions, Golang, and Terraform.

4. Service Account with read-only access on Google Admin Directory.

  1. Define Terraform Resource for Memberships

Let’s say we create the memberships inside main.tf. That would look as simple as the following piece of code:

├── users
│ ├── main.tf
│ ├── members.json
│ ├── providers.tf
# main.tf
locals {
members = jsondecode(file("${path.module}/members.json"))
}

# Members in the organization
resource "github_membership" "members" {
for_each = local.members

username = each.value
role = "member"
}

Inside members.json, we define the email address and GitHub usernames. (yes, we need the email address later for Admin Directory API.)

{
"foo@gmail.com": "foo123",
"bar@gmail.com": "bar123"
}

These resources are already applied on the CI or locally, and now users exist inside our Github Organization. ✔️

WARNING: If implementing these changes into an existing Terraform codebase, carefully check the Terraform plan. Those changes might destroy and recreate all current memberships. To prevent such cases, use moved blocks.

2. Offboarding members from Github

Now, let’s proceed to manage offboarding GitHub members. The goal is to automate this process by creating a small Go script that checks if the users defined in members.json are suspended in the Admin Directory and updates the current members.json file. It will be a CRON job running on GitHub Actions.

  • Get the Directory API Go client library.
go get google.golang.org/api/admin/directory/v1

In our main function, we create a Directory Client with AdminDirectoryUserReadonlyScope and CloudPlatformScope. Modify members to point to your current path to members.json.

We get the offboarded members from the offboard function and update the file with the updated JSON.

package main

import (
"context"
admin "google.golang.org/api/admin/directory/v1"
"google.golang.org/api/option"
"log"
)

func main() {
ctx := context.Background()
srv, err := admin.NewService(ctx, option.WithScopes(
admin.AdminDirectoryUserReadonlyScope,
admin.CloudPlatformScope))
if err != nil {
log.Fatalf("Unable to retrieve directory Client %v", err)
}

var members = "path/to/members/json"
sm := offboard(members, srv) // returns updated members
err = update(members, sm)
if err != nil {
log.Fatalf("Unable to update github members %v", err)
}
}

Let’s dive into our offboarding process. We’ve named it the offboard function, and here’s how it’s structured:

func offboard(members string, srv *admin.Service) map[string]string {
var se = adminDirectorySuspendedMembers(srv)
var gm, err = githubMembers(members)
if err != nil {
log.Fatal(err)
}

for email := range gm {
for _, su := range se {
if email == su {
fmt.Printf("::set-output name=suspended::%s\n", su)
delete(gm, email)
}
}
return gm
}

We need to get all suspended users from the Admin Directory. We do that by using service.Users.List() method. It needs a CUSTOMER_ID, which you can get from the Admin Directory console. See: https://support.google.com/a/answer/10070793?hl=en

func adminDirectorySuspendedMembers(srv *admin.Service) []string {
var emails []string
nextPageToken := ""
for {
users, err := suspendedUsers(srv, nextPageToken)
if err != nil {
log.Fatalf("Error fetching users: %v", err)
}

for _, u := range users.Users {
emails = append(emails, u.PrimaryEmail)
}

nextPageToken = users.NextPageToken
if nextPageToken == "" {
break // No more pages
}
}

return emails
}

func suspendedUsers(service *admin.Service, nextPageToken string) (*admin.Users, error) {
req := service.Users.List().Customer("CUSTOMER_ID").OrderBy("email")
req.MaxResults(100)
req.Query("isSuspended=true")
if nextPageToken != "" {
req.PageToken(nextPageToken)
}

return req.Do()
}

Now, let’s look at the githubMembers function. This code reads a members.json file containing GitHub members’ data and returns it in a structured format. Here’s what it looks like:

func githubMembers(filePath string) (map[string]string, error) {
jf, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer jf.Close()

members, err := io.ReadAll(jf)
if err != nil {
return nil, err
}

var jsonData map[string]string
err = json.Unmarshal(members, &jsonData)
if err != nil {
return nil, err
}

return jsonData, nil
}

Lastly, we use the same file to update users, ensuring that our Github Membership stays up-to-date and synchronized with Google Workspace.

func update(filePath string, gm map[string]string) error {
// extract and sort the keys alphabetically
keys := make([]string, 0, len(gm))
for key := range gm {
keys = append(keys, key)
}
sort.Strings(keys)

// create a new map with sorted keys and their corresponding values
sortedData := make(map[string]string)
for _, key := range keys {
sortedData[key] = gm[key]
}

updatedData, err := json.MarshalIndent(sortedData, "", " ")
if err != nil {
return err
}

err = os.WriteFile(filePath, updatedData, 0644)
if err != nil {
fmt.Println("Error writing to the file:", err)
return err
}

return nil
}

3. CI/CD using Github Actions

We run this code on the Github Action Cron Job and automatically open a Pull Request with the updated `members.json` file to remove the user from Github.

Now, let’s create a file called offboarding.yml under .github/workflows folder and add the code below.

name: 'Revoke Github Membership'
on:
schedule:
- cron: "0 8 * * *" # UTC - every day 10am
jobs:
trigger:
name: 'Revoke Github Membership'
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
pull-requests: write
steps:
....
- name: Trigger
id: suspended-members
run: |-
go run suspended_members.go
- name: Open Pull Request
if: ${{ steps.suspended-members.outputs.suspended }}
id: cpr
uses: peter-evans/create-pull-request@v5
with:
add-paths: |
users/members.json
token: ${{ secrets.PAT }}
commit-message: remove suspended users
committer: GitHub <noreply@github.com>
author: GitHub <noreply@github.com>
branch: off-boarding-users
base: ${{ github.head_ref }}
delete-branch: true
title: 'Revoke Github Membership'
body: |
Revoke access for suspended users!
team-reviewers: your-team
draft: false

Make sure that the service account used inside the workflow has access to read users on the admin directory.

Example PR:

Just hit the 👏button and leave a comment if you want us to share and open-source the full codebase!

Thank you!

--

--