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

Wednesday, May 20, 2026

Grant Azure App Registration Access to Selected SharePoint Sites Using Sites.Selected

Grant Azure App Registration Access to Selected SharePoint Sites Using Sites.Selected

Microsoft 365 | SharePoint Online | PnP PowerShell | Microsoft Graph


Introduction

By default, when you grant an Azure App Registration access to SharePoint via Microsoft Graph, it gets access to every site in your tenant. That is a significant over-privilege for most automation scenarios where you only need to interact with one or a few specific sites.

Microsoft introduced the Sites.Selected permission model to solve this exact problem. It is a two-step approach: you grant the app a limited placeholder permission in Entra ID, then you explicitly assign it access to only the sites you choose. The app gets zero access until you complete both steps.

This article covers three approaches to implement Sites.Selected:

Approach Script Best For
Option 1 PnP PowerShell Easiest — dedicated cmdlets, recommended
Option 2 Microsoft Graph REST API via PowerShell No module dependency — pure REST calls
Option 3 Microsoft Graph PowerShell SDK Official Microsoft SDK approach

How Sites.Selected Works

Step 1 — Azure Portal
  App Registration → API Permissions → Sites.Selected → Admin Consent
  (App has NO site access yet)

Step 2 — PowerShell (choose one option below)
  Explicitly assign the app to specific sites with a role
  (App can now only access those assigned sites)
Permission Scope
Sites.ReadWrite.All ❌ All sites in the tenant
Sites.Selected ✅ Only explicitly assigned sites

Step 1 — Azure App Registration Setup

1.1 Create or open your App Registration

  1. Go to Azure PortalMicrosoft Entra IDApp Registrations
  2. Select your existing app or create a new one
  3. Navigate to API PermissionsAdd a permission
  4. Select Microsoft Graph
  5. Choose the permission type based on your scenario (see table below)
  6. Search for Sites.Selected and add it
  7. Click Grant admin consent for your tenant

1.2 Delegated vs Application — Which One to Select?

Sites.Selected exists under both permission types in the portal. Pick based on your scenario:

Microsoft Graph
├── Delegated permissions    → Sites.Selected  ✅
└── Application permissions  → Sites.Selected  ✅
Scenario Permission Type to Select
Background automation, daemon, scheduled job, no user login Application permissions
Power Automate unattended / service account flow Application permissions
App acting on behalf of a signed-in user Delegated permissions
Interactive app / user-context required Delegated permissions

Key difference in Delegated mode: Access is the intersection of the app's permission AND the signed-in user's own SharePoint permissions. The app can never exceed what the signed-in user can already access. In Application mode, there is no user — the app accesses the site purely based on the explicit role assigned in Step 2.

Most automation scenarios (scripts, Power Automate, background services) → use Application permissions.

Why Sites.Selected and not Sites.ReadWrite.All? Sites.ReadWrite.All grants the app access to every SharePoint site in your tenant — including sensitive sites. Sites.Selected scopes access to only the sites you explicitly configure, following the principle of least privilege.

1.3 Note down these values

After app registration, note the following — you will need them in all three script options:

  • Application (client) ID
  • Directory (tenant) ID
  • Client Secret or Certificate (for app-only auth)

Option 1 — PnP PowerShell (Recommended)

# =============================================================================
# SITES.SELECTED — Option 1: PnP PowerShell Script
# Purpose  : Grant / View / Update / Revoke app permissions on selected sites
# Module   : PnP.PowerShell
# Requires : SharePoint Admin or Global Admin account
# Ref      : https://pnp.github.io/powershell/cmdlets/Grant-PnPEntraIDAppSitePermission.html
# =============================================================================

# ── CONFIGURATION — update these values before running ───────────────────────

$adminUrl        = "https://<your-tenant>-admin.sharepoint.com"       # SharePoint Admin Center URL
$appClientId     = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"             # App Registration Client ID
$appDisplayName  = "MyAppName"                                         # Display label (any name)
$permissionLevel = "Write"                                             # Read | Write | Manage | FullControl

# Single site — used for grant / view / update / revoke operations
$singleSiteUrl   = "https://<tenant>.sharepoint.com/sites/SiteName"

# Bulk grant — list of sites to grant access to
$bulkSites = @(
    "https://<tenant>.sharepoint.com/sites/Site1",
    "https://<tenant>.sharepoint.com/sites/Site2",
    "https://<tenant>.sharepoint.com/sites/Site3"
)

# =============================================================================
# SECTION 1 — MODULE SETUP
# Pattern : Install if not present → always Import-Module to load into session
# =============================================================================

$moduleName = "PnP.PowerShell"

Write-Host "`n[Module] $moduleName" -ForegroundColor Cyan

# Step 1: Install if not already present
$installed = Get-Module -Name $moduleName -ListAvailable |
             Sort-Object Version -Descending |
             Select-Object -First 1

if (-not $installed) {
    Write-Host "  Install : Not found — installing from PSGallery..." -ForegroundColor Yellow
    try {
        Install-Module -Name $moduleName `
                       -Scope CurrentUser `
                       -Force `
                       -AllowClobber `
                       -ErrorAction Stop

        $installed = Get-Module -Name $moduleName -ListAvailable |
                     Sort-Object Version -Descending |
                     Select-Object -First 1

        Write-Host "  Install : Done — v$($installed.Version)" -ForegroundColor Green
    }
    catch {
        Write-Host "  Install : FAILED — $_" -ForegroundColor Red
        exit 1
    }
}
else {
    Write-Host "  Install : Already present — v$($installed.Version)" -ForegroundColor Green
}

# Step 2: Always import into the current session
# Import-Module is idempotent — safe to call even if already loaded
try {
    Import-Module -Name $moduleName -Force -ErrorAction Stop
    Write-Host "  Import  : Loaded into session" -ForegroundColor Green
}
catch {
    Write-Host "  Import  : FAILED — $_" -ForegroundColor Red
    exit 1
}

# =============================================================================
# SECTION 2 — CONNECT
# =============================================================================

Write-Host "`n[Connect] Connecting to SharePoint Admin Center..." -ForegroundColor Cyan

try {
    # Interactive login — supports MFA
    # For app-only auth replace with: -ClientId <id> -CertificateThumbprint <thumb> -Tenant <tenant>
    Connect-PnPOnline -Url $adminUrl -Interactive -ErrorAction Stop
    Write-Host "  Connected to: $adminUrl" -ForegroundColor Green
}
catch {
    Write-Host "  Connection FAILED — $_" -ForegroundColor Red
    exit 1
}

# =============================================================================
# SECTION 3 — GRANT: Single Site
# =============================================================================

Write-Host "`n[Grant] Single site permission..." -ForegroundColor Cyan

try {
    # Grant permission to the target site
    Grant-PnPEntraIDAppSitePermission `
        -AppId       $appClientId `       # App Client ID from Entra ID
        -DisplayName $appDisplayName `    # Label only — does not need to match Entra app name
        -Site        $singleSiteUrl `     # Target site URL
        -Permissions $permissionLevel     # Read | Write | Manage | FullControl

    Write-Host "  Granted [$permissionLevel] on: $singleSiteUrl" -ForegroundColor Green
}
catch {
    Write-Host "  FAILED on: $singleSiteUrl — $_" -ForegroundColor Red
}

# =============================================================================
# SECTION 4 — GRANT: Bulk Sites
# =============================================================================

Write-Host "`n[Grant] Bulk site permissions..." -ForegroundColor Cyan

foreach ($siteUrl in $bulkSites) {
    try {
        Grant-PnPEntraIDAppSitePermission `
            -AppId       $appClientId `
            -DisplayName $appDisplayName `
            -Site        $siteUrl `
            -Permissions $permissionLevel

        Write-Host "  Granted [$permissionLevel] → $siteUrl" -ForegroundColor Green
    }
    catch {
        Write-Host "  FAILED  → $siteUrl — $_" -ForegroundColor Red
    }
}

# =============================================================================
# SECTION 5 — VIEW: List permissions on a site
# =============================================================================

Write-Host "`n[View] Listing permissions on: $singleSiteUrl" -ForegroundColor Cyan

try {
    $perms = Get-PnPEntraIDAppSitePermission -Site $singleSiteUrl -ErrorAction Stop
    $perms | Select-Object Id, Roles, GrantedToIdentities | Format-Table -AutoSize
}
catch {
    Write-Host "  FAILED — $_" -ForegroundColor Red
}

# =============================================================================
# SECTION 6 — UPDATE: Change permission level on an existing assignment
# =============================================================================

Write-Host "`n[Update] Updating permission level..." -ForegroundColor Cyan

# Auto-lookup PermissionId for this app on the target site
$targetPerm = Get-PnPEntraIDAppSitePermission -Site $singleSiteUrl |
              Where-Object { $_.GrantedToIdentities.Application.Id -eq $appClientId } |
              Select-Object -First 1

if ($targetPerm) {
    try {
        Set-PnPEntraIDAppSitePermission `
            -Site         $singleSiteUrl `
            -PermissionId $targetPerm.Id `    # PermissionId retrieved above
            -Permissions  FullControl         # New permission level

        Write-Host "  Updated to [FullControl] on: $singleSiteUrl" -ForegroundColor Green
    }
    catch {
        Write-Host "  Update FAILED — $_" -ForegroundColor Red
    }
}
else {
    Write-Host "  No matching permission found for AppId: $appClientId" -ForegroundColor Yellow
}

# =============================================================================
# SECTION 7 — REVOKE: Remove app permission from a site
# =============================================================================

Write-Host "`n[Revoke] Revoking permission..." -ForegroundColor Cyan

# Auto-lookup PermissionId for this app on the target site
$revokePerm = Get-PnPEntraIDAppSitePermission -Site $singleSiteUrl |
              Where-Object { $_.GrantedToIdentities.Application.Id -eq $appClientId } |
              Select-Object -First 1

if ($revokePerm) {
    try {
        Revoke-PnPEntraIDAppSitePermission `
            -Site         $singleSiteUrl `
            -PermissionId $revokePerm.Id `    # PermissionId to remove
            -Force                            # Skip confirmation prompt

        Write-Host "  Revoked permission on: $singleSiteUrl" -ForegroundColor Green
    }
    catch {
        Write-Host "  Revoke FAILED — $_" -ForegroundColor Red
    }
}
else {
    Write-Host "  No matching permission found to revoke for AppId: $appClientId" -ForegroundColor Yellow
}

# =============================================================================
Write-Host "`n[Done] PnP Sites.Selected script completed." -ForegroundColor Cyan
# =============================================================================

Option 2 — Microsoft Graph REST API via PowerShell

No module dependency. Uses Invoke-RestMethod directly against the Graph API. Requires an access token — obtain it using client credentials (app secret or certificate).

# =============================================================================
# SITES.SELECTED — Option 2: Microsoft Graph REST API via PowerShell
# Purpose  : Grant / View / Update / Revoke app permissions using raw Graph API
# Module   : None — uses Invoke-RestMethod only
# Requires : App with Sites.FullControl.All (to manage permissions)
#            Target app with Sites.Selected (the app being granted access)
# Ref      : https://learn.microsoft.com/en-us/graph/api/site-post-permissions
# =============================================================================

# ── CONFIGURATION — update these values before running ───────────────────────

$tenantId       = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"   # Your Entra ID Tenant ID
$operatorAppId  = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"   # App used to call Graph (needs Sites.FullControl.All)
$operatorSecret = "your-client-secret-here"                 # Secret for the operator app above

$targetAppId    = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"   # App being granted Sites.Selected access
$targetAppName  = "MyAppName"                               # Display label for the target app

$siteUrl        = "https://<tenant>.sharepoint.com/sites/SiteName"  # Target site URL
$permissionRole = "write"                                             # read | write | manage | fullcontrol

# Bulk sites — used in Section 5
$bulkSites = @(
    "https://<tenant>.sharepoint.com/sites/Site1",
    "https://<tenant>.sharepoint.com/sites/Site2",
    "https://<tenant>.sharepoint.com/sites/Site3"
)

# =============================================================================
# SECTION 1 — GET ACCESS TOKEN (Client Credentials flow)
# =============================================================================

Write-Host "`n[Token] Acquiring access token..." -ForegroundColor Cyan

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

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

    $accessToken = $tokenResponse.access_token
    $headers = @{
        Authorization  = "Bearer $accessToken"
        "Content-Type" = "application/json"
    }

    Write-Host "  Token acquired successfully" -ForegroundColor Green
}
catch {
    Write-Host "  Token request FAILED — $_" -ForegroundColor Red
    exit 1
}

# =============================================================================
# SECTION 2 — GET SITE ID from site URL
# =============================================================================

Write-Host "`n[Site] Resolving Site ID for: $siteUrl" -ForegroundColor Cyan

try {
    # Parse hostname and path from the site URL
    $uri      = [System.Uri]$siteUrl
    $hostname = $uri.Host                          # e.g. tenant.sharepoint.com
    $sitePath = $uri.AbsolutePath                  # e.g. /sites/SiteName

    $siteResponse = Invoke-RestMethod `
        -Uri     "https://graph.microsoft.com/v1.0/sites/${hostname}:${sitePath}" `
        -Headers $headers `
        -Method  GET `
        -ErrorAction Stop

    $siteId = $siteResponse.id
    Write-Host "  Site ID: $siteId" -ForegroundColor Green
}
catch {
    Write-Host "  Failed to resolve Site ID — $_" -ForegroundColor Red
    exit 1
}

# =============================================================================
# SECTION 3 — GRANT: Single Site
# =============================================================================

Write-Host "`n[Grant] Granting permission on: $siteUrl" -ForegroundColor Cyan

try {
    $grantBody = @{
        roles                = @($permissionRole)   # read | write | manage | fullcontrol
        grantedToIdentities  = @(
            @{
                application = @{
                    id          = $targetAppId      # App being granted access
                    displayName = $targetAppName    # Label only
                }
            }
        )
    } | ConvertTo-Json -Depth 5

    $grantResponse = Invoke-RestMethod `
        -Uri     "https://graph.microsoft.com/v1.0/sites/$siteId/permissions" `
        -Headers $headers `
        -Method  POST `
        -Body    $grantBody `
        -ErrorAction Stop

    Write-Host "  Granted [$permissionRole] — Permission ID: $($grantResponse.id)" -ForegroundColor Green
}
catch {
    Write-Host "  Grant FAILED — $_" -ForegroundColor Red
}

# =============================================================================
# SECTION 4 — VIEW: List all permissions on a site
# =============================================================================

Write-Host "`n[View] Listing permissions on: $siteUrl" -ForegroundColor Cyan

try {
    $listResponse = Invoke-RestMethod `
        -Uri     "https://graph.microsoft.com/v1.0/sites/$siteId/permissions" `
        -Headers $headers `
        -Method  GET `
        -ErrorAction Stop

    $listResponse.value | ForEach-Object {
        Write-Host "  ID   : $($_.id)" -ForegroundColor White
        Write-Host "  Roles: $($_.roles -join ', ')" -ForegroundColor DarkCyan
        Write-Host "  App  : $($_.grantedToIdentitiesV2.application.displayName)" -ForegroundColor DarkGray
        Write-Host ""
    }
}
catch {
    Write-Host "  View FAILED — $_" -ForegroundColor Red
}

# =============================================================================
# SECTION 5 — GRANT: Bulk Sites
# =============================================================================

Write-Host "`n[Grant] Bulk site permissions..." -ForegroundColor Cyan

foreach ($url in $bulkSites) {
    try {
        # Resolve Site ID for each site
        $u        = [System.Uri]$url
        $host_    = $u.Host
        $path_    = $u.AbsolutePath

        $s = Invoke-RestMethod `
            -Uri     "https://graph.microsoft.com/v1.0/sites/${host_}:${path_}" `
            -Headers $headers `
            -Method  GET `
            -ErrorAction Stop

        $sid = $s.id

        # Grant permission
        $body_ = @{
            roles               = @($permissionRole)
            grantedToIdentities = @(@{ application = @{ id = $targetAppId; displayName = $targetAppName } })
        } | ConvertTo-Json -Depth 5

        Invoke-RestMethod `
            -Uri     "https://graph.microsoft.com/v1.0/sites/$sid/permissions" `
            -Headers $headers `
            -Method  POST `
            -Body    $body_ `
            -ErrorAction Stop | Out-Null

        Write-Host "  Granted [$permissionRole] → $url" -ForegroundColor Green
    }
    catch {
        Write-Host "  FAILED  → $url — $_" -ForegroundColor Red
    }
}

# =============================================================================
# SECTION 6 — UPDATE: Change permission level (requires Permission ID)
# =============================================================================

Write-Host "`n[Update] Updating permission..." -ForegroundColor Cyan

try {
    # First, find the Permission ID for the target app
    $existing = Invoke-RestMethod `
        -Uri     "https://graph.microsoft.com/v1.0/sites/$siteId/permissions" `
        -Headers $headers `
        -Method  GET `
        -ErrorAction Stop

    $permId = ($existing.value |
               Where-Object { $_.grantedToIdentitiesV2.application.id -eq $targetAppId } |
               Select-Object -First 1).id

    if ($permId) {
        $updateBody = @{ roles = @("fullcontrol") } | ConvertTo-Json   # New permission level

        Invoke-RestMethod `
            -Uri     "https://graph.microsoft.com/v1.0/sites/$siteId/permissions/$permId" `
            -Headers $headers `
            -Method  PATCH `
            -Body    $updateBody `
            -ErrorAction Stop | Out-Null

        Write-Host "  Updated to [fullcontrol] — Permission ID: $permId" -ForegroundColor Green
    }
    else {
        Write-Host "  No matching permission found for AppId: $targetAppId" -ForegroundColor Yellow
    }
}
catch {
    Write-Host "  Update FAILED — $_" -ForegroundColor Red
}

# =============================================================================
# SECTION 7 — REVOKE: Delete a permission (requires Permission ID)
# =============================================================================

Write-Host "`n[Revoke] Revoking permission..." -ForegroundColor Cyan

try {
    # Find the Permission ID for the target app
    $existing = Invoke-RestMethod `
        -Uri     "https://graph.microsoft.com/v1.0/sites/$siteId/permissions" `
        -Headers $headers `
        -Method  GET `
        -ErrorAction Stop

    $permId = ($existing.value |
               Where-Object { $_.grantedToIdentitiesV2.application.id -eq $targetAppId } |
               Select-Object -First 1).id

    if ($permId) {
        Invoke-RestMethod `
            -Uri     "https://graph.microsoft.com/v1.0/sites/$siteId/permissions/$permId" `
            -Headers $headers `
            -Method  DELETE `
            -ErrorAction Stop | Out-Null

        Write-Host "  Revoked — Permission ID: $permId" -ForegroundColor Green
    }
    else {
        Write-Host "  No matching permission found for AppId: $targetAppId" -ForegroundColor Yellow
    }
}
catch {
    Write-Host "  Revoke FAILED — $_" -ForegroundColor Red
}

# =============================================================================
Write-Host "`n[Done] Graph REST API Sites.Selected script completed." -ForegroundColor Cyan
# =============================================================================

Option 3 — Microsoft Graph PowerShell SDK

Official Microsoft SDK approach. Uses Microsoft.Graph module cmdlets instead of raw REST calls.

# =============================================================================
# SITES.SELECTED — Option 3: Microsoft Graph PowerShell SDK
# Purpose  : Grant / View / Update / Revoke app permissions using Graph SDK cmdlets
# Module   : Microsoft.Graph
# Requires : Sites.FullControl.All (to manage permissions)
#            Target app with Sites.Selected (the app being granted access)
# Ref      : https://learn.microsoft.com/en-us/powershell/module/microsoft.graph.sites
# =============================================================================

# ── CONFIGURATION — update these values before running ───────────────────────

$tenantId       = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"   # Your Entra ID Tenant ID
$operatorAppId  = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"   # App used to connect (needs Sites.FullControl.All)
$certThumbprint = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" # Certificate thumbprint for app-only auth

$targetAppId    = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"   # App being granted Sites.Selected access
$targetAppName  = "MyAppName"                               # Display label for the target app

$siteUrl        = "https://<tenant>.sharepoint.com/sites/SiteName"  # Target site URL
$permissionRole = "write"                                             # read | write | manage | fullcontrol

# Bulk sites — used in Section 5
$bulkSites = @(
    "https://<tenant>.sharepoint.com/sites/Site1",
    "https://<tenant>.sharepoint.com/sites/Site2",
    "https://<tenant>.sharepoint.com/sites/Site3"
)

# =============================================================================
# SECTION 1 — MODULE SETUP
# Pattern : Install if not present → always Import-Module to load into session
# =============================================================================

$moduleName = "Microsoft.Graph"

Write-Host "`n[Module] $moduleName" -ForegroundColor Cyan

# Step 1: Install if not already present
$installed = Get-Module -Name $moduleName -ListAvailable |
             Sort-Object Version -Descending |
             Select-Object -First 1

if (-not $installed) {
    Write-Host "  Install : Not found — installing from PSGallery..." -ForegroundColor Yellow
    try {
        Install-Module -Name $moduleName `
                       -Scope CurrentUser `
                       -Force `
                       -AllowClobber `
                       -ErrorAction Stop

        $installed = Get-Module -Name $moduleName -ListAvailable |
                     Sort-Object Version -Descending |
                     Select-Object -First 1

        Write-Host "  Install : Done — v$($installed.Version)" -ForegroundColor Green
    }
    catch {
        Write-Host "  Install : FAILED — $_" -ForegroundColor Red
        exit 1
    }
}
else {
    Write-Host "  Install : Already present — v$($installed.Version)" -ForegroundColor Green
}

# Step 2: Always import into the current session
try {
    Import-Module -Name $moduleName -Force -ErrorAction Stop
    Write-Host "  Import  : Loaded into session" -ForegroundColor Green
}
catch {
    Write-Host "  Import  : FAILED — $_" -ForegroundColor Red
    exit 1
}

# =============================================================================
# SECTION 2 — CONNECT (App-only with Certificate)
# =============================================================================

Write-Host "`n[Connect] Connecting to Microsoft Graph..." -ForegroundColor Cyan

try {
    Connect-MgGraph `
        -TenantId             $tenantId `
        -ClientId             $operatorAppId `
        -CertificateThumbprint $certThumbprint `
        -NoWelcome `
        -ErrorAction Stop

    Write-Host "  Connected to Microsoft Graph" -ForegroundColor Green
}
catch {
    Write-Host "  Connection FAILED — $_" -ForegroundColor Red
    exit 1
}

# =============================================================================
# SECTION 3 — GET SITE ID from site URL
# =============================================================================

Write-Host "`n[Site] Resolving Site ID for: $siteUrl" -ForegroundColor Cyan

try {
    $uri      = [System.Uri]$siteUrl
    $hostname = $uri.Host
    $sitePath = $uri.AbsolutePath

    # Get-MgSite resolves site by hostname:path format
    $site   = Get-MgSite -SiteId "${hostname}:${sitePath}" -ErrorAction Stop
    $siteId = $site.Id

    Write-Host "  Site ID: $siteId" -ForegroundColor Green
}
catch {
    Write-Host "  Failed to resolve Site ID — $_" -ForegroundColor Red
    exit 1
}

# =============================================================================
# SECTION 4 — GRANT: Single Site
# =============================================================================

Write-Host "`n[Grant] Granting permission on: $siteUrl" -ForegroundColor Cyan

try {
    $grantedIdentity = @{
        Application = @{
            Id          = $targetAppId      # App being granted access
            DisplayName = $targetAppName    # Label only
        }
    }

    $newPerm = New-MgSitePermission `
        -SiteId                  $siteId `
        -Roles                   @($permissionRole) `   # read | write | manage | fullcontrol
        -GrantedToIdentities     @($grantedIdentity) `
        -ErrorAction Stop

    Write-Host "  Granted [$permissionRole] — Permission ID: $($newPerm.Id)" -ForegroundColor Green
}
catch {
    Write-Host "  Grant FAILED — $_" -ForegroundColor Red
}

# =============================================================================
# SECTION 5 — GRANT: Bulk Sites
# =============================================================================

Write-Host "`n[Grant] Bulk site permissions..." -ForegroundColor Cyan

foreach ($url in $bulkSites) {
    try {
        $u   = [System.Uri]$url
        $s   = Get-MgSite -SiteId "$($u.Host):$($u.AbsolutePath)" -ErrorAction Stop
        $sid = $s.Id

        New-MgSitePermission `
            -SiteId              $sid `
            -Roles               @($permissionRole) `
            -GrantedToIdentities @(@{ Application = @{ Id = $targetAppId; DisplayName = $targetAppName } }) `
            -ErrorAction Stop | Out-Null

        Write-Host "  Granted [$permissionRole] → $url" -ForegroundColor Green
    }
    catch {
        Write-Host "  FAILED  → $url — $_" -ForegroundColor Red
    }
}

# =============================================================================
# SECTION 6 — VIEW: List all permissions on a site
# =============================================================================

Write-Host "`n[View] Listing permissions on: $siteUrl" -ForegroundColor Cyan

try {
    $perms = Get-MgSitePermission -SiteId $siteId -ErrorAction Stop
    $perms | ForEach-Object {
        Write-Host "  ID   : $($_.Id)" -ForegroundColor White
        Write-Host "  Roles: $($_.Roles -join ', ')" -ForegroundColor DarkCyan
        Write-Host "  App  : $($_.GrantedToIdentitiesV2.Application.DisplayName)" -ForegroundColor DarkGray
        Write-Host ""
    }
}
catch {
    Write-Host "  View FAILED — $_" -ForegroundColor Red
}

# =============================================================================
# SECTION 7 — UPDATE: Change permission level on an existing assignment
# =============================================================================

Write-Host "`n[Update] Updating permission..." -ForegroundColor Cyan

try {
    # Find the Permission ID for the target app
    $perms  = Get-MgSitePermission -SiteId $siteId -ErrorAction Stop
    $permId = ($perms |
               Where-Object { $_.GrantedToIdentitiesV2.Application.Id -eq $targetAppId } |
               Select-Object -First 1).Id

    if ($permId) {
        Update-MgSitePermission `
            -SiteId       $siteId `
            -PermissionId $permId `
            -Roles        @("fullcontrol") `   # New permission level
            -ErrorAction Stop

        Write-Host "  Updated to [fullcontrol] — Permission ID: $permId" -ForegroundColor Green
    }
    else {
        Write-Host "  No matching permission found for AppId: $targetAppId" -ForegroundColor Yellow
    }
}
catch {
    Write-Host "  Update FAILED — $_" -ForegroundColor Red
}

# =============================================================================
# SECTION 8 — REVOKE: Remove a permission
# =============================================================================

Write-Host "`n[Revoke] Revoking permission..." -ForegroundColor Cyan

try {
    # Find the Permission ID for the target app
    $perms  = Get-MgSitePermission -SiteId $siteId -ErrorAction Stop
    $permId = ($perms |
               Where-Object { $_.GrantedToIdentitiesV2.Application.Id -eq $targetAppId } |
               Select-Object -First 1).Id

    if ($permId) {
        Remove-MgSitePermission `
            -SiteId       $siteId `
            -PermissionId $permId `
            -ErrorAction Stop

        Write-Host "  Revoked — Permission ID: $permId" -ForegroundColor Green
    }
    else {
        Write-Host "  No matching permission found for AppId: $targetAppId" -ForegroundColor Yellow
    }
}
catch {
    Write-Host "  Revoke FAILED — $_" -ForegroundColor Red
}

# =============================================================================
Write-Host "`n[Done] Graph SDK Sites.Selected script completed." -ForegroundColor Cyan
# =============================================================================

Approach Comparison

Option 1 — PnP PowerShell Option 2 — Graph REST API Option 3 — Graph SDK
Module required PnP.PowerShell None Microsoft.Graph
Cmdlet style Dedicated SPO cmdlets Raw Invoke-RestMethod Graph SDK cmdlets
Auth support Interactive + App-only App-only (token) Interactive + App-only
Complexity Low Medium Medium
Best for Day-to-day admin tasks No-module / CI-CD pipelines Microsoft-native SDK preference

Required Admin Roles

Action Minimum Role Required
Add API permission in Entra ID Global Administrator
Grant admin consent Global Administrator
Run Grant-PnPEntraIDAppSitePermission (Option 1) SharePoint Administrator or Global Administrator
Call Graph /sites/{id}/permissions (Option 2 & 3) App or delegated token with Sites.FullControl.All

Quick Summary

Step 1 — Azure Portal (all options)
  App Registration → API Permissions
  → Microsoft Graph → Sites.Selected → Admin Consent

Step 2 — Choose one option:

  Option 1 — PnP PowerShell (Recommended)
    Module  : PnP.PowerShell
    Grant   : Grant-PnPEntraIDAppSitePermission  -AppId ... -Site ... -Permissions Write
    View    : Get-PnPEntraIDAppSitePermission    -Site ...
    Update  : Set-PnPEntraIDAppSitePermission    -Site ... -PermissionId ... -Permissions FullControl
    Revoke  : Revoke-PnPEntraIDAppSitePermission -Site ... -PermissionId ...

  Option 2 — Graph REST API (No module)
    Grant   : POST   /v1.0/sites/{id}/permissions
    View    : GET    /v1.0/sites/{id}/permissions
    Update  : PATCH  /v1.0/sites/{id}/permissions/{permissionId}
    Revoke  : DELETE /v1.0/sites/{id}/permissions/{permissionId}

  Option 3 — Graph SDK
    Module  : Microsoft.Graph
    Grant   : New-MgSitePermission    -SiteId ... -Roles ... -GrantedToIdentities ...
    View    : Get-MgSitePermission    -SiteId ...
    Update  : Update-MgSitePermission -SiteId ... -PermissionId ... -Roles ...
    Revoke  : Remove-MgSitePermission -SiteId ... -PermissionId ...


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