Lab Metadata
Core technologies
Scenario and Description
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.
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.
Get-IBIdentityBaseline, without anyone needing the original script file. It is the difference between a loose recipe card and a published cookbook.
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.
| Dimension | Why this matters |
|---|---|
| Risk | Inconsistent scripts request inconsistent, often excessive, permissions. A single connection function enforces least privilege in one place. |
| Compliance | Repeatable, scheduled, versioned reporting is the evidence auditors want, produced the same way every time. |
| Productivity | One installable toolkit replaces a folder of tribal snippets, and new team members are productive immediately. |
| Security posture | Consistent, 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.
Skills Mapped to Production Solutions
| Skill learned in this lab | Real-world enterprise application |
|---|---|
| Structuring a PowerShell module with a manifest | Distributable, versioned tooling shared across a team |
| Centralising connection and permissions | Consistent least-privilege access for all reporting |
| Handling Graph throttling gracefully | Reliable reporting against large tenants without failures |
| Emitting structured objects | Output that pipes cleanly to CSV, JSON, HTML or a SIEM |
| Generating and scheduling a branded report | Automated stakeholder reporting with no manual effort |
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.
SupportsShouldProcess so it honours -WhatIf. Mixing read and write in one toolkit is how a reporting job accidentally becomes a change job.
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
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
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
}
}
}
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
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']
}
}
}
}
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:Lato,Verdana,sans-serif;background:#fff;color:#1b1b25;margin:0}
header{background:#0a0a0f;color:#fff;padding:34px 26px;border-bottom:3px solid #00fff9}
header .b{color:#00fff9;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:#f6f7fb;border:1px solid #e6e6ef;border-radius:12px;padding:16px;text-align:center}
.v{font-size:28px;font-weight:800;color:#6a2fb0}.l{font-size:12px;color:#55566a;margin-top:4px}
table{width:100%;border-collapse:collapse;margin-top:20px;font-size:14px}
th,td{border:1px solid #e6e6ef;padding:8px 10px;text-align:left}th{background:#f6f7fb}
</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"
}
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
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.
Testing and Validation
- Manifest:
Test-ModuleManifestpasses with no error. - Import:
Import-Moduleexposes all six functions (Get-Command -Module IdentityBytes.EntraSec). - Functions: each assessment function returns objects, not just console text, confirm by piping to
Get-Member. - Report:
New-IBSecurityReportgenerates a branded HTML file and a JSON export. - Unattended: the runbook runs with the managed identity and produces the report with no interactive sign-in.
| Symptom | Cause | Resolution |
|---|---|---|
| Functions not found after import | Files not in Public, or names not exported | Confirm the .ps1 files are under Public and the manifest exports them |
| Throttling errors on a large tenant | Too many per-object calls | Use bulk -All -Property queries and wrap loops in the retry helper |
| Report renders unbranded or broken | Here-string escaping of HTML | Keep CSS in the here-string and test the output in a browser |
| Runbook cannot connect | Managed identity lacks read permissions | Grant the five read Graph app roles to the identity |
Security Analysis
What makes this sound
- Single least-privilege door: one connection function means one place that defines exactly which read permissions the toolkit uses.
- Read-only by design: the reporting module never changes state, so it cannot cause an incident even if scheduled and forgotten.
- Reproducible evidence: versioned, scheduled output gives auditors identical reports run the same way each time.
Intentionally simplified for the lab
- Delivery (email or storage) is described rather than fully wired, it depends on your environment.
- The report shows a core set of metrics, extend it with the domain, hygiene and risk functions as your board's needs grow.
Production hardening
- Keep write actions in a separate module with
SupportsShouldProcessso reporting and change are never confused. - Publish signed, versioned module releases and pin the Automation account to a known version.
- Protect the report output and its delivery channel, posture reports reveal where your weaknesses are.
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
Recommended Learning Links
Key Takeaways and Next Lab
- Production automation is fewer, better organised scripts, packaged as a versioned module.
- Centralise connection and permissions so least privilege is defined once.
- Return structured objects, not console text, so output pipes anywhere.
- Keep reporting read-only, and schedule it unattended with a managed identity.
Identity Bytes // IB-ENTRA-SEC Track // Lab 11 of 12. British English. For lab and training use against a disposable tenant only.