It’s already been two weeks since I started my new job at Appvia — how time flies! As part of settling in, I’ve had to switch my mindset from deployments focused on HCP Terraform and Azure DevOps, over to using GitHub Actions and Azure storage for state. Not the biggest shift, and overall I think I actually prefer GitHub (especially GitHub Actions), but I have come across some interesting quirks whilst playing about.

One of which I came across when integrating Open Policy Agent (OPA) policies into pipelines for policy-as-code guardrails…


The Challenge

In order to use private, version-controlled modules without the HCP Terraform registry, we are going to need to be able to download these with Git. This is also the case if there are other repositories we need to clone (such as centralised OPA policies).

“Simple” I thought. I’ll just use the workflow identity and grant it access somehow. In Azure DevOps, this was straightforward enough. Each pipeline ran under the context of a build service, which gave you an identity you could assign permissions to…

View of workflow identity service

Surely GitHub is the same? Well, no. Not quite! While GitHub does indeed provide an identity and a token that can be used for authentication (see Authenticating with the GITHUB_TOKEN), there is one important point to note in the docs…

“The token’s permissions are limited to the repository that contains your workflow”

Ah. That scuppers our plans.


The Options

Where there is a will, there is a way! We just have to work out what our options are. Doing a bit of research, it seemed I had a four main approaches:

  • Public Modules - If security and visibility aren’t of concern, this is by far the simplest option. But then I’d learn nothing would I - so let’s discard this one.
  • Personal Access Tokens (PATs) — I could create a classic or fine-grained PAT with access to the repos I need, then store it as a repository or organisation secret and use it in my workflow. Simple to set up, but PATs are tied to a user account, so I didn’t really like this.
    • Note: I believe from cursory glances this doesn’t have to be the case (tying them to a user), but I didn’t dive into this too much, and believe there would be an additional user license consumed for the non-user identity needed.
  • Deploy keys — We could use GitHub deploy keys, which are essentially SSH keypairs used to access repositories. Not the end of the world, but we’d end up needing a key per repo, so it doesn’t scale well when many repos are involved.
  • GitHub Apps — We can create a GitHub App with permissions to read repository contents, then install it and grant access to the repos we want (e.g. modules and policy as code). We can then use installation access tokens in our workflow to authenticate as the app, and gain access to clone the code we need. Permissions are pretty fine-grained, the identity is the app (not a user), and auditing is clearer. This is also the approach GitHub recommends for automation and cross-repo access.

Now, I’ve got to be honest, I’m not a huge fan of any of them - I’d rather we were just able to grant the workflow identity from “A” access to “B” without the faff - but there we go. It is what it is, and I’m sure there is a reason GitHub has chosen not to implement this (I get there are security concerns if done incorrectly).

With this all in mind, let’s have a walk through the best of the options - using a GitHub app.


Walking Through an Example

I saw various articles online, but not as many as I expected, and a bunch didn’t walk through everything. So for my own benefit if nothing else, I figured it was worth documenting (and hopefully it will help someone else too!)

Creating a Module

To prove this out we are going to need some code! So let’s create a new module repo, and add something simple. We will just use a basic storage account for now. Please note - the code to follow is NOT representative of a good module - I’m going for quick and easy here 😉.

First, let’s create a repo…

gh repo create mike-guy/example-terraform-module --private --add-readme --clone

Now, let’s add our rubbish module code…

# main.tf
terraform {
  required_version = ">= 1.14"
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}

locals {
  normalised_project_name = lower(replace(var.project_name, " ", ""))
}

resource "azurerm_storage_account" "this" {
  name                     = "st${local.normalised_project_name}${var.region_short}${var.environment}"
  resource_group_name      = var.resource_group_name
  location                 = var.location
  account_tier             = "Standard"
  account_kind             = "StorageV2"
  min_tls_version          = "TLS1_2"
  account_replication_type = var.account_replication_type
  tags                     = var.tags
}
# variables.tf
variable "resource_group_name" {
  description = "Name of the resource group to create the storage account in."
  type        = string
}

variable "location" {
  description = "Azure region for the storage account."
  type        = string
}

variable "project_name" {
  description = "Project name; used (normalised) as part of the storage account name."
  type        = string
}

variable "region_short" {
  description = "Short region code used in the storage account name (e.g. uks, eus)."
  type        = string
}

variable "environment" {
  description = "Environment used in the storage account name (e.g. dev, prod)."
  type        = string
}

variable "account_replication_type" {
  description = "Replication type: LRS, GRS, RAGRS, ZRS, GZRS, or RAGZRS."
  type        = string
  default     = "LRS"
}

variable "tags" {
  description = "Tags to apply to the storage account."
  type        = map(string)
  default     = {}
}
# outputs.tf
output "id" {
  description = "The ID of the storage account."
  value       = azurerm_storage_account.this.id
}

output "name" {
  description = "The name of the storage account."
  value       = azurerm_storage_account.this.name
}

output "primary_endpoints" {
  description = "The primary endpoint URLs for blob, queue, table and file storage."
  value       = azurerm_storage_account.this.primary_endpoints
}

output "primary_connection_string" {
  description = "The primary connection string for the storage account."
  value       = azurerm_storage_account.this.primary_connection_string
  sensitive   = true
}

Next - add, commit and push the files, then tag as 1.0.0. Usually I’d have branch protection, automated validation and testing, pull requests etc., but we will save that for another article on another day.

➜  example-terraform-module git:(main) ✗ git add .
➜  example-terraform-module git:(main) ✗ git commit -m "initial commit"
[main fa55470] initial commit
 3 files changed, 80 insertions(+)
 create mode 100644 main.tf
 create mode 100644 outputs.tf
 create mode 100644 variables.tf
➜  example-terraform-module git:(main) git push origin main
Enumerating objects: 6, done.
Counting objects: 100% (6/6), done.
Delta compression using up to 10 threads
Compressing objects: 100% (5/5), done.
Writing objects: 100% (5/5), 1.17 KiB | 1.17 MiB/s, done.
Total 5 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
To https://github.com/mike-guy/example-terraform-module.git
   8bbfc38..fa55470  main -> main
➜  example-terraform-module git:(main) gh release create 1.0.0 --target main 
? Title (optional) Initial version demo
? Release notes Leave blank
? Is this a prerelease? No
? Submit? Publish release
https://github.com/mike-guy/example-terraform-module/releases/tag/1.0.0
➜  example-terraform-module git:(main) 

Creating some Root Code

In a separate repo, let’s create the root code that calls the module…

gh repo create mike-guy/example-terraform-root --private --add-readme --clone
terraform {
  required_version = ">= 1.14"
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}

provider "azurerm" {
  features {}
}

locals {
  tags = {
    environment = "prd"
    project     = "someapp"
    owner       = "mike-guy"
  }
}

resource "azurerm_resource_group" "this" {
  name     = "rg-mikeguy-someapp-prd-uks"
  location = "uksouth"
  tags     = local.tags
}

module "storage_account" {
  source                   = "git::https://github.com/mike-guy/example-terraform-module.git?ref=1.0.0"
  resource_group_name      = azurerm_resource_group.this.name
  location                 = azurerm_resource_group.this.location
  project_name             = "someapp"
  region_short             = "uks"
  environment              = "prd"
  account_replication_type = "LRS"
  tags                     = local.tags
}

Knocking up a Simple Workflow

Let’s create a very simple workflow in .github/workflows/terraform-plan-and-apply.yaml using pre-canned GitHub actions for ease. It will just do a terraform init and a terraform apply -auto-approve (I don’t need to remind you again that I’m not going for best practices here right?!)


name: Terraform Plan and Apply

on:
  push:
    branches: [main]

env:
  TF_VERSION: "1.14"

jobs:
  terraform:
    name: Terraform
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Azure Login (OIDC)
        uses: azure/login@v2
        with:
          client-id: ${{ vars.AZURE_CLIENT_ID }}
          tenant-id: ${{ vars.AZURE_TENANT_ID }}
          subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}

      - name: Setup Terraform
        uses: hashicorp/[email protected]
        with:
          terraform_version: ${{ env.TF_VERSION }}

      - name: Terraform Init
        run: terraform init

      - name: Terraform Apply
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        run: terraform apply -auto-approve -input=false

Before we even think about trying this, we are going to need to set up the federated credentials it is referencing (hey - I’ve got to include some good practices right!)

Federated Credentials

If you’re not already using federated credentials in Azure, why not? If there is interest, I’ll do a follow up in the future, but for now, here is some code that will go ahead and create them. As I’ve not created any GitHub environments, we will just tie this to the main branch for now.

Note: This code is not part of the root or module. I’m just running this in a separate folder for ease of creation.

terraform {
  required_providers {
    azuread = {
      source  = "hashicorp/azuread"
      version = "~> 3.0"
    }
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}

provider "azurerm" {
  features {}
}

resource "azuread_application" "this" {
  display_name = "github-example-terraform-root-oidc"
  description  = "App registration for GitHub Actions OIDC (mike-guy/example-terraform-root)"
}

resource "azuread_service_principal" "this" {
  client_id                    = azuread_application.this.client_id
  app_role_assignment_required = false
}

resource "azuread_application_federated_identity_credential" "this" {
  application_id = azuread_application.this.id
  display_name   = "github-mike-guy-example-terraform-root-main"
  audiences      = ["api://AzureADTokenExchange"]
  issuer         = "https://token.actions.githubusercontent.com"
  subject        = "repo:mike-guy/example-terraform-root:ref:refs/heads/main"
}

resource "azurerm_role_assignment" "owner" {
  scope                = "/subscriptions/b422e843-2945-4646-82be-7365f96bdf5f"
  role_definition_name = "Owner"
  principal_id         = azuread_service_principal.this.object_id
}

output "client_id" {
  description = "Application (client) ID for Azure OIDC login in GitHub Actions"
  value       = azuread_application.this.client_id
}

For the federated credentials to work in our workflow, we now need to populate the following as environment or repo variables (repo in this case, as I’ve not setup environments):

  • AZURE_CLIENT_ID - “ef2d7c43-0801-4df7-821a-178f88b7d7f0”
  • AZURE_TENANT_ID - “f8667506-a537-4c81-842a-41fd0e547e43”
  • AZURE_SUBSCRIPTION_ID - “b422e843-2945-4646-82be-7365f96bdf5f”
gh variable set AZURE_CLIENT_ID --body "ef2d7c43-0801-4df7-821a-178f88b7d7f0" -r mike-guy/example-terraform-root
gh variable set AZURE_TENANT_ID --body "f8667506-a537-4c81-842a-41fd0e547e43" -r mike-guy/example-terraform-root
gh variable set AZURE_SUBSCRIPTION_ID --body "b422e843-2945-4646-82be-7365f96bdf5f" -r mike-guy/example-terraform-root

This won’t work yet, because the runner won’t be able to access the module, but it would be good to see the error…

Terraform Init Error

No surprises there. Onto the solution…

Creating a GitHub App

Next, we need to create a GitHub app. Unfortunately, this doesn’t seem to be something you can do via Terraform, so over to the GUI we go (APIs may be available - I’ve not looked yet).

As I’m not setup as an “org”, I’ll just be setting this up under my personal account, but for those using this in an enterprise environment, you’d want to set it up under the organisation (I believe it would just be under the org settings, but not got one to hand to check!)

I’ll click my profile in the top right, select Settings, and then head to Developer Settings. From here, I want to click “New GitHub App” under the GitHub Apps section.

We will need to give it a name, a website address (though it doesn’t need to be real), and then set the following settings:

  • Webhook > Active - Disable (not required).
  • Permissions > Repository permissions - Set Contents to Read-only
  • Where can this GitHub App be installed? - Select “Only on this account”

Created GitHub App

Once the app is created, note down the App ID and click the “generate a private key” link at the top. We will need both of these shortly.

Installing the App

Whilst we’ve created the app, it can’t actually do anything anywhere yet. We need to “install” it into our org and grant it access to specific repos. It looks like the latter can be done through Terraform, but we will stick to the GUI for now.

Whilst still in the app, select “Install App” from the menu on the left and choose your organisation…

Install App Screenshot

On the next page, you review the permissions and select the repositories you wish to grant it access to. In our instance, we’re just selecting a single module repo, but you’ll likely have multiple (with more being added in the future), and may need to consider other repositories such as any housing centralised policy as code.

Repository selection for Install

Install App Confirmation

Great. The app is now installed, and has read-permission to our repos, but at the moment, our root code workflow still doesn’t have access to it.

Granting Access to Root Repo Action

In order for our consuming GitHub actions workflow to use the identity of the new app, we need to do two things:

  1. Provide Access to App ID and Private Key - In order for a workflow to get a token for authentication, it needs access to both the App ID and the Private Key. For most organisations, these will be best created as organisation secrets so that all repos can make use of them. All they will be used for is to exchange for a JSON Web Token (JWT), which then gives read access to your module repos - so there is little risk in doing this in most cases.
  2. Modify the Workflow - The workflow will need modifying to exchange the values above for a JWT, and then use it to access the relevant repo when running terraform init.

We will stick to the GUI for now, but this could be handled via automation. Whilst there is nothing secret about the App ID, I will create them both as secrets for now, as clearly we don’t want the private key as a cleartext variable.

App secrets configuration screenshot

Next we need to modify our pipeline to retrieve the token, and set git to use it. We do this before running terraform init. Fortunately, there is a GitHub action that makes this nice and easy. The workflow now looks as shown below (note the new “Generate GitHub App Token” and “Configure Git for Private Modules” steps.)

name: Terraform Plan and Apply

on:
  push:
    branches: [main]

env:
  TF_VERSION: "1.14"

jobs:
  terraform:
    name: Terraform
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Azure Login (OIDC)
        uses: azure/login@v2
        with:
          client-id: ${{ vars.AZURE_CLIENT_ID }}
          tenant-id: ${{ vars.AZURE_TENANT_ID }}
          subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}

      - name: Generate GitHub App Token
        id: generate-token
        uses: actions/create-github-app-token@v3
        with:
          app-id: ${{ secrets.MODULE_ACCESS_APP_ID }}
          private-key: ${{ secrets.MODULE_ACCESS_PRIVATE_KEY }}
          owner: "mike-guy" 

      - name: Configure Git for Private Modules
        run: |
          git config --global url."https://x-access-token:${{ steps.generate-token.outputs.token }}@github.com/".insteadOf "https://github.com/"          

      - name: Setup Terraform
        uses: hashicorp/[email protected]
        with:
          terraform_version: ${{ env.TF_VERSION }}

      - name: Terraform Init
        run: terraform init

      - name: Terraform Apply
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        run: terraform apply -auto-approve -input=false

Now, on the first run, I actually didn’t include owner: "mike-guy" under the with settings. This resulted in the following error:

workflow error

Because I’d not specified the “org” as the owner, it used the org/repo as the value instead. Because I’d not installed the app against the consuming repo (intentionally - as I’ve no need for other repos to read it), it detected that it wasn’t installed and generated this error. Once the owner value was added…

successful init

Success!

Well, it wasn’t…

failed apply

…but that was only because Cursor had made up one of my output references. The important part—the init—passed just fine, and that’s a win worth smiling about on a Sunday evening 🙂!


Conclusion

Hopefully this is of use to anyone else facing the same challenge.

Now that I’m working full-time in the Azure/Terraform/automation space, I’m hoping to ramp up the blog articles and document things as I learn new and interesting bits along the way.

As always, if you have any feedback or questions, drop me a message.