
Graph API for Architects: The Endpoints That Actually Matter
A curated guide to the Microsoft Graph endpoints that solve real architectural problems — discovery, identity auditing, security posture, governance, and licensing. With working PowerShell scripts for every section.
Every Microsoft 365 engagement I've worked starts with the same five questions:
- What do we have? — Discovery
- Who can access what? — Identity and access
- Is it secure? — Security posture
- Is it compliant? — Governance
- What's it costing us? — Licensing and usage
The admin portals will give you partial answers to each of these, spread across half a dozen consoles, none of which talk to each other properly. You'll click through Entra, Intune, Defender, Purview, and the M365 Admin Centre, copying data into spreadsheets, trying to build a picture that should have taken twenty minutes but takes the better part of a day.
Graph API answers all five questions from a single interface. Not as a developer tool — as an architectural one. The difference matters: developers build integrations, architects extract insight. The endpoints are the same; the intent is different.
This post maps the Graph endpoints that matter for each of those five questions, with working PowerShell that produces deliverables you can hand to a client, a security team, or a board. Every script is concise for readability — the companion repository has production-ready versions with proper parameterisation and error handling.
Authentication Setup
You need an Entra ID app registration with certificate-based auth. The minimum viable connection:
# App-only auth (certificate) — preferred for automation
Connect-MgGraph -ClientId $appId -TenantId $tenantId -CertificateThumbprint $thumbprint
# Or interactive auth for ad-hoc work (delegated)
Connect-MgGraph -Scopes "User.Read.All","Group.Read.All","Device.Read.All",
"DeviceManagementManagedDevices.Read.All","Policy.Read.All",
"Directory.Read.All","SecurityEvents.Read.All","Reports.Read.All",
"AuditLog.Read.All"The scopes above cover everything in this post. For app-only auth, grant these as application permissions via admin consent. They're all read-only — you're not changing anything.
Discovery: What Do We Have?
The first thing any solution architect needs is a clear picture of the estate. Not the org chart version — the actual, ground-truth inventory of users, devices, groups, and applications. The admin portals show you each of these in isolation. Graph lets you pull the lot in one pass.
Key Endpoints
| Endpoint | Purpose | Architectural Value |
|---|---|---|
GET /users | Active, disabled, and guest users with licence and sign-in data | User scoping for design decisions |
GET /groups | Dynamic vs. assigned, M365 vs. security groups | Group strategy assessment |
GET /devices | Join type (Entra joined, hybrid, registered) and OS distribution | Device estate profiling |
GET /deviceManagement/managedDevices | Intune-enrolled devices with compliance state | Enrollment gap analysis |
GET /deviceManagement/mobileApps | Win32, MSIX, WinGet, Store deployments | Application estate inventory |
GET /deviceManagement/detectedApps | Actually installed software across managed devices | Shadow IT detection |
The gap between /devices (Entra registered) and /deviceManagement/managedDevices (Intune enrolled) is one of the most useful things you can surface early in an engagement. Devices that appear in Entra but not in Intune are unmanaged — and in most environments, nobody knows how many there are until you count them.
Deliverable: One-Page Tenant Summary
# Pull core counts
$users = Get-MgUser -All -Property "Id,UserType,AccountEnabled" -ConsistencyLevel eventual -CountVariable userCount
$groups = Get-MgGroup -All -Property "Id,GroupTypes,SecurityEnabled,MailEnabled"
$devices = Get-MgDevice -All -Property "Id,OperatingSystem,TrustType"
$managedDevices = Get-MgDeviceManagementManagedDevice -All -Property "Id,OperatingSystem,ComplianceState"
# User breakdown
$members = ($users | Where-Object { $_.UserType -eq "Member" }).Count
$guests = ($users | Where-Object { $_.UserType -eq "Guest" }).Count
$disabled = ($users | Where-Object { -not $_.AccountEnabled }).Count
# Group breakdown
$m365Groups = ($groups | Where-Object { $_.GroupTypes -contains "Unified" }).Count
$securityGroups = ($groups | Where-Object { $_.SecurityEnabled -and $_.GroupTypes -notcontains "Unified" }).Count
$dynamicGroups = ($groups | Where-Object { $_.GroupTypes -contains "DynamicMembership" }).Count
# Device breakdown
$entraJoined = ($devices | Where-Object { $_.TrustType -eq "AzureAd" }).Count
$hybridJoined = ($devices | Where-Object { $_.TrustType -eq "ServerAd" }).Count
$intuneEnrolled = $managedDevices.Count
$compliant = ($managedDevices | Where-Object { $_.ComplianceState -eq "compliant" }).Count
Write-Host "=== Tenant Summary ==="
Write-Host "Users: $($users.Count) (Members: $members, Guests: $guests, Disabled: $disabled)"
Write-Host "Groups: $($groups.Count) (M365: $m365Groups, Security: $securityGroups, Dynamic: $dynamicGroups)"
Write-Host "Entra Devices: $($devices.Count) (Entra Joined: $entraJoined, Hybrid: $hybridJoined)"
Write-Host "Intune Enrolled: $intuneEnrolled (Compliant: $compliant, Non-compliant: $($intuneEnrolled - $compliant))"
Write-Host "Entra-to-Intune gap: $($devices.Count - $intuneEnrolled) devices registered but not managed"That last line — the Entra-to-Intune gap — is the number that gets stakeholder attention. In a recent 3,000-device estate, the gap was over 400 devices. Nobody had noticed because no single console shows the comparison.
Identity & Access: Who Can Access What?
This is where Graph's value over the admin portals becomes most apparent. Entra shows you Conditional Access policies one at a time. Graph lets you export them all, diff them, version-control them, and identify gaps programmatically.
Key Endpoints
| Endpoint | Purpose | Architectural Value |
|---|---|---|
GET /identity/conditionalAccess/policies | All CA policies with state and controls | Baseline audit of enforcement |
GET /roleManagement/directory/roleAssignments | Entra directory role assignments | Privileged access review |
GET /servicePrincipals | App registrations with permissions | Application permission audit |
GET /oauth2PermissionGrants | Delegated permission grants (user consent) | Consent sprawl analysis |
Deliverable: Conditional Access Policy Export
$policies = Get-MgIdentityConditionalAccessPolicy -All
# Summary analysis
$enabled = ($policies | Where-Object { $_.State -eq "enabled" }).Count
$reportOnly = ($policies | Where-Object { $_.State -eq "enabledForReportingButNotEnforced" }).Count
$disabled = ($policies | Where-Object { $_.State -eq "disabled" }).Count
Write-Host "CA Policies: $($policies.Count) (Enabled: $enabled, Report-Only: $reportOnly, Disabled: $disabled)"
# Flag policies with broad exclusions
$broadExclusions = $policies | Where-Object {
$_.Conditions.Users.ExcludeGroups.Count -gt 0 -or
$_.Conditions.Users.ExcludeUsers.Count -gt 3
} | Select-Object DisplayName, State,
@{Name="ExcludedUsers"; Expression={$_.Conditions.Users.ExcludeUsers.Count}},
@{Name="ExcludedGroups"; Expression={$_.Conditions.Users.ExcludeGroups.Count}}
if ($broadExclusions) {
Write-Host "`nPolicies with broad exclusions (review these):"
$broadExclusions | Format-Table -AutoSize
}
# Export full policy set to JSON for version control
$policies | ConvertTo-Json -Depth 10 | Out-File "ca-policies-export.json"
Write-Host "`nFull policy export written to ca-policies-export.json"The exclusion analysis is the most operationally useful part of this. Every CA environment I've audited has at least one policy where an exclusion group has quietly grown to include half the organisation — typically because someone added users to the exclusion "temporarily" and the removal never happened.
An Opinion on CA Policy Management
Conditional Access policies should be in Git. Not just exported for documentation — managed as code, deployed via pipeline, reviewed via PR. The portal is fine for prototyping a policy. It's a terrible place to manage thirty of them across multiple tenants.
Export them, commit them, diff them between tenants, and deploy them through CI/CD. The JSON export above is the first step. If you're already doing Configuration as Code for Intune (and you should be), extending it to CA policies is a natural progression.
Privileged Access Review
# Get all active directory role assignments
# Note: Graph limits $expand to one property per query, so we expand separately
$roleAssignments = Get-MgRoleManagementDirectoryRoleAssignment -All -ExpandProperty "roleDefinition"
# Build a clean summary — resolve principal display names via the directory
$privilegedUsers = foreach ($ra in $roleAssignments) {
$principal = Get-MgDirectoryObject -DirectoryObjectId $ra.PrincipalId
[PSCustomObject]@{
Role = $ra.RoleDefinition.DisplayName
Principal = $principal.AdditionalProperties.displayName
PrincipalType = $principal.AdditionalProperties.'@odata.type' -replace '#microsoft.graph.',''
}
}
# Flag Global Admins specifically
$globalAdmins = $privilegedUsers | Where-Object { $_.Role -eq "Global Administrator" }
Write-Host "Global Administrators: $($globalAdmins.Count)"
$globalAdmins | Format-Table Principal, PrincipalType -AutoSize
# Count assignments by role
$privilegedUsers | Group-Object Role | Sort-Object Count -Descending |
Select-Object @{Name="Role";Expression={$_.Name}}, Count |
Format-Table -AutoSizeThe number of Global Administrators is the headline metric here. Microsoft's guidance is two — and no more than five. I've seen tenants with thirty. When you pull this data via Graph rather than clicking through Entra, the scale of the problem becomes immediately visible.
Security Posture: Is It Secure?
Microsoft Secure Score is imperfect — it conflates control implementation with actual security, and some of its recommendations are debatable. But it's the closest thing to a standardised posture metric across M365, and it's the number that boards and auditors will ask about.
Key Endpoints
| Endpoint | Purpose | Architectural Value |
|---|---|---|
GET /security/secureScores | Secure Score history | Board-reportable posture timeline |
GET /security/secureScores/{id}/controlScores | Per-control score breakdown | Improvement prioritisation |
GET /deviceManagement/deviceCompliancePolicyStates | Per-device compliance state | Non-compliance identification |
GET /security/alerts_v2 | Defender alerts and incidents | Security incident overview |
Deliverable: Top Improvement Actions
# Get latest Secure Score
$scores = Get-MgSecuritySecureScore -Top 1 -Sort "createdDateTime desc"
$latest = $scores[0]
Write-Host "Current Secure Score: $($latest.CurrentScore) / $($latest.MaxScore)"
$pct = if ($latest.MaxScore -gt 0) { [math]::Round(($latest.CurrentScore / $latest.MaxScore) * 100, 1) } else { 0 }
Write-Host "Percentage: ${pct}%"
# Find top 5 improvement actions by potential score increase
$improvements = $latest.ControlScores |
Where-Object { $_.Score -lt $_.ScoreInPercentage } | # Not fully implemented
Sort-Object @{Expression={$_.ScoreInPercentage - $_.Score}; Descending=$true} |
Select-Object -First 5 @{Name="Control"; Expression={$_.ControlName}},
@{Name="Current"; Expression={$_.Score}},
@{Name="Max"; Expression={$_.ScoreInPercentage}},
@{Name="Potential Gain"; Expression={$_.ScoreInPercentage - $_.Score}}
Write-Host "`nTop 5 improvement actions:"
$improvements | Format-Table -AutoSizeA note on beta endpoints: Several security-related Graph endpoints remain in beta — particularly the more granular Defender and compliance endpoints. Beta endpoints work, but their schema can change without notice. In the companion repo, beta calls are clearly flagged and separated from v1.0 endpoints. If you're building production automation, stick to v1.0 where possible and treat beta as informational.
Device Compliance Summary
$managedDevices = Get-MgDeviceManagementManagedDevice -All `
-Property "DeviceName,OperatingSystem,ComplianceState,LastSyncDateTime,UserPrincipalName"
$complianceSummary = $managedDevices | Group-Object ComplianceState |
Select-Object @{Name="State"; Expression={$_.Name}}, Count |
Sort-Object Count -Descending
$complianceSummary | Format-Table -AutoSize
# Devices that haven't synced in 30+ days — likely stale
$staleDevices = $managedDevices | Where-Object {
$_.LastSyncDateTime -and $_.LastSyncDateTime -lt (Get-Date).AddDays(-30)
}
Write-Host "Devices not synced in 30+ days: $($staleDevices.Count)"Stale devices are a compliance blind spot. A device that hasn't checked in for a month could be non-compliant, lost, or decommissioned — but it still counts in your numbers. Surfacing the stale count alongside compliance state gives a much more honest picture.
Governance: Is It Compliant?
Governance endpoints answer the questions that compliance and audit teams ask cyclically — MFA enrollment, sign-in forensics, change audit trails, and access reviews. The value of Graph here isn't just the data; it's that you can automate the evidence gathering that would otherwise consume days of manual work each quarter.
Key Endpoints
| Endpoint | Purpose | Architectural Value |
|---|---|---|
GET /reports/authenticationMethods/userRegistrationDetails | MFA enrollment status per user | Compliance gap reporting |
GET /auditLogs/signIns | Sign-in logs with device, location, CA policy data | Forensics and evidence |
GET /auditLogs/directoryAudits | Configuration change history | Change audit trail |
GET /identityGovernance/accessReviews | Access review campaign status | Recurring review evidence |
GET /reports/getM365AppUserDetail | Per-user workload usage | Adoption metrics |
Deliverable: MFA Gap Report
This is the report compliance teams ask for quarterly — and it takes ten minutes to produce via Graph versus an afternoon of portal clicking.
# Get MFA registration details
$mfaStatus = Get-MgReportAuthenticationMethodUserRegistrationDetail -All
# Get enabled member users (exclude guests and disabled accounts)
$enabledUsers = Get-MgUser -All -Filter "accountEnabled eq true and userType eq 'Member'" `
-Property "Id,UserPrincipalName,Department" -ConsistencyLevel eventual
# Cross-reference: enabled users without MFA
$mfaGaps = foreach ($user in $enabledUsers) {
$registration = $mfaStatus | Where-Object { $_.Id -eq $user.Id }
if ($registration -and -not $registration.IsMfaRegistered) {
[PSCustomObject]@{
UPN = $user.UserPrincipalName
Department = $user.Department ?? "Unset"
MfaRegistered = $false
MethodsRegistered = ($registration.MethodsRegistered -join ", ")
}
}
}
Write-Host "Enabled members without MFA: $($mfaGaps.Count) of $($enabledUsers.Count)"
# Group by department for the report
$mfaGaps | Group-Object Department |
Select-Object @{Name="Department"; Expression={$_.Name}},
Count |
Sort-Object Count -Descending |
Format-Table -AutoSizeThe department grouping is deliberate. "147 users haven't registered for MFA" is a statistic. "87 of them are in the Finance department" is a conversation with a department head. Framing compliance gaps by organisational unit creates accountability.
Licensing & Usage: What's It Costing Us?
This section is a primer — I've written a deep dive on licence auditing that covers waste identification, automated reclamation, Azure Automation runbooks, and Power BI dashboards in detail. The endpoints here give you the starting data.
Key Endpoints
| Endpoint | Purpose | Architectural Value |
|---|---|---|
GET /subscribedSkus | Licence inventory (purchased vs. assigned) | Baseline and availability |
GET /users/{id}/licenseDetails | Per-user assignments with service plan detail | Waste identification |
GET /reports/getOffice365ActiveUserDetail | Last activity per workload per user | Right-sizing input |
GET /reports/getMailboxUsageDetail | Mailbox sizes and activity | Exchange planning |
GET /reports/getSharePointSiteUsageDetail | Site storage and activity | Storage governance |
Deliverable: Licence Waste Summary
$skus = Get-MgSubscribedSku -All
# SKU friendly name lookup
$skuNames = @{
"ENTERPRISEPREMIUM" = "Microsoft 365 E5"
"ENTERPRISEPACK" = "Microsoft 365 E3"
"SPE_E5" = "Microsoft 365 E5 (unified)"
"SPE_E3" = "Microsoft 365 E3 (unified)"
"EMSPREMIUM" = "EMS E5"
"Microsoft_365_Copilot" = "Microsoft 365 Copilot"
"SPB" = "Microsoft 365 Business Premium"
}
$summary = $skus | Where-Object { $_.AppliesTo -eq "User" -and $_.PrepaidUnits.Enabled -gt 0 } |
Select-Object @{Name="Licence"; Expression={ $skuNames[$_.SkuPartNumber] ?? $_.SkuPartNumber }},
@{Name="Purchased"; Expression={$_.PrepaidUnits.Enabled}},
@{Name="Assigned"; Expression={$_.ConsumedUnits}},
@{Name="Available"; Expression={$_.PrepaidUnits.Enabled - $_.ConsumedUnits}},
@{Name="Utilisation"; Expression={
[math]::Round(($_.ConsumedUnits / $_.PrepaidUnits.Enabled) * 100, 1)
}}
$summary | Sort-Object Licence | Format-Table -AutoSize
# Flag under-utilised paid SKUs
$underUtilised = $summary | Where-Object {
$_.Utilisation -lt 70 -and $_.Licence -notmatch "Free|Trial"
}
if ($underUtilised) {
Write-Host "`nUnder-utilised paid licences (below 70%):"
$underUtilised | Format-Table -AutoSize
}Any paid SKU below 70% utilisation is worth investigating. Below 50% is almost certainly overspend — either the licences were over-purchased, or the rollout stalled and nobody adjusted the subscription. The licence audit post walks through cross-referencing these assignments against actual usage data to quantify the waste in pounds.
Putting It Together: The Tenant Assessment
Each section above solves one problem. The real value comes from running them together on day one of an engagement to produce a baseline assessment — a single document that answers those five opening questions with data rather than assumptions.
The companion repository structures this as a modular script set:
graph-api-for-architects/
├── README.md
├── scripts/
│ ├── 01-discovery.ps1
│ ├── 02-identity-access.ps1
│ ├── 03-security-posture.ps1
│ ├── 04-governance.ps1
│ ├── 05-licensing-usage.ps1
│ └── Full-TenantAssessment.ps1
├── output/
│ └── sample-report.md
└── docs/
└── permissions-required.md
Full-TenantAssessment.ps1 runs all five modules and produces a markdown report. The sections are independent — you can run any of them standalone if you only need one slice of the picture.
Consolidated Permissions
Rather than scattering permission requirements through each section, here's the full set for the complete assessment:
| Permission | Type | Required For |
|---|---|---|
User.Read.All | Application | Discovery, identity, governance, licensing |
Group.Read.All | Application | Discovery |
Device.Read.All | Application | Discovery |
DeviceManagementManagedDevices.Read.All | Application | Discovery, security posture |
Policy.Read.All | Application | Identity & access (CA policies) |
RoleManagement.Read.Directory | Application | Identity & access (role assignments) |
Application.Read.All | Application | Identity & access (service principals) |
SecurityEvents.Read.All | Application | Security posture |
Reports.Read.All | Application | Governance, licensing & usage |
AuditLog.Read.All | Application | Governance (sign-in/audit logs) |
Directory.Read.All | Application | General directory queries |
All read-only. If you're running this against a client tenant, the consent conversation is straightforward — nothing here modifies anything.
Where This Goes Next
This post maps the endpoints that solve immediate architectural questions. It's intentionally broad — a reference guide, not a deep dive on any single topic. The depth lives in dedicated posts:
- The M365 Licensing Audit Nobody Wants to Do — takes the licensing section above and builds a full audit with waste quantification, automated reclamation, and a self-refreshing Power BI dashboard.
- Conditional Access as Code (coming soon) — extends the CA policy export into a full GitOps workflow with pipeline deployment and drift detection.
- The E3 vs. E5 Decision Framework (coming soon) — works through the licence tier question systematically, using Graph data to determine which users actually need premium features.
The through-line is the same: the admin portals show you what Microsoft wants you to see. Graph shows you what's actually there. Once you start working with the data directly, you'll find it difficult to go back to clicking through consoles.
If you're already comfortable with Graph and want to go straight to the licensing deep dive, start with The M365 Licensing Audit Nobody Wants to Do.