Identity Bytes // IB-ENTRA-SEC Track
Intermediate Lab 11 of 12 Est. 110 minutes

PowerShell Automation and Reporting at Scale

Every earlier lab produced a useful script, but they are scattered, single-use and hard to hand over. This lab consolidates them into one maintainable PowerShell module with consistent connection handling, throttling-aware queries, structured output and a branded HTML dashboard, then schedules the whole thing to run unattended and deliver itself to stakeholders.

Section 1

Lab Metadata

Lab ID
IB-ENTRA-SEC-11
Difficulty
Intermediate
Scenario Org
Northgate Financial
Estimated Time
110 minutes

Core technologies

PowerShell modules Module manifest Microsoft Graph SDK Throttling and retry HTML reporting Azure Automation Managed identity
Section 2

Scenario and Description

IN PLAIN TERMS Every earlier lab produced a useful one-off report, the way a shop assistant might scribble a stock count on a scrap of paper each time you ask. That works once, but it does not scale and nobody else can repeat it reliably. This lab turns those scraps into a proper till system: a single, reusable set of buttons that anyone on the team can press to get the same clean report every time, on a schedule, formatted for the board, with the counting logic written down once and maintained in one place instead of copied around.

Across this track you have written assessment scripts in Lab 01 (identity baseline), Lab 05 (domain risk), Lab 08 (hygiene) and Lab 10 (risk). Each works, but each lives in its own file, connects to Graph its own way, formats its output differently, and depends on someone remembering how to run it. That is exactly how useful automation quietly rots: it becomes tribal knowledge that leaves when its author does.

Production-grade automation is not more scripts, it is fewer, better organised ones. This lab packages the assessments into a single PowerShell module, IdentityBytes.EntraSec, with a manifest, versioning, one connection function, throttling-aware queries, and functions that return clean structured objects rather than console text. On top of that you build a consolidated report function that produces a branded HTML dashboard, then schedule it to run unattended with a managed identity and deliver the report to stakeholders.

This is what the brief means by "automating administration and reporting through PowerShell": not ad hoc snippets, but a maintainable toolkit that anyone on the team can install, run, read and extend.

Section 3

Prerequisites

Prior labs

Labs 01, 05, 08 and 10 recommended. Their assessment logic is what you refactor into module functions here. Lab 06's managed-identity runbook pattern is reused for scheduling. No new licensing beyond the P2 trial.

Skills assumed

Comfort running the Graph PowerShell scripts from earlier labs. This lab introduces module structure and packaging, explained from first principles as it is used.

INFO: term check A PowerShell module is a packaged set of related functions plus a manifest that describes them. Installing a module makes its functions available by name, like Get-IBIdentityBaseline, without anyone needing the original script file. It is the difference between a loose recipe card and a published cookbook.
Section 4

Real-World Problem Statement

Scattered scripts do not survive contact with a real team. They drift out of date, each connects with different permissions, their output cannot be compared week to week, and only the author knows how to run them. Turning them into a versioned module with consistent behaviour is what makes the automation an asset rather than a liability.

DimensionWhy this matters
RiskInconsistent scripts request inconsistent, often excessive, permissions. A single connection function enforces least privilege in one place.
ComplianceRepeatable, scheduled, versioned reporting is the evidence auditors want, produced the same way every time.
ProductivityOne installable toolkit replaces a folder of tribal snippets, and new team members are productive immediately.
Security postureConsistent, comparable metrics turn one-off checks into a trend the programme board can track.

Concrete scenario: Northgate's security team wants a weekly identity posture report, produced automatically, delivered to the CISO, and generated by tooling any team member can run on demand and extend, not a script only one engineer understands.

Section 5

Skills Mapped to Production Solutions

Skill learned in this labReal-world enterprise application
Structuring a PowerShell module with a manifestDistributable, versioned tooling shared across a team
Centralising connection and permissionsConsistent least-privilege access for all reporting
Handling Graph throttling gracefullyReliable reporting against large tenants without failures
Emitting structured objectsOutput that pipes cleanly to CSV, JSON, HTML or a SIEM
Generating and scheduling a branded reportAutomated stakeholder reporting with no manual effort
Section 6

Architecture Overview

The toolkit is one module. A single connection function authenticates, public functions gather each assessment as structured objects, and a report function consolidates them into a branded HTML dashboard. A scheduled runbook runs the whole thing unattended.

Schedule weekly runbook managed identity IdentityBytes.EntraSec Connect-IBGraph Get-IBIdentityBaseline Get-IBDomainRisk Get-IBHygieneReport Get-IBPrivilegedReport New-IBSecurityReport Microsoft Graph retry on throttling Branded HTML report CSV / JSON exports emailed to stakeholders
PRODUCTION CONSIDERATION Keep every function read-only in a reporting module, and put anything that changes state (like the disablement from Lab 06) in a separate, clearly named module with SupportsShouldProcess so it honours -WhatIf. Mixing read and write in one toolkit is how a reporting job accidentally becomes a change job.
Section 7

Step-by-Step Implementation

Phase A - Lay out the module

1Create the folder structure and manifest

Purpose: give the toolkit a proper, versioned shape that anyone can install.

Scaffold the module and write the manifest
# Folder layout:
#   IdentityBytes.EntraSec\
#     IdentityBytes.EntraSec.psd1   (manifest)
#     IdentityBytes.EntraSec.psm1   (root: loads and exports functions)
#     Public\   (exported functions, one per file)
#     Private\  (internal helpers)

$root = ".\IdentityBytes.EntraSec"
New-Item -ItemType Directory -Path "$root\Public","$root\Private" -Force | Out-Null

New-ModuleManifest -Path "$root\IdentityBytes.EntraSec.psd1" `
  -RootModule 'IdentityBytes.EntraSec.psm1' `
  -ModuleVersion '1.0.0' `
  -Author 'Identity Bytes' `
  -Description 'Entra ID security assessment and reporting toolkit' `
  -PowerShellVersion '7.4' `
  -RequiredModules @('Microsoft.Graph.Authentication','Microsoft.Graph.Users',
                     'Microsoft.Graph.Reports','Microsoft.Graph.Identity.DirectoryManagement') `
  -FunctionsToExport @('Connect-IBGraph','Get-IBIdentityBaseline','Get-IBDomainRisk',
                       'Get-IBHygieneReport','Get-IBPrivilegedReport','New-IBSecurityReport')
The root module (.psm1): load and export
# IdentityBytes.EntraSec.psm1
# Dot-source every function file, then export only the public ones.
$public  = @(Get-ChildItem -Path "$PSScriptRoot\Public\*.ps1"  -ErrorAction SilentlyContinue)
$private = @(Get-ChildItem -Path "$PSScriptRoot\Private\*.ps1" -ErrorAction SilentlyContinue)

foreach ($file in @($public + $private)) {
  try { . $file.FullName }
  catch { Write-Error "Failed to import $($file.FullName): $_" }
}

Export-ModuleMember -Function $public.BaseName
VERIFICATION Test-ModuleManifest "$root\IdentityBytes.EntraSec.psd1" returns the module details with no error. The two subfolders exist and are empty, ready for functions.

Phase B - Centralise connection and throttling

REAL WORLD ANALOGYThrottling is the busy shop limiting how many customers reach the till at once. Microsoft's servers do the same when a script fires thousands of requests. The retry helper is the polite customer who waits the stated time and rejoins the queue, rather than hammering on the door and being ejected. Better still is needing fewer trips: one big trolley run (a bulk query) instead of a thousand single-item visits.

2One connection function and a retry helper

Purpose: connect the same way every time with least-privilege scopes, and survive Graph throttling on large tenants.

Public\Connect-IBGraph.ps1
function Connect-IBGraph {
  <#
  .SYNOPSIS Connect to Microsoft Graph with the toolkit's least-privilege read scopes.
  .DESCRIPTION Supports interactive sign-in for ad hoc use and managed identity for runbooks.
  #>
  [CmdletBinding()]
  param(
    [ValidateSet('Interactive','ManagedIdentity')] [string]$Method = 'Interactive'
  )
  $scopes = @('User.Read.All','AuditLog.Read.All','Directory.Read.All',
              'RoleManagement.Read.Directory','Domain.Read.All')
  if ($Method -eq 'ManagedIdentity') { Connect-MgGraph -Identity -NoWelcome }
  else                               { Connect-MgGraph -Scopes $scopes -NoWelcome }
  Get-MgContext | Select-Object Account, TenantId
}
Private\Invoke-IBWithRetry.ps1
function Invoke-IBWithRetry {
  # Retry a scriptblock on HTTP 429 (throttling), honouring Retry-After.
  # The Graph SDK auto-retries many calls; use this around raw or bulk loops.
  param([scriptblock]$Script, [int]$MaxRetries = 5)
  $attempt = 0
  while ($true) {
    try { return & $Script }
    catch {
      $attempt++
      $status = $_.Exception.Response.StatusCode.value__
      if ($attempt -ge $MaxRetries -or $status -ne 429) { throw }
      $wait = 5
      $ra = $_.Exception.Response.Headers.RetryAfter.Delta
      if ($ra) { $wait = [int]$ra.TotalSeconds }
      Write-Verbose "Throttled. Waiting $wait s (attempt $attempt/$MaxRetries)."
      Start-Sleep -Seconds $wait
    }
  }
}
INFO: query efficiently to avoid throttling in the first place The cheapest throttling fix is fewer, leaner calls. Pull users once with Get-MgUser -All -Property naming only the fields you need, rather than looping Get-MgUser per user. The registration report already returns one row per user, so join to it in memory rather than calling Graph again.

Phase C - Wrap the assessments as functions

REAL WORLD ANALOGYA script that prints text to the screen produces loose boxes only a human can carry. A function that returns structured objects loads the same goods onto standard pallets, and now any forklift can pick them up: export to a spreadsheet, feed a dashboard, ship to the security monitoring system. This one change, returning data instead of printing it, is what turns a personal script into team tooling.

3Refactor the earlier scripts to return structured objects

Purpose: turn console-printing scripts into functions whose output pipes cleanly anywhere.

Public\Get-IBIdentityBaseline.ps1 (from Lab 01)
function Get-IBIdentityBaseline {
  <# .SYNOPSIS Summarise MFA, SSPR, dormancy and guests as one object. #>
  [CmdletBinding()] param([int]$DormantDays = 90)

  $reg   = Get-MgReportAuthenticationMethodUserRegistrationDetail -All
  $users = Get-MgUser -All -Property id,userType,accountEnabled,signInActivity,createdDateTime
  $cutoff = (Get-Date).AddDays(-$DormantDays)

  [pscustomobject]@{
    GeneratedUtc   = (Get-Date).ToUniversalTime().ToString('u')
    TotalUsers     = $users.Count
    MfaCapable     = ($reg | Where-Object IsMfaCapable).Count
    MfaCapablePct  = [math]::Round((($reg | Where-Object IsMfaCapable).Count / [math]::Max($users.Count,1))*100)
    SsprRegistered = ($reg | Where-Object IsSsprRegistered).Count
    Dormant        = ($users | Where-Object { $_.AccountEnabled -and $_.UserType -eq 'Member' -and
                       ($null -eq $_.SignInActivity.LastSignInDateTime -or
                        $_.SignInActivity.LastSignInDateTime -lt $cutoff) }).Count
    Guests         = ($users | Where-Object { $_.UserType -eq 'Guest' }).Count
  }
}
Public\Get-IBPrivilegedReport.ps1 (from Labs 01 and 07)
function Get-IBPrivilegedReport {
  <# .SYNOPSIS List active directory-role holders. #>
  [CmdletBinding()] param()
  foreach ($role in (Get-MgDirectoryRole -All)) {
    foreach ($m in (Get-MgDirectoryRoleMember -DirectoryRoleId $role.Id -All)) {
      [pscustomobject]@{
        Role       = $role.DisplayName
        MemberType = ($m.AdditionalProperties['@odata.type'] -replace '#microsoft.graph.','')
        Upn        = $m.AdditionalProperties['userPrincipalName']
      }
    }
  }
}
VERIFICATION
Import-Module "$root\IdentityBytes.EntraSec.psd1" -Force
Connect-IBGraph
Get-IBIdentityBaseline        # returns one clean object
Get-IBPrivilegedReport | Format-Table
# Both work by name, no original script file needed.

What just happened? The same logic from earlier labs now lives behind named commands that emit objects. Because they return data rather than print it, you can pipe them to Export-Csv, ConvertTo-Json, a dashboard, or a SIEM, without rewriting anything.

Phase D - Consolidate into a branded report

4Generate an HTML dashboard in Identity Bytes style

Purpose: produce a single stakeholder-ready report from all the assessments.

Public\New-IBSecurityReport.ps1
function New-IBSecurityReport {
  <# .SYNOPSIS Build a branded HTML posture report from all assessments. #>
  [CmdletBinding()]
  param([string]$OutputPath = ".\IB-Entra-Posture-$(Get-Date -f yyyyMMdd).html")

  $base = Get-IBIdentityBaseline
  $priv = @(Get-IBPrivilegedReport)

  $card = {
    param($label,$value) "<div class='c'><div class='v'>$value</div><div class='l'>$label</div></div>"
  }

  $html = @"
<!DOCTYPE html><html><head><meta charset='utf-8'>
<style>
 body{font-family: "Segoe UI", -apple-system, BlinkMacSystemFont, system-ui, Roboto, "Helvetica Neue", Arial, sans-serif;background:#ffffff;color:#1b2125;margin:0}
 header{background:#161616;color:#ffffff;padding:34px 26px;border-bottom:3px solid #0d8ff2}
 header .b{color:#0d8ff2;letter-spacing:.2em;font-size:12px;text-transform:uppercase}
 main{max-width:900px;margin:0 auto;padding:24px}
 .grid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px}
 .c{background:#faf9f8;border:1px solid #e6ebef;border-radius:12px;padding:16px;text-align:center}
 .v{font-size:28px;font-weight:800;color:#2f78b0}.l{font-size:12px;color:#3b3a39;margin-top:4px}
 table{width:100%;border-collapse:collapse;margin-top:20px;font-size:14px}
 th,td{border:1px solid #e6ebef;padding:8px 10px;text-align:left}th{background:#faf9f8}
</style></head><body>
<header><div class='b'>Identity Bytes // Northgate Financial</div>
<h1>Entra ID Posture Report</h1><div>Generated $($base.GeneratedUtc)</div></header>
<main>
 <div class='grid'>
  $(& $card 'Total users' $base.TotalUsers)
  $(& $card 'MFA-capable %' "$($base.MfaCapablePct)%")
  $(& $card 'Dormant (90d)' $base.Dormant)
  $(& $card 'Privileged holders' $priv.Count)
 </div>
 <h2>Privileged role holders</h2>
 <table><tr><th>Role</th><th>Type</th><th>User</th></tr>
 $(($priv | ForEach-Object { "<tr><td>$($_.Role)</td><td>$($_.MemberType)</td><td>$($_.Upn)</td></tr>" }) -join "`n")
 </table>
</main></body></html>
"@

  $html | Out-File -FilePath $OutputPath -Encoding utf8
  # Also emit machine-readable exports alongside the report.
  $base | ConvertTo-Json | Out-File "$($OutputPath -replace '\.html$','.json')" -Encoding utf8
  Write-Host "Report written to $OutputPath"
}
VERIFICATION Running New-IBSecurityReport produces a white-body, dark-hero HTML dashboard with metric cards and a privileged-holders table, plus a JSON export. Open it in a browser to confirm it renders on brand.

Phase E - Schedule it unattended

REAL WORLD ANALOGYThe finished state is a utility meter that reads itself and submits its own figures. Every week the report generates and delivers itself, produced identically each time by versioned tooling, with the robot's built-in badge (managed identity) and read-only permissions so the reporting job can never accidentally become a change job. Nobody has to remember anything, which is the only kind of process that survives staff turnover.

5Run the report weekly with a managed identity

Purpose: deliver the report automatically, with no secret and no human.

Runbook wrapper (reuses the Lab 06 managed-identity pattern)
# In an Azure Automation runbook (PowerShell 7.x), with the module imported
# into the Automation account and the managed identity granted the READ
# Graph permissions (User.Read.All, AuditLog.Read.All, Directory.Read.All,
# RoleManagement.Read.Directory, Domain.Read.All):

Import-Module IdentityBytes.EntraSec
Connect-IBGraph -Method ManagedIdentity

$report = "$env:TEMP\IB-Entra-Posture-$(Get-Date -f yyyyMMdd).html"
New-IBSecurityReport -OutputPath $report

# Deliver it: email via Graph sendMail (needs Mail.Send app permission),
# or upload to SharePoint / Azure Blob for the team to collect.
# Link a weekly schedule to this runbook in the Automation account.
PRODUCTION CONSIDERATION Store the module in source control and publish versioned releases to a private repository (an internal PowerShell gallery or Azure Artifacts feed), so the Automation account and every engineer install the same tested version. Ad hoc copies on individual machines are how drift and inconsistent results creep back in.
Section 8

Testing and Validation

  1. Manifest: Test-ModuleManifest passes with no error.
  2. Import: Import-Module exposes all six functions (Get-Command -Module IdentityBytes.EntraSec).
  3. Functions: each assessment function returns objects, not just console text, confirm by piping to Get-Member.
  4. Report: New-IBSecurityReport generates a branded HTML file and a JSON export.
  5. Unattended: the runbook runs with the managed identity and produces the report with no interactive sign-in.
SymptomCauseResolution
Functions not found after importFiles not in Public, or names not exportedConfirm the .ps1 files are under Public and the manifest exports them
Throttling errors on a large tenantToo many per-object callsUse bulk -All -Property queries and wrap loops in the retry helper
Report renders unbranded or brokenHere-string escaping of HTMLKeep CSS in the here-string and test the output in a browser
Runbook cannot connectManaged identity lacks read permissionsGrant the five read Graph app roles to the identity
Section 9

Security Analysis

What makes this sound

Intentionally simplified for the lab

Production hardening

Section 10

Cleanup Instructions

Remove the imported module and generated reports
Remove-Module IdentityBytes.EntraSec -ErrorAction SilentlyContinue
# Delete generated report artefacts if you do not want to keep them.
Remove-Item ".\IB-Entra-Posture-*.html",".\IB-Entra-Posture-*.json" -ErrorAction SilentlyContinue
Disconnect-MgGraph
INFO: preserve for the track Keep the module, Lab 12 uses its report output as the raw material for the remediation roadmap and Secure Score narrative.
Section 12

Key Takeaways and Next Lab

Identity Bytes // IB-ENTRA-SEC Track // Lab 11 of 12. British English. For lab and training use against a disposable tenant only.