Showing posts with label PowerShell. Show all posts
Showing posts with label PowerShell. Show all posts

Wednesday, June 24, 2026

Automate Azure PIM Role Activation for Entra ID + Azure Resources with PowerShell

Automate Azure PIM Role Activation for Entra ID + Azure Resources with PowerShell

If you're working in a Zero Trust security environment, your Azure roles are likely managed through Privileged Identity Management (PIM) — meaning they expire and need to be manually re-activated every few hours. Clicking through the portal every time is tedious.

This PowerShell script automates activation for both Entra ID directory roles (via Microsoft Graph) and Azure resource roles (via ARM REST API) in a single run — with automatic detection of the maximum allowed duration from your PIM policy.


What This Script Does

  • Auto-installs all required PowerShell modules if not present
  • Activates all eligible Entra ID (Microsoft Graph) PIM roles — e.g. Global Reader, Security Administrator
  • Activates all eligible Azure Resource PIM roles — e.g. Contributor, Owner on subscriptions
  • Auto-detects the maximum allowed activation duration from your PIM policy per role
  • Displays a clean summary table of all eligible roles before activation

Prerequisites

  • PowerShell 5.1+ or PowerShell 7+
  • Internet access (modules are auto-installed if missing)
  • An account with eligible PIM role assignments in Entra ID or Azure subscriptions

The script will auto-install these modules if not found:

ModulePurpose
Az.AccountsAzure authentication & context
Az.ResourcesAzure AD user lookup
Microsoft.Graph.AuthenticationGraph API authentication
Microsoft.Graph.Identity.GovernancePIM role management
Microsoft.Graph.Identity.SignInsSign-in & identity operations

The Script

# ============================================================
#  Azure PIM Role Activator - Entra ID + Azure Resources
# ============================================================

# ── Install & Import Modules ─────────────────────────────────────────────────
foreach ($module in @("Az.Accounts", "Az.Resources")) {
    if (-not (Get-Module -ListAvailable -Name $module)) {
        Write-Host "Installing $module..." -ForegroundColor Yellow
        Install-Module $module -Scope CurrentUser -Force -AllowClobber
    }
    Import-Module $module -ErrorAction Stop
}

$targetVersion = (Get-Module -ListAvailable -Name Microsoft.Graph.Authentication | Sort-Object Version -Descending | Select-Object -First 1).Version
if (-not $targetVersion) {
    Install-Module Microsoft.Graph.Authentication -Scope CurrentUser -Force -AllowClobber
    $targetVersion = (Get-Module -ListAvailable -Name Microsoft.Graph.Authentication | Sort-Object Version -Descending | Select-Object -First 1).Version
}
foreach ($module in @("Microsoft.Graph.Authentication", "Microsoft.Graph.Identity.Governance", "Microsoft.Graph.Identity.SignIns")) {
    if (-not (Get-Module -ListAvailable -Name $module | Where-Object { $_.Version -eq $targetVersion })) {
        Write-Host "Installing $module $targetVersion..." -ForegroundColor Yellow
        Install-Module $module -RequiredVersion $targetVersion -Scope CurrentUser -Force -AllowClobber
    }
    Import-Module $module -RequiredVersion $targetVersion -ErrorAction Stop
}

# ============================================================
#  PART 1 — Entra ID (Microsoft Graph) PIM Roles
# ============================================================
Write-Host "`n===== ENTRA ID ROLES =====" -ForegroundColor Cyan

Disconnect-MgGraph -ErrorAction SilentlyContinue
Connect-MgGraph -Scopes "User.Read", "RoleManagement.ReadWrite.Directory", "RoleAssignmentSchedule.ReadWrite.Directory" -NoWelcome

$user = Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/v1.0/me" -Method GET
if (-not $user) { Write-Error "Failed to retrieve user."; exit }
Write-Host "Logged in as: $($user.userPrincipalName)" -ForegroundColor Green

$eligibleRoles = Get-MgRoleManagementDirectoryRoleEligibilitySchedule -Filter "principalId eq '$($user.id)'" -ExpandProperty RoleDefinition
if (-not $eligibleRoles) { Write-Warning "No eligible Entra ID PIM roles found." }

foreach ($role in $eligibleRoles) {
    try {
        $rawDuration = ((Get-MgPolicyRoleManagementPolicyAssignment `
            -Filter "scopeId eq '/' and scopeType eq 'DirectoryRole' and roleDefinitionId eq '$($role.RoleDefinitionId)'" `
            -ExpandProperty "Policy(`$expand=Rules)").Policy.Rules | Where-Object { $_.Id -eq "Expiration_EndUser_Assignment" }).AdditionalProperties["maximumDuration"]
        $hours = if ($rawDuration -match "PT(\d+)H") { [int]$Matches[1] } elseif ($rawDuration -match "P(\d+)D") { [int]$Matches[1]*24 } else { 8 }
        New-MgRoleManagementDirectoryRoleAssignmentScheduleRequest -BodyParameter @{
            Action           = "selfActivate"
            PrincipalId      = $user.id
            RoleDefinitionId = $role.RoleDefinitionId
            DirectoryScopeId = $role.DirectoryScopeId
            Justification    = "M365 Team"
            ScheduleInfo     = @{ StartDateTime = (Get-Date).ToUniversalTime(); Expiration = @{ Type = "AfterDuration"; Duration = "PT${hours}H" } }
        } | Out-Null
        Write-Host "  ✔ Activated : $($role.RoleDefinition.DisplayName) for $hours hr(s)" -ForegroundColor Green
    }
    catch { Write-Host "  ✘ Failed    : $($role.RoleDefinition.DisplayName) — $($_.Exception.Message)" -ForegroundColor Red }
}

# ============================================================
#  PART 2 — Azure Resources (ARM) PIM Roles
# ============================================================
Write-Host "`n===== AZURE RESOURCE ROLES =====" -ForegroundColor Cyan

Connect-AzAccount
$tokenObj = Get-AzAccessToken -ResourceUrl "https://management.azure.com"
$token    = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto(
                [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($tokenObj.Token))
$headers  = @{ Authorization = "Bearer $token"; "Content-Type" = "application/json" }
$userId   = (Get-AzADUser -SignedIn).Id

Write-Host "Subscription : $((Get-AzContext).Subscription.Name)" -ForegroundColor Green
Write-Host "User ID      : $userId" -ForegroundColor Green

$roles = (Invoke-RestMethod `
    -Uri "https://management.azure.com/providers/Microsoft.Authorization/roleEligibilityScheduleInstances?api-version=2020-10-01&`$filter=asTarget()" `
    -Headers $headers).value

if (-not $roles) { Write-Warning "No eligible Azure resource roles found." } else {
    $roles | ForEach-Object {
        [PSCustomObject]@{
            Role     = $_.properties.expandedProperties.roleDefinition.displayName
            Resource = $_.properties.expandedProperties.scope.displayName
            Type     = $_.properties.expandedProperties.scope.type
            Status   = $_.properties.status
        }
    } | Format-Table -AutoSize
}

Write-Host "`n===== ACTIVATING ALL ROLES =====" -ForegroundColor Yellow

foreach ($role in $roles) {

    $roleName  = $role.properties.expandedProperties.roleDefinition.displayName
    $scopePath = $role.properties.scope
    $roleDefId = $role.properties.roleDefinitionId

    # ── Auto-detect max allowed hours from PIM policy assignment ─────────────
    try {
        $policyAssignments = (Invoke-RestMethod `
            -Uri "https://management.azure.com$scopePath/providers/Microsoft.Authorization/roleManagementPolicyAssignments?api-version=2020-10-01&`$filter=roleDefinitionId eq '$roleDefId'" `
            -Headers $headers).value

        $policyId = $policyAssignments[0].properties.policyId

        $rules = (Invoke-RestMethod `
            -Uri "https://management.azure.com$policyId`?api-version=2020-10-01" `
            -Headers $headers).properties.effectiveRules

        $expiryRule  = $rules | Where-Object { $_.id -eq "Expiration_EndUser_Assignment" }
        $rawDuration = $expiryRule.maximumDuration

        $hours = if ($rawDuration -match "PT(\d+)H")     { [int]$Matches[1] }
                 elseif ($rawDuration -match "P(\d+)D")  { [int]$Matches[1] * 24 }
                 else                                     { 1 }

        Write-Host "  Policy max duration for '$roleName': $rawDuration ($hours hr)" -ForegroundColor DarkGray
    }
    catch {
        $hours = 1
        Write-Host "  Could not read policy for '$roleName', defaulting to 1hr" -ForegroundColor DarkYellow
    }

    # ── Activate the role ─────────────────────────────────────────────────────
    try {
        $body = @{ properties = @{
            principalId      = $userId
            roleDefinitionId = $roleDefId
            requestType      = "SelfActivate"
            linkedRoleEligibilityScheduleId = $role.properties.roleEligibilityScheduleId
            justification    = "Self activation"
            scheduleInfo     = @{ expiration = @{ type = "AfterDuration"; duration = "PT${hours}H" } }
        }} | ConvertTo-Json -Depth 10

        Invoke-RestMethod `
            -Uri "https://management.azure.com$scopePath/providers/Microsoft.Authorization/roleAssignmentScheduleRequests/$([guid]::NewGuid())?api-version=2020-10-01" `
            -Headers $headers -Method Put -Body $body | Out-Null

        Write-Host "  ✔ Activated : $roleName → $($role.properties.expandedProperties.scope.displayName) for $hours hour(s)" -ForegroundColor Green
    }
    catch {
        $err = ($_.ErrorDetails.Message | ConvertFrom-Json -ErrorAction SilentlyContinue).error.message
        Write-Host "  ✘ Failed    : $roleName — $err" -ForegroundColor Red
    }
}

Write-Host "`nDone!" -ForegroundColor Cyan

How It Works

Module Auto-Install — The script checks for all required Az and Microsoft Graph modules and installs any missing ones before proceeding. It also pins Graph modules to the same version to avoid compatibility conflicts.

Part 1 — Entra ID Roles (Graph API) — Connects via Connect-MgGraph with the required scopes, fetches all eligible directory role assignments for the signed-in user, reads the max allowed duration from each role's PIM policy, and submits a selfActivate request via New-MgRoleManagementDirectoryRoleAssignmentScheduleRequest.

Part 2 — Azure Resource Roles (ARM REST API) — Connects via Connect-AzAccount, retrieves a bearer token (handling the SecureString conversion in newer Az module versions), queries the tenant-level PIM endpoint for eligible resource role assignments, auto-detects max duration from roleManagementPolicyAssignments, and submits activation requests via the ARM REST API.

Automate Azure PIM Role Activation with PowerShell

Automate Azure PIM Role Activation with PowerShell

Tired of manually clicking Activate in the Azure portal every time your PIM role expires?
This script automatically activates all eligible Azure resource roles — and even detects the maximum allowed duration from your PIM policy, so you never hit the "duration exceeds maximum" error again.


Prerequisites

  • Az PowerShell module installed (Install-Module Az -Scope CurrentUser -Force)
  • Eligible Azure RBAC roles assigned via PIM
  • Az.Accounts and Az.Resources modules

The Script

# ============================================================
#  Azure PIM Resource Role Activator - Full Script
# ============================================================

# ── Auto-install required modules ────────────────────────────────────────────
foreach ($module in @("Az.Accounts", "Az.Resources")) {
    if (-not (Get-Module -ListAvailable -Name $module)) {
        Write-Host "Installing $module..." -ForegroundColor Yellow
        Install-Module $module -Scope CurrentUser -Force -AllowClobber
    }
    Import-Module $module -ErrorAction Stop
}

# Or if you want to install the full Az bundle (all Az modules) instead:
# if (-not (Get-Module -ListAvailable -Name Az.Accounts)) {
#     Write-Host "Installing Az module..." -ForegroundColor Yellow
#     Install-Module Az -Scope CurrentUser -Force -AllowClobber
# }
# Import-Module Az.Accounts, Az.Resources -ErrorAction Stop

Connect-AzAccount

# Get token (handle SecureString in newer Az versions)
$tokenObj = Get-AzAccessToken -ResourceUrl "https://management.azure.com"
$token    = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto(
                [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($tokenObj.Token))

$headers = @{ Authorization = "Bearer $token"; "Content-Type" = "application/json" }
$subId   = (Get-AzContext).Subscription.Id
$userId  = (Get-AzADUser -SignedIn).Id

Write-Host "Subscription : $subId" -ForegroundColor Cyan
Write-Host "User ID      : $userId" -ForegroundColor Cyan

# ── Get eligible roles (tenant-level works, subscription-level doesn't) ──────
$roles = (Invoke-RestMethod `
    -Uri "https://management.azure.com/providers/Microsoft.Authorization/roleEligibilityScheduleInstances?api-version=2020-10-01&`$filter=asTarget()" `
    -Headers $headers).value
	
$roles | ForEach-Object {
    [PSCustomObject]@{
        Role         = $_.properties.expandedProperties.roleDefinition.displayName
        Resource     = $_.properties.expandedProperties.scope.displayName
        ResourceType = $_.properties.expandedProperties.scope.type
        Scope        = $_.properties.scope
        StartTime    = $_.properties.startDateTime
        EndTime      = $_.properties.endDateTime
        Status       = $_.properties.status
    }
} | Format-Table -AutoSize
	
Write-Host "`nFound $($roles.Count) eligible role(s)" -ForegroundColor Yellow
$roles | ForEach-Object {
    Write-Host "  • $($_.properties.expandedProperties.roleDefinition.displayName) → $($_.properties.expandedProperties.scope.displayName)"
}

Write-Host "`n===== ACTIVATING ALL ROLES =====" -ForegroundColor Yellow

foreach ($role in $roles) {

    $roleName  = $role.properties.expandedProperties.roleDefinition.displayName
    $scopePath = $role.properties.scope
    $roleDefId = $role.properties.roleDefinitionId

    # ── Auto-detect max allowed hours from PIM policy assignment ─────────────
    try {
        $policyAssignments = (Invoke-RestMethod `
            -Uri "https://management.azure.com$scopePath/providers/Microsoft.Authorization/roleManagementPolicyAssignments?api-version=2020-10-01&`$filter=roleDefinitionId eq '$roleDefId'" `
            -Headers $headers).value

        $policyId = $policyAssignments[0].properties.policyId

        $rules = (Invoke-RestMethod `
            -Uri "https://management.azure.com$policyId`?api-version=2020-10-01" `
            -Headers $headers).properties.effectiveRules

        $expiryRule  = $rules | Where-Object { $_.id -eq "Expiration_EndUser_Assignment" }
        $rawDuration = $expiryRule.maximumDuration

        $hours = if ($rawDuration -match "PT(\d+)H")     { [int]$Matches[1] }
                 elseif ($rawDuration -match "P(\d+)D")  { [int]$Matches[1] * 24 }
                 else                                     { 1 }

        Write-Host "  Policy max duration for '$roleName': $rawDuration ($hours hr)" -ForegroundColor DarkGray
    }
    catch {
        $hours = 1
        Write-Host "  Could not read policy for '$roleName', defaulting to 1hr" -ForegroundColor DarkYellow
    }

    # ── Activate the role ─────────────────────────────────────────────────────
    try {
        $body = @{ properties = @{
            principalId      = $userId
            roleDefinitionId = $roleDefId
            requestType      = "SelfActivate"
            linkedRoleEligibilityScheduleId = $role.properties.roleEligibilityScheduleId
            justification    = "Self activation"
            scheduleInfo     = @{ expiration = @{ type = "AfterDuration"; duration = "PT${hours}H" } }
        }} | ConvertTo-Json -Depth 10

        Invoke-RestMethod `
            -Uri "https://management.azure.com$scopePath/providers/Microsoft.Authorization/roleAssignmentScheduleRequests/$([guid]::NewGuid())?api-version=2020-10-01" `
            -Headers $headers -Method Put -Body $body | Out-Null

        Write-Host "  ✔ Activated : $roleName → $($role.properties.expandedProperties.scope.displayName) for $hours hour(s)" -ForegroundColor Green
    }
    catch {
        $err = ($_.ErrorDetails.Message | ConvertFrom-Json -ErrorAction SilentlyContinue).error.message
        Write-Host "  ✘ Failed    : $roleName — $err" -ForegroundColor Red
    }
}

Write-Host "`nDone!" -ForegroundColor Cyan

How It Works

1. Authentication — Connects to Azure and retrieves a bearer token, handling the SecureString conversion introduced in newer Az module versions.

2. Eligible Role Discovery — Queries the tenant-level PIM endpoint to fetch all eligible role assignments for the signed-in user.

3. Auto-Duration Detection — For each role, it reads the roleManagementPolicyAssignment to find the Expiration_EndUser_Assignment rule and extracts the maximumDuration (e.g. PT4H). This prevents the "duration exceeds maximum" API error.

4. Role Activation — Submits a SelfActivate request for each eligible role using the exact maximum duration allowed by policy.



Active Directory Using PowerShell

Active Directory Using PowerShell

Prerequisites

  • PowerShell 5.1 or PowerShell 7+
  • RSAT Active Directory module installed (Windows Server or Windows 10/11 with RSAT)
  • Read access to Active Directory
  • The exported agents CSV file

The Script

$CSVPath    = "C:\Temp\Agents.csv"
$OutputPath = "C:\Temp\agents_updated.csv"
$LogPath    = "C:\Temp\agents_ad_check.log"

Import-Module ActiveDirectory

function Write-Log {
    param([string]$Message, [string]$Level = "INFO")
    $line = "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] [$Level] $Message"
    Write-Host $line
    Add-Content -Path $LogPath -Value $line
}

function Get-ADUserStatus {
    param([string]$UserEmail)

    # Check 1: Skip empty or whitespace values
    if ([string]::IsNullOrWhiteSpace($UserEmail)) { return "not-exist" }

    # Check 2: Domain normalization — replace legacy domain with current domain
    if ($UserEmail -like "*@olddomain.com") {
        $UserEmail = $UserEmail.Replace("@olddomain.com", "@newdomain.com")
    }

    try {
        # Primary lookup: by UserPrincipalName
        $user = Get-ADUser -Filter "UserPrincipalName -eq '$UserEmail'" `
                           -Properties Enabled -ErrorAction Stop

        # Fallback lookup: by mail attribute
        if ($null -eq $user) {
            $user = Get-ADUser -Filter "mail -eq '$UserEmail'" `
                               -Properties Enabled -ErrorAction Stop
        }

        if ($user.Enabled -eq $true) { return "exist" } else { return "not-exist" }
    }
    catch {
        Write-Log "ERROR: '$UserEmail' — $($_.Exception.Message)" -Level "ERROR"
        return "not-exist"
    }
}

$records = Import-Csv -Path $CSVPath

foreach ($row in $records) {
    $row.Is_Owner_Exist = Get-ADUserStatus -UserEmail $row.Owner.Trim()
    Write-Log "$($row.Owner) → $($row.Is_Owner_Exist)"
}

$records | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8
Write-Log "=== Done. Output: $OutputPath ==="

PowerShell, ActiveDirectory, CopilotStudio, PowerPlatform, MicrosoftCopilot, EntraID, M365Governance, PowerShellAutomation, MicrosoftTeams, LowCode

Monday, June 22, 2026

Send Emails via Microsoft Graph API Using PowerShell and App Registration

Send Emails via Microsoft Graph API Using PowerShell and App Registration

Microsoft Graph API is the unified gateway to Microsoft 365 data and services. One of the most common automation scenarios is sending emails programmatically — without relying on a logged-in user, Outlook, or SMTP. In this post, we'll walk through how to register an Azure AD app, grant it the right permissions, and use a single end-to-end PowerShell script to send emails and query SharePoint via Graph API using client credentials (app-only auth).


Why Use Graph API for Sending Emails?

  • No user sign-in required — works great for background jobs, bots, and automation flows
  • Works from anywhere — PowerShell scripts, Power Automate Desktop, Azure Functions, etc.
  • Scalable and auditable — full control over sending identity and logging

Step 1: Register an App in Microsoft Entra ID (Azure AD)

Before writing any code, you need an app registration that represents your script or automation.

  1. Go to https://portal.azure.com
  2. Navigate to Microsoft Entra ID → App registrations → New registration
  3. Give it a meaningful name (e.g., GraphAPI-MailSender)
  4. Leave Redirect URI blank — not needed for client credentials flow
  5. Click Register

Once created, note down:

  • Application (client) ID → used as $clientId in the script
  • Directory (tenant) ID → used in the token endpoint URL

Step 2: Create a Client Secret

  1. Go to Certificates & secrets → New client secret
  2. Add a description and set an expiry (e.g., 12 months)
  3. Copy the Value immediately — it won't be shown again
  4. Store it securely (Azure Key Vault is recommended for production)

Step 3: Grant API Permissions

Your app needs the following Application permissions (not Delegated):

Permission Purpose
Mail.Send Send email as any mailbox in the tenant
Sites.Read.All Read SharePoint site data

Steps:

  1. Go to API permissions → Add a permission → Microsoft Graph → Application permissions
  2. Add Mail.Send and Sites.Read.All
  3. Click Grant admin consent — required for application permissions

⚠️ Mail.Send as an application permission allows sending mail as any user in the tenant. Use application access policies to restrict it to specific mailboxes in production.


Step 4: Full PowerShell Script

The script below handles everything in sequence:

  1. Acquires a token using client credentials
  2. Sends an email via Graph API
  3. Reuses the token to query the SharePoint root site
# ============================================================
# Microsoft Graph API — Send Email + Query SharePoint Root Site
# Using App-Only (Client Credentials) Authentication
# ============================================================

# ── Configuration ──────────────────────────────────────────
$clientId     = "<YOUR_CLIENT_ID>"       # Application (client) ID
$clientSecret = "<YOUR_CLIENT_SECRET>"   # Client secret value
$tenantId     = "<YOUR_TENANT_ID>"       # Directory (tenant) ID
$senderUPN    = "sender@yourdomain.com"  # Mailbox used to send email
$recipientUPN = "recipient@yourdomain.com" # Target recipient

# ── Part 1: Acquire Access Token ────────────────────────────
Write-Output "Acquiring access token..."

$tokenBody = @{
    client_id     = $clientId
    client_secret = $clientSecret
    scope         = "https://graph.microsoft.com/.default"
    grant_type    = "client_credentials"
}

$tokenResponse = Invoke-RestMethod `
    -Uri    "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token" `
    -Method POST `
    -Body   $tokenBody

$token = $tokenResponse.access_token
Write-Output "Token acquired successfully."

# ── Part 2: Send Email via Graph API ────────────────────────
Write-Output "Sending email..."

$mailPayload = @{
    message = @{
        subject = "Test Email from Graph API"
        body    = @{
            contentType = "Text"
            content     = "This is a test email sent via Microsoft Graph API using PowerShell with app-only authentication."
        }
        toRecipients = @(
            @{
                emailAddress = @{
                    address = $recipientUPN
                }
            }
        )
    }
} | ConvertTo-Json -Depth 10

$headers = @{
    Authorization  = "Bearer $token"
    "Content-Type" = "application/json"
}

try {
    Invoke-RestMethod `
        -Uri         "https://graph.microsoft.com/v1.0/users/$senderUPN/sendMail" `
        -Method      POST `
        -Headers     $headers `
        -Body        $mailPayload `
        -ContentType "application/json"

    Write-Output "✅ Email sent successfully to $recipientUPN"
}
catch {
    Write-Error "❌ Failed to send email: $_"
}

# ── Part 3: Query SharePoint Root Site ──────────────────────
Write-Output "`nQuerying SharePoint root site..."

try {
    $rootSite = Invoke-RestMethod `
        -Uri     "https://graph.microsoft.com/v1.0/sites/root" `
        -Method  GET `
        -Headers $headers

    Write-Output "✅ SharePoint root site retrieved:"
    Write-Output ($rootSite | ConvertTo-Json -Depth 3)
}
catch {
    Write-Error "❌ Failed to query SharePoint root site: $_"
}

How to Run the Script

  1. Open PowerShell (or Windows PowerShell ISE / VS Code)
  2. Replace the four placeholder values at the top of the script:
    • <YOUR_CLIENT_ID>
    • <YOUR_CLIENT_SECRET>
    • <YOUR_TENANT_ID>
    • sender@yourdomain.com and recipient@yourdomain.com
  3. Save as Send-GraphEmail.ps1
  4. Run:
.\Send-GraphEmail.ps1

Expected Output

Acquiring access token...
Token acquired successfully.
Sending email...
✅ Email sent successfully to recipient@yourdomain.com

Querying SharePoint root site...
✅ SharePoint root site retrieved:
{
  "id": "yourtenant.sharepoint.com,xxxxxxxx-...",
  "displayName": "Communication site",
  "name": "root",
  "webUrl": "https://yourtenant.sharepoint.com"
}

Script Breakdown

Section What it does
Configuration block Centralises all credentials and addresses at the top — easy to maintain
Token acquisition Posts to the OAuth 2.0 token endpoint using client credentials
Email sending Constructs the mail JSON payload and calls /users/{sender}/sendMail
SharePoint query Reuses the same token to call /sites/root — no second token needed
try/catch blocks Catches errors per operation so one failure doesn't stop the rest

Security Best Practices

Practice Recommendation
Never hardcode secrets Move $clientSecret to Azure Key Vault or environment variables
Rotate secrets regularly Set reminders before expiry; automate via Key Vault rotation
Restrict Mail.Send scope Use application access policies to limit to specific mailboxes
Prefer certificates Certificate-based auth (New-SelfSignedCertificate) is more secure than secrets
Audit permissions Review app registrations and permissions in Entra ID quarterly

Summary

In this post, we covered:

  • Registering an app in Microsoft Entra ID and creating a client secret
  • Granting Mail.Send and Sites.Read.All application permissions with admin consent
  • A complete PowerShell script that acquires a token, sends an email, and queries SharePoint — all using app-only auth
  • Error handling with try/catch per operation
  • Security recommendations for production use

This pattern is perfect for Power Automate Desktop flows, scheduled PowerShell jobs, or any scenario where no interactive user is present. The same token works across multiple Graph API endpoints — making it efficient to chain operations in a single script.


Tags: MicrosoftGraph, PowerShell, AzureAD, AppRegistration, Microsoft365, EmailAutomation, GraphAPI, EntraID, PowerAutomate, SharePoint


Part 2: End-to-End in Power Automate Desktop (PAD)

The same logic — get a token, send an email, query SharePoint — can run entirely inside a Power Automate Desktop flow without any external PowerShell script. PAD has built-in HTTP actions and a Run PowerShell script action, giving you two clean approaches.


Approach A: Using the "Run PowerShell Script" Action (Quickest Way)

This embeds the entire PowerShell script directly inside a PAD flow. Best when you already have the script and just want to automate it.

PAD Flow Structure

Flow: Send Email via Graph API
│
├── [1] Set Variable — clientId
├── [2] Set Variable — clientSecret
├── [3] Set Variable — tenantId
├── [4] Set Variable — senderUPN
├── [5] Set Variable — recipientUPN
├── [6] Run PowerShell Script
│       └── Script: (full script using %clientId%, %clientSecret%, etc.)
│       └── Output: PowershellOutput
├── [7] IF PowershellOutput contains "successfully"
│       └── Display Message — "Flow completed successfully"
│   ELSE
│       └── Display Message — "Flow encountered an error"
└── [8] (Optional) Write output to text file or log

Step-by-Step in PAD Designer

Step 1 — Set your variables

Add one Set variable action for each credential. Using variables (rather than hardcoding) keeps the script clean and makes future updates easy.

Variable Name Value
clientId Your Application (client) ID
clientSecret Your client secret value
tenantId Your Directory (tenant) ID
senderUPN sender@yourdomain.com
recipientUPN recipient@yourdomain.com

In PAD, go to Actions panel → Variables → Set variable, set Name and Value for each.

Step 2 — Add "Run PowerShell Script" action

Search for Run PowerShell script in the Actions panel (under Scripting). Paste the script below into the script body. PAD variables are referenced using %variableName% syntax inside the script block.

# ============================================================
# Graph API — Send Email + Query SharePoint Root Site
# Running inside Power Automate Desktop
# ============================================================

$clientId     = "%clientId%"
$clientSecret = "%clientSecret%"
$tenantId     = "%tenantId%"
$senderUPN    = "%senderUPN%"
$recipientUPN = "%recipientUPN%"

# ── Part 1: Acquire Token ────────────────────────────────────
$tokenBody = @{
    client_id     = $clientId
    client_secret = $clientSecret
    scope         = "https://graph.microsoft.com/.default"
    grant_type    = "client_credentials"
}

$tokenResponse = Invoke-RestMethod `
    -Uri    "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token" `
    -Method POST `
    -Body   $tokenBody

$token = $tokenResponse.access_token

# ── Part 2: Send Email ───────────────────────────────────────
$mailPayload = @{
    message = @{
        subject = "Test Email from Graph API via PAD"
        body    = @{
            contentType = "Text"
            content     = "This email was sent via Microsoft Graph API running inside a Power Automate Desktop flow."
        }
        toRecipients = @(
            @{ emailAddress = @{ address = $recipientUPN } }
        )
    }
} | ConvertTo-Json -Depth 10

$headers = @{
    Authorization  = "Bearer $token"
    "Content-Type" = "application/json"
}

try {
    Invoke-RestMethod `
        -Uri         "https://graph.microsoft.com/v1.0/users/$senderUPN/sendMail" `
        -Method      POST `
        -Headers     $headers `
        -Body        $mailPayload `
        -ContentType "application/json"

    Write-Output "Email sent successfully to $recipientUPN"
}
catch {
    Write-Output "ERROR sending email: $_"
}

# ── Part 3: Query SharePoint Root Site ──────────────────────
try {
    $rootSite = Invoke-RestMethod `
        -Uri     "https://graph.microsoft.com/v1.0/sites/root" `
        -Method  GET `
        -Headers $headers

    Write-Output "SharePoint root site: $($rootSite.webUrl)"
}
catch {
    Write-Output "ERROR querying SharePoint: $_"
}

In the action settings:

  • Script to run → paste the script above
  • PowerShell script output → save to variable PowershellOutput
  • Script error output → save to variable ScriptError

Step 3 — Handle output with an IF condition

Add an If action:

  • First operand: %PowershellOutput%
  • Operator: Contains
  • Second operand: successfully

Inside the If block → add Display message: "✅ Email sent and SharePoint queried successfully"
Inside the Else block → add Display message: "❌ Flow error — check ScriptError variable"

Step 4 — (Optional) Log output to a file

Add a Write text to file action after the If block:

  • File path: C:\PAD-Logs\GraphAPI-Run-%CurrentDateTime%.txt
  • Text to write: %PowershellOutput%%NewLine%%ScriptError%
  • If file exists: Append

Approach B: Using PAD HTTP Actions (No PowerShell Required)

PAD has native HTTP actions under Web → Invoke web service that can call REST APIs directly — no PowerShell needed. This is the cleanest approach for PAD-native flows.

PAD Flow Structure

Flow: Send Email via Graph API (HTTP Native)
│
├── [1]  Set Variable — clientId
├── [2]  Set Variable — clientSecret  
├── [3]  Set Variable — tenantId
├── [4]  Set Variable — senderUPN
├── [5]  Set Variable — recipientUPN
│
├── [6]  Invoke web service — POST token endpoint
│        └── Saves response → TokenResponse (JSON)
│
├── [7]  Get JSON key — extract access_token
│        └── Saves → AccessToken
│
├── [8]  Invoke web service — POST sendMail
│        └── Uses Bearer %AccessToken% in header
│        └── Saves response → MailResult
│
├── [9]  Invoke web service — GET sites/root
│        └── Uses Bearer %AccessToken% in header
│        └── Saves response → SharePointResult
│
└── [10] Display message — show results

Step-by-Step: HTTP Actions in PAD

Step 1 — Set variables (same as Approach A)

Step 2 — Get Access Token (Invoke web service)

Action: Web → Invoke web service

Field Value
URL https://login.microsoftonline.com/%tenantId%/oauth2/v2.0/token
Method POST
Accept application/json
Content type application/x-www-form-urlencoded
Custom headers (leave blank)
Request body client_id=%clientId%&client_secret=%clientSecret%&scope=https://graph.microsoft.com/.default&grant_type=client_credentials
Save response into TokenResponse

Step 3 — Extract the access token

Action: Variables → Get JSON key value
(or use Convert JSON to custom object then dot-notation)

  • JSON: %TokenResponse%
  • Key: access_token
  • Save into: AccessToken

Alternatively, use a Run PowerShell script just for this one-liner:

$json = '%TokenResponse%' | ConvertFrom-Json
Write-Output $json.access_token

Save output into AccessToken.

Step 4 — Send Email (Invoke web service)

Action: Web → Invoke web service

Field Value
URL https://graph.microsoft.com/v1.0/users/%senderUPN%/sendMail
Method POST
Accept application/json
Content type application/json
Custom headers Authorization: Bearer %AccessToken%
Request body (see JSON below)
Save response into MailResult

Request body (paste as-is, update addresses via variables):

{
  "message": {
    "subject": "Test Email from PAD via Graph API",
    "body": {
      "contentType": "Text",
      "content": "This email was sent using Power Automate Desktop with Microsoft Graph API HTTP actions."
    },
    "toRecipients": [
      {
        "emailAddress": {
          "address": "%recipientUPN%"
        }
      }
    ]
  }
}

Step 5 — Query SharePoint Root Site (Invoke web service)

Action: Web → Invoke web service

Field Value
URL https://graph.microsoft.com/v1.0/sites/root
Method GET
Accept application/json
Custom headers Authorization: Bearer %AccessToken%
Save response into SharePointResult

Step 6 — Display results

Action: Message boxes → Display message
Message: SharePoint site: %SharePointResult%


Approach Comparison

Approach A (Run PowerShell) Approach B (HTTP Actions)
Complexity Low — paste and run Medium — multiple actions
PAD native No (calls PowerShell) Yes (pure PAD)
Debugging Via script output variable Per-action response variables
Best for Existing PowerShell scripts New PAD-native flows
Token parsing Built into the script Needs extra step in PAD
Error handling try/catch in script On block error / IF conditions

Recommendation: Use Approach A if you already have the PowerShell script. Use Approach B if you want a fully PAD-native flow with no external dependencies.


Tips for PAD Flows with Graph API

  • Store secrets in PAD Input variables marked as Sensitive — they are masked in logs and not stored in plain text in the flow definition
  • Add On block error handlers around each Invoke web service action to gracefully catch HTTP failures (401 Unauthorized, 403 Forbidden, etc.)
  • Token expiry: The client credentials token is valid for 1 hour. For long-running flows, re-acquire the token mid-flow if needed
  • Test in PAD debugger using the step-through (F10) to inspect each variable value before running end-to-end

Full Flow Summary

App Registration (Entra ID)
        │
        ▼
Client Credentials → OAuth 2.0 Token Endpoint
        │
        ▼
Access Token (Bearer)
        │
        ├──▶ POST /users/{sender}/sendMail   → Email delivered ✅
        │
        └──▶ GET  /sites/root               → SharePoint data ✅

All three entry points — standalone PowerShell, PAD via PowerShell action, and PAD via HTTP actions — use the exact same app registration, permissions, and token. The only difference is where and how the script runs.


Tags: MicrosoftGraph, PowerShell, PowerAutomateDesktop, PAD, AzureAD, AppRegistration, Microsoft365, EmailAutomation, GraphAPI, EntraID, SharePoint, RPA

Wednesday, June 3, 2026

How to Create a Self-Signed Certificate and Upload It to Azure App Registration Using PowerShell

ApplicationClientId

How to Create a Self-Signed Certificate and Upload It to Azure App Registration Using PowerShell

Securing your Azure app with certificate-based authentication is a best practice — especially when building automation with Microsoft Graph, Exchange Online, or Teams. In this post, I'll walk you through how to create a self-signed certificate using PowerShell on your local machine and upload it to an Azure App Registration in Microsoft Entra ID.


Why Use Certificate-Based Authentication?

Certificate-based authentication is more secure than client secrets because:

  • Certificates are harder to leak accidentally (no plain-text secret string)
  • They support expiry enforcement with a clear renewal cycle
  • They work seamlessly with GitHub Actions, Azure Automation, and PowerShell scripts
  • They are the recommended approach for unattended/service account automation in M365

Prerequisites

  • Windows machine with PowerShell 5.1+ or PowerShell 7+
  • Access to Azure Portal with permissions to manage App Registrations
  • An existing App Registration in Microsoft Entra ID (or create a new one)

Step 1 — Create the Self-Signed Certificate

Open PowerShell as Administrator and run the following script to generate a self-signed certificate and store it in your local certificate store.

# ─────────────────────────────────────────────────────────────
# STEP 1: Create Self-Signed Certificate
# ─────────────────────────────────────────────────────────────

$certName   = "MyAppAuthCert"
$validYears = 2

# Generate the certificate in CurrentUser\My store
$cert = New-SelfSignedCertificate `
    -Subject           "CN=$certName" `
    -CertStoreLocation "Cert:\CurrentUser\My" `
    -KeyExportPolicy   Exportable `
    -KeySpec           Signature `
    -KeyLength         2048 `
    -KeyAlgorithm      RSA `
    -HashAlgorithm     SHA256 `
    -NotAfter          (Get-Date).AddYears($validYears)

# Confirm creation
Write-Host "✅ Certificate created successfully!" -ForegroundColor Green
Write-Host "   Subject    : $($cert.Subject)"
Write-Host "   Thumbprint : $($cert.Thumbprint)"
Write-Host "   Valid Until: $($cert.NotAfter)"

Note: The certificate is stored in Cert:\CurrentUser\My. You can verify it by opening certmgr.msc → Personal → Certificates.


Step 2 — Export the Public Key (.cer)

The App Registration only needs the public key in .cer format. No password is required for this export.

# ─────────────────────────────────────────────────────────────
# STEP 2: Export Public Key (.cer) for Azure Portal Upload
# ─────────────────────────────────────────────────────────────

$cerPath = "$env:USERPROFILE\Desktop\$certName.cer"

Export-Certificate `
    -Cert     "Cert:\CurrentUser\My\$($cert.Thumbprint)" `
    -FilePath $cerPath

Write-Host "✅ Public key exported!" -ForegroundColor Green
Write-Host "   Path: $cerPath"

Step 3 — Export the Private Key (.pfx)

Keep your .pfx file safe — this is used by your PowerShell scripts and automation pipelines to authenticate.

# ─────────────────────────────────────────────────────────────
# STEP 3: Export Private Key (.pfx) for Script Authentication
# ─────────────────────────────────────────────────────────────

$pfxPassword = Read-Host -Prompt "Enter PFX password" -AsSecureString
$pfxPath     = "$env:USERPROFILE\Desktop\$certName.pfx"

Export-PfxCertificate `
    -Cert     "Cert:\CurrentUser\My\$($cert.Thumbprint)" `
    -FilePath $pfxPath `
    -Password $pfxPassword

Write-Host "✅ Private key (PFX) exported!" -ForegroundColor Green
Write-Host "   Path: $pfxPath"
Write-Host "⚠️  Keep this file secure. Do NOT commit it to source control." -F Yellow

⚠️ Security Tip: Store the .pfx file in Azure Key Vault or as a GitHub Actions secret (base64-encoded). Never commit it to a repository.


Step 4 — Upload the Certificate to Azure App Registration

  1. Sign in to the Azure Portal
  2. Navigate to Microsoft Entra IDApp registrations
  3. Select your application
  4. In the left menu, click Certificates & secrets
  5. Click the Certificates tab → Upload certificate
  6. Browse and select the .cer file exported to your Desktop
  7. Add a Description (e.g., MyAppAuthCert-2026) → click Add

You will now see the certificate listed with its Thumbprint, Start date, and Expiry date.


Step 5 — Complete PowerShell Script (All-in-One)

Here is the complete end-to-end script you can save and run directly:

# ═══════════════════════════════════════════════════════════════
# Create Self-Signed Certificate & Export for Azure App Reg
# Author : Your Name
# Version: 1.0
# Date   : June 2026
# ═══════════════════════════════════════════════════════════════

# ── Configuration ─────────────────────────────────────────────
$certName   = "MyAppAuthCert"
$validYears = 2
$exportPath = "$env:USERPROFILE\Desktop"

Write-Host "`n🔐 Starting Certificate Creation..." -ForegroundColor Cyan

# ── Step 1: Create Self-Signed Certificate ────────────────────
$cert = New-SelfSignedCertificate `
    -Subject           "CN=$certName" `
    -CertStoreLocation "Cert:\CurrentUser\My" `
    -KeyExportPolicy   Exportable `
    -KeySpec           Signature `
    -KeyLength         2048 `
    -KeyAlgorithm      RSA `
    -HashAlgorithm     SHA256 `
    -NotAfter          (Get-Date).AddYears($validYears)

Write-Host "✅ Certificate created: $($cert.Thumbprint)" -ForegroundColor Green

# ── Step 2: Export Public Key (.cer) ─────────────────────────
$cerPath = "$exportPath\$certName.cer"

Export-Certificate `
    -Cert     "Cert:\CurrentUser\My\$($cert.Thumbprint)" `
    -FilePath $cerPath | Out-Null

Write-Host "✅ Public key exported : $cerPath" -ForegroundColor Green

# ── Step 3: Export Private Key (.pfx) ────────────────────────
$pfxPassword = Read-Host -Prompt "`nEnter a secure password for the PFX file" -AsSecureString
$pfxPath     = "$exportPath\$certName.pfx"

Export-PfxCertificate `
    -Cert     "Cert:\CurrentUser\My\$($cert.Thumbprint)" `
    -FilePath $pfxPath `
    -Password $pfxPassword | Out-Null

Write-Host "✅ Private key exported: $pfxPath" -ForegroundColor Green

# ── Summary ───────────────────────────────────────────────────
Write-Host "`n════════════════════════════════════════" -ForegroundColor Cyan
Write-Host "  Certificate Summary" -ForegroundColor Cyan
Write-Host "════════════════════════════════════════" -ForegroundColor Cyan
Write-Host "  Subject     : $($cert.Subject)"
Write-Host "  Thumbprint  : $($cert.Thumbprint)"
Write-Host "  Valid From  : $($cert.NotBefore)"
Write-Host "  Valid Until : $($cert.NotAfter)"
Write-Host "  CER Path    : $cerPath"
Write-Host "  PFX Path    : $pfxPath"
Write-Host "════════════════════════════════════════" -ForegroundColor Cyan
Write-Host "`n📌 Next Step: Upload the .cer file to your Azure App Registration" -F Yellow
Write-Host "   Portal → Entra ID → App Registrations → Certificates & Secrets`n"

Step 6 — Authenticate Using the Certificate in PowerShell

Once the certificate is uploaded to the App Registration, use the thumbprint to authenticate in your automation scripts.

Microsoft Graph

# ─────────────────────────────────────────────────────────────
# Authenticate with Microsoft Graph using Certificate
# ─────────────────────────────────────────────────────────────

$tenantId   = "<your-tenant-id>"
$clientId   = "<your-app-client-id>"
$thumbprint = "<certificate-thumbprint>"

Connect-MgGraph `
    -TenantId             $tenantId `
    -ClientId             $clientId `
    -CertificateThumbprint $thumbprint

Write-Host "✅ Connected to Microsoft Graph" -ForegroundColor Green

Exchange Online

$ApplicationClientId = "<your-application-client-id>"

$ApplicationBase64Pfx = "<your-application-base64-pfx>"

$ApplicationCertPFXPassword = "<your-application-cert-pfx-password>"

#For Automation

$pfxBytes = [Convert]::FromBase64String($ApplicationBase64Pfx)

$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($pfxBytes, 
    $ApplicationCertPFXPassword,
    [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::MachineKeySet) Connect-ExchangeOnline -AppId $ApplicationClientId -Organization '<your-organization>'
    -Certificate $cert #For Local Machine Connect-ExchangeOnline ` -AppId $ApplicationClientId ` -Organization nandps.onmicrosoft.com ` -CertificateThumbprint $ApplicationThumbprint #For Login admin user #Connect-ExchangeOnline -UserPrincipalName $AdminUpn -ShowProgress $true

Quick Reference Summary

Step Action Output
1 New-SelfSignedCertificate Cert created in local store
2 Export-Certificate .cer public key file
3 Export-PfxCertificate .pfx private key file
4 Upload .cer in Azure Portal App Registration cert added
5 Use thumbprint in scripts Cert-based auth working

Tips & Best Practices

  • 🔁 Renewal reminder — Set a calendar alert 30 days before expiry (NotAfter date)
  • 🔒 Store PFX securely — Use Azure Key Vault or GitHub Actions secrets (base64-encoded)
  • 🏷️ Use descriptive names — Include the year in the cert description (e.g., AppAuthCert-2026)
  • 📋 Document the thumbprint — Save it in your runbook/wiki for reference during troubleshooting
  • 🚫 Never use secrets for automation — Cert-based auth is always preferred over client secrets for unattended scripts

Hashtags

#MicrosoftAzure #EntraID #PowerShell #AzureAppRegistration #CertificateAuthentication #M365 #MicrosoftGraph #DevOps #CloudSecurity #AzureAutomation

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

Featured Post

Microsoft Copilot Studio: The Complete Beginner to Advanced Guide

Microsoft Copilot Studio: The Complete Beginner to Advanced Guide Version: July 2026 | Audience: Beginners, Citizen Developers, Busine...

Popular posts