Friday, May 29, 2026

Automate Azure PIM Role Activation Using GitHub Actions and OIDC (No Secrets Needed)

Automate Azure PIM Role Activation Using GitHub Actions and OIDC (No Secrets Needed)

Tags: Azure PIM | GitHub Actions | OIDC | Microsoft Graph | PowerShell | DevOps | M365 Level: Intermediate — Azure AD, GitHub Actions, PowerShell


Introduction

Azure Privileged Identity Management (PIM) lets you assign roles on a just-in-time basis — users activate roles only when needed, reducing your attack surface. But what if you want to automate PIM role assignment from a CI/CD pipeline without storing any credentials?

In this article, I walk through the complete end-to-end implementation I built:

  • A GitHub Actions workflow that lints and runs a PowerShell script
  • The script uses OIDC (OpenID Connect) to authenticate to Azure — zero stored secrets
  • It reads eligible PIM roles for a target user and creates adminAssign requests via Microsoft Graph
  • The workflow has automatic retry logic and reports final pass/fail status

Everything in this article is based on a real working implementation with real errors fixed along the way.


Why OIDC Instead of Client Secrets?

Traditional CI/CD pipelines store an Azure App Registration client secret (or certificate) as a GitHub secret. That secret:

  • Expires and needs manual rotation
  • Can be leaked if someone gets access to your GitHub secrets
  • Creates a long-lived credential that exists outside Azure's control plane

OIDC (Workload Identity Federation) replaces this with a trust relationship. GitHub Actions generates a short-lived JWT token per run. Azure AD verifies that token — no secret ever needs to be stored. The token is valid only for that specific run and expires automatically.

GitHub Actions Run
        │
        ▼
   GitHub OIDC Provider  ──── JWT Token ────▶  Azure AD
                                               (validates token
                                                against federated
                                                credential config)
                                                      │
                                                      ▼
                                              Issues short-lived
                                              access token

Architecture Overview

┌─────────────────────────────────────────────────────────────┐
│                    GitHub Actions Workflow                  │
│                                                             │
│  Job 1: PSScriptAnalyzer Lint                               │
│  ├── Checkout repo                                          │
│  ├── Install PSScriptAnalyzer                               │
│  └── Lint Azure/AzureRoleEnable-v5.ps1                      │
│      ├── Error found? → FAIL (fix and re-run)               │
│      └── Clean? → trigger Job 2                             │
│                                                             │
│  Job 2: Activate PIM Roles (depends on lint passing)        │
│  ├── Azure OIDC Login (no client secret)                    │
│  ├── Get Microsoft Graph access token via OIDC              │
│  ├── Install Microsoft Graph PowerShell SDK                 │
│  ├── Run AzureRoleEnable-v5.ps1 (attempt 1)                 │
│  │   └── Fail? → wait 30s → attempt 2                       │
│  └── Report final status                                    │
└─────────────────────────────────────────────────────────────┘
                          │
                          ▼ Microsoft Graph API
┌─────────────────────────────────────────────────────────────┐
│  AzureRoleEnable-v5.ps1                                     │
│  ├── Connect-MgGraph (using OIDC access token)              │
│  ├── Get-MgUser (resolve target UPN → Object ID)            │
│  ├── Get-MgRoleManagementDirectoryRoleEligibilitySchedule   │
│  │   (fetch all eligible roles for user)                    │
│  ├── Check if role already active → skip                    │
│  └── New-MgRoleManagementDirectoryRoleAssignmentScheduleRequest
│      (adminAssign action for each eligible role)            │
└─────────────────────────────────────────────────────────────┘

Prerequisites

Before starting, you need:

  • An Azure AD tenant (Entra ID)
  • A GitHub repository
  • Azure AD P2 or Microsoft Entra ID Governance licence (required for PIM)
  • A user account with permissions to create App Registrations in Azure AD
  • A Global Administrator or Privileged Role Administrator to grant admin consent (you can prepare everything and ask them to click one button)

Step 1 — Create the Azure App Registration

Navigate to Azure Portal → Microsoft Entra ID → App Registrations → New Registration.

FieldValue
Namegithub-pim-activator
Supported account typesAccounts in this org only
Redirect URILeave blank

After creating, note down:

  • Application (client) ID
  • Directory (tenant) ID

Add API Permission

Go to API Permissions → Add a permission → Microsoft Graph → Application permissions.

Search for and add: RoleManagement.ReadWrite.Directory

⚠️ This permission requires admin consent. The "Grant admin consent" button will be greyed out if you are not a Global Administrator. Note this down to ask your admin later.

Add Federated Credential (OIDC)

Go to Certificates & secrets → Federated credentials → Add credential.

FieldValue
Federated credential scenarioGitHub Actions deploying Azure resources
Organizationyour-github-org
Repositoryyour-repo-name
Entity typeBranch
Branchmain
Namegithub-actions-main-branch

This tells Azure AD: "Trust JWT tokens from GitHub Actions running on the main branch of this repository."


Step 2 — Add GitHub Secrets

In your GitHub repository, go to Settings → Secrets and variables → Actions → New repository secret.

Add these two secrets:

Secret NameValue
AZURE_CLIENT_IDApplication (client) ID from Step 1
AZURE_TENANT_IDDirectory (tenant) ID from Step 1

No client secret is needed. OIDC handles authentication entirely.


Step 3 — The PowerShell Script

Create Azure/AzureRoleEnable-v5.ps1 in your repository. This script is PSScriptAnalyzer compliant — it passes lint checks with zero errors.

Key design decisions:

  • Uses [System.Net.NetworkCredential]::new("", $accessToken).SecurePassword instead of ConvertTo-SecureString -AsPlainText (avoids PSScriptAnalyzer error)
  • Uses Write-Information instead of Write-Host (avoids PSScriptAnalyzer warnings)
  • Reads target UPN and access token from environment variables
  • Checks if a role is already active before attempting to assign (skip logic)
  • Exits with code 1 if any role assignment fails (triggers retry in workflow)
# ============================================================
# Azure PIM Role Activator v5 - App-Only OIDC
# PSScriptAnalyzer compliant
# ============================================================
param(
    [string]$TargetUserUPN   = $env:TARGET_USER_UPN,
    [string]$Justification   = "M365 Team - GitHub Actions"
)

Set-StrictMode -Version Latest
$ErrorActionPreference  = "Stop"
$InformationPreference  = "Continue"

if ([string]::IsNullOrWhiteSpace($TargetUserUPN)) {
    Write-Error "TARGET_USER_UPN required"; exit 1
}

$accessToken = $env:AZURE_ACCESS_TOKEN
if ([string]::IsNullOrWhiteSpace($accessToken)) {
    Write-Error "AZURE_ACCESS_TOKEN not set"; exit 1
}

Write-Information "Connecting to Microsoft Graph..."

# Use [securestring] direct cast to avoid PSAvoidUsingConvertToSecureStringWithPlainText
$secureToken = [System.Net.NetworkCredential]::new("", $accessToken).SecurePassword
Connect-MgGraph -AccessToken $secureToken -NoWelcome

Write-Information "Resolving user: $TargetUserUPN"
$user = Get-MgUser -UserId $TargetUserUPN -Property "id,userPrincipalName,displayName" -ErrorAction Stop
Write-Information "User: $($user.DisplayName) [$($user.Id)]"

Write-Information "Fetching eligible PIM roles..."
$eligibleRoles = Get-MgRoleManagementDirectoryRoleEligibilitySchedule `
    -Filter "principalId eq '$($user.Id)'" `
    -ExpandProperty RoleDefinition `
    -ErrorAction SilentlyContinue

if (-not $eligibleRoles -or $eligibleRoles.Count -eq 0) {
    Write-Warning "No eligible roles found for $TargetUserUPN"
    exit 0
}

Write-Information "Found $($eligibleRoles.Count) eligible role(s)."

$ok = 0; $fail = 0; $skip = 0

foreach ($role in $eligibleRoles) {
    $rn = $role.RoleDefinition.DisplayName
    try {
        # Check if already active — skip if so
        $aa = Get-MgRoleManagementDirectoryRoleAssignmentSchedule `
            -Filter "principalId eq '$($user.Id)' and roleDefinitionId eq '$($role.RoleDefinitionId)'" `
            -ErrorAction SilentlyContinue

        if ($aa) {
            Write-Information "SKIP: $rn (already active)"
            $skip++
            continue
        }

        # Submit adminAssign request
        New-MgRoleManagementDirectoryRoleAssignmentScheduleRequest -BodyParameter @{
            Action           = "adminAssign"
            PrincipalId      = $user.Id
            RoleDefinitionId = $role.RoleDefinitionId
            DirectoryScopeId = if ($role.DirectoryScopeId) { $role.DirectoryScopeId } else { "/" }
            Justification    = $Justification
            ScheduleInfo     = @{
                StartDateTime = (Get-Date).ToUniversalTime().ToString("o")
                Expiration    = @{ Type = "AfterDuration"; Duration = "PT8H" }
            }
        } | Out-Null

        Write-Information "OK : $rn activated for 8h"
        $ok++
    }
    catch {
        Write-Error "FAIL: $rn - $($_.Exception.Message)"
        $fail++
    }
}

Write-Information "Summary: OK=$ok SKIP=$skip FAIL=$fail"
Disconnect-MgGraph -ErrorAction SilentlyContinue

if ($fail -gt 0) { exit 1 }

Step 4 — The GitHub Actions Workflow

Create .github/workflows/run-AzureRoleEnable.yml:

name: Azure PIM Role Activator (OIDC)

on:
  workflow_dispatch:
    inputs:
      target_user_upn:
        description: 'Target user UPN (e.g. user@domain.com)'
        required: false
        default: 'user@yourdomain.com'
        type: string
      justification:
        description: 'Activation justification'
        required: false
        default: 'M365 Team - GitHub Actions'
        type: string

permissions:
  id-token: write   # Required for OIDC token
  contents: read    # Required for checkout

jobs:
  lint:
    name: PSScriptAnalyzer Lint
    runs-on: windows-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Install PSScriptAnalyzer
        shell: pwsh
        run: Install-Module PSScriptAnalyzer -Scope CurrentUser -Force -AllowClobber

      - name: Run PSScriptAnalyzer
        shell: pwsh
        run: |
          $results = Invoke-ScriptAnalyzer `
            -Path "Azure/AzureRoleEnable-v5.ps1" `
            -Severity Error, Warning `
            -Recurse

          if ($results) {
            $results | Format-Table -AutoSize
            $errors = $results | Where-Object { $_.Severity -eq 'Error' }
            if ($errors) {
              Write-Error "PSScriptAnalyzer found $($errors.Count) error(s). Fix before proceeding."
              exit 1
            }
          } else {
            Write-Host "PSScriptAnalyzer: No issues found." -ForegroundColor Green
          }

  activate-pim-roles:
    name: Activate PIM Roles
    runs-on: windows-latest
    needs: lint   # Only runs if lint job passes
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Azure OIDC Login
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          allow-no-subscriptions: true

      - name: Get Microsoft Graph Access Token via OIDC
        id: get-token
        shell: pwsh
        run: |
          $token = az account get-access-token `
            --resource https://graph.microsoft.com `
            --query accessToken -o tsv
          echo "AZURE_ACCESS_TOKEN=$token" >> $env:GITHUB_ENV

      - name: Install Microsoft Graph PowerShell SDK
        shell: pwsh
        run: |
          Install-Module Microsoft.Graph -Scope CurrentUser -Force -AllowClobber
          Import-Module Microsoft.Graph -Force

      - name: Run PIM Role Activator (attempt 1)
        id: attempt1
        shell: pwsh
        continue-on-error: true
        env:
          TARGET_USER_UPN: ${{ github.event.inputs.target_user_upn }}
          JUSTIFICATION:   ${{ github.event.inputs.justification }}
        run: |
          Write-Host "=== Attempt 1 ===" -ForegroundColor Cyan
          & "./Azure/AzureRoleEnable-v5.ps1"

      - name: Wait before retry
        if: steps.attempt1.outcome == 'failure'
        shell: pwsh
        run: Start-Sleep -Seconds 30

      - name: Run PIM Role Activator (attempt 2 on failure)
        if: steps.attempt1.outcome == 'failure'
        id: attempt2
        shell: pwsh
        continue-on-error: true
        env:
          TARGET_USER_UPN: ${{ github.event.inputs.target_user_upn }}
          JUSTIFICATION:   ${{ github.event.inputs.justification }}
        run: |
          Write-Host "=== Attempt 2 ===" -ForegroundColor Cyan
          & "./Azure/AzureRoleEnable-v5.ps1"

      - name: Report final status
        shell: pwsh
        run: |
          $a1 = "${{ steps.attempt1.outcome }}"
          $a2 = "${{ steps.attempt2.outcome }}"

          if ($a1 -eq "success") {
            Write-Host "PIM role activation SUCCEEDED on attempt 1." -ForegroundColor Green
          } elseif ($a2 -eq "success") {
            Write-Host "PIM role activation SUCCEEDED on attempt 2." -ForegroundColor Yellow
          } else {
            Write-Error "PIM role activation FAILED after all attempts."
            exit 1
          }

Step 5 — Grant Admin Consent

This is the one step that requires a Global Administrator or Privileged Role Administrator in your tenant.

  1. Navigate to: Azure Portal → App Registrations → github-pim-activator → API Permissions
  2. Click "Grant admin consent for [your tenant]"
  3. Confirm the dialog

Once granted, the RoleManagement.ReadWrite.Directory permission status changes from ⚠️ "Not granted" to ✅ "Granted".

Optional but recommended: Also assign the "Privileged Role Administrator" Azure AD role directly to the github-pim-activator Service Principal via: Azure AD → Roles and administrators → Privileged Role Administrator → Add assignment → search github-pim-activator


Errors I Hit and How I Fixed Them

This is a real implementation — here are the actual errors encountered and their fixes.

Error 1: PSScriptAnalyzer — PSAvoidUsingConvertToSecureStringWithPlainText

Cause: Using ConvertTo-SecureString -AsPlainText -Force to convert the access token.

Fix: Replace with:

# Before (triggers PSScriptAnalyzer error)
$secureToken = ConvertTo-SecureString $accessToken -AsPlainText -Force

# After (PSScriptAnalyzer compliant)
$secureToken = [System.Net.NetworkCredential]::new("", $accessToken).SecurePassword

Error 2: PSScriptAnalyzer — PSAvoidUsingWriteHost (7 warnings)

Cause: Multiple Write-Host calls throughout the script.

Fix:

# Add at top of script
$InformationPreference = "Continue"

# Replace all Write-Host with Write-Information
Write-Information "Connecting to Microsoft Graph..."

Error 3: OIDC Login Failed — "client-id and tenant-id not supplied"

Cause: GitHub secrets AZURE_CLIENT_ID and AZURE_TENANT_ID had not been added to the repository yet.

Fix: Navigate to GitHub → Settings → Secrets and variables → Actions and add both secrets.

Error 4: 403 Forbidden — "Insufficient privileges"

Cause: The RoleManagement.ReadWrite.Directory Application permission was added to the App Registration, but admin consent was never granted.

Fix: A Global Administrator must click "Grant admin consent for [tenant]" in the API permissions page.


How to Run the Workflow

Once everything is set up and admin consent is granted:

  1. Go to your repository → Actions tab
  2. Select "Azure PIM Role Activator (OIDC)"
  3. Click "Run workflow"
  4. Fill in:
    • Target user UPN: user@yourdomain.com
    • Justification: M365 Team - GitHub Actions
  5. Click "Run workflow"

The workflow will:

  1. ✅ Run PSScriptAnalyzer lint
  2. ✅ Authenticate via OIDC (no stored secret)
  3. ✅ Get Microsoft Graph access token
  4. ✅ Resolve the user by UPN
  5. ✅ Fetch all eligible PIM roles
  6. ✅ Skip roles already active
  7. ✅ Submit adminAssign requests for remaining roles
  8. ✅ Report pass/fail with retry on failure

Key Design Decisions

Why adminAssign and not selfActivate?

selfActivate requires delegated authentication — a real user signing in interactively. GitHub Actions uses app-only (application) authentication, which cannot impersonate a user. adminAssign works with app-only auth and assigns roles on behalf of the target user.

Why OIDC over a client secret?

No rotation, no leakage risk, no long-lived credential. The token is scoped to the exact repo + branch and expires immediately after the run.

Why PSScriptAnalyzer lint as a separate job?

It ensures the script is always clean before any Azure resources are touched. If a code change introduces a linting error, the workflow fails fast at the lint stage without ever attempting to connect to Azure.

Why retry logic?

Microsoft Graph can occasionally return transient errors on PIM requests (throttling, replication delays). A 30-second wait and second attempt handles these without requiring a full re-run.


Summary

ComponentDetails
ScriptAzure/AzureRoleEnable-v5.ps1 — PSScriptAnalyzer compliant
Workflow.github/workflows/run-AzureRoleEnable.yml — lint + retry
AuthOIDC Workload Identity Federation — zero stored secrets
App Registrationgithub-pim-activator — Application permission only
Graph PermissionRoleManagement.ReadWrite.Directory (requires admin consent)
ActionadminAssign — compatible with app-only auth
TriggerManual workflow_dispatch with UPN and justification inputs

Resources

No comments:

Post a Comment

Featured Post

Automate Azure PIM Role Activation Using GitHub Actions and OIDC (No Secrets Needed)

Automate Azure PIM Role Activation Using GitHub Actions and OIDC (No Secrets Needed) Tags: Azure PIM | GitHub Actions | OIDC | Microsoft Gr...

Popular posts