//sbd.org.uk
Back to blog
Graph API for Architects: The Endpoints That Actually Matter
·8 min read

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.

microsoft-365graph-apipowershellarchitecture

Every Microsoft 365 engagement I've worked starts with the same five questions:

  1. What do we have? — Discovery
  2. Who can access what? — Identity and access
  3. Is it secure? — Security posture
  4. Is it compliant? — Governance
  5. 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

EndpointPurposeArchitectural Value
GET /usersActive, disabled, and guest users with licence and sign-in dataUser scoping for design decisions
GET /groupsDynamic vs. assigned, M365 vs. security groupsGroup strategy assessment
GET /devicesJoin type (Entra joined, hybrid, registered) and OS distributionDevice estate profiling
GET /deviceManagement/managedDevicesIntune-enrolled devices with compliance stateEnrollment gap analysis
GET /deviceManagement/mobileAppsWin32, MSIX, WinGet, Store deploymentsApplication estate inventory
GET /deviceManagement/detectedAppsActually installed software across managed devicesShadow 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

EndpointPurposeArchitectural Value
GET /identity/conditionalAccess/policiesAll CA policies with state and controlsBaseline audit of enforcement
GET /roleManagement/directory/roleAssignmentsEntra directory role assignmentsPrivileged access review
GET /servicePrincipalsApp registrations with permissionsApplication permission audit
GET /oauth2PermissionGrantsDelegated 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 -AutoSize

The 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

EndpointPurposeArchitectural Value
GET /security/secureScoresSecure Score historyBoard-reportable posture timeline
GET /security/secureScores/{id}/controlScoresPer-control score breakdownImprovement prioritisation
GET /deviceManagement/deviceCompliancePolicyStatesPer-device compliance stateNon-compliance identification
GET /security/alerts_v2Defender alerts and incidentsSecurity 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 -AutoSize

A 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

EndpointPurposeArchitectural Value
GET /reports/authenticationMethods/userRegistrationDetailsMFA enrollment status per userCompliance gap reporting
GET /auditLogs/signInsSign-in logs with device, location, CA policy dataForensics and evidence
GET /auditLogs/directoryAuditsConfiguration change historyChange audit trail
GET /identityGovernance/accessReviewsAccess review campaign statusRecurring review evidence
GET /reports/getM365AppUserDetailPer-user workload usageAdoption 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 -AutoSize

The 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

EndpointPurposeArchitectural Value
GET /subscribedSkusLicence inventory (purchased vs. assigned)Baseline and availability
GET /users/{id}/licenseDetailsPer-user assignments with service plan detailWaste identification
GET /reports/getOffice365ActiveUserDetailLast activity per workload per userRight-sizing input
GET /reports/getMailboxUsageDetailMailbox sizes and activityExchange planning
GET /reports/getSharePointSiteUsageDetailSite storage and activityStorage 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:

PermissionTypeRequired For
User.Read.AllApplicationDiscovery, identity, governance, licensing
Group.Read.AllApplicationDiscovery
Device.Read.AllApplicationDiscovery
DeviceManagementManagedDevices.Read.AllApplicationDiscovery, security posture
Policy.Read.AllApplicationIdentity & access (CA policies)
RoleManagement.Read.DirectoryApplicationIdentity & access (role assignments)
Application.Read.AllApplicationIdentity & access (service principals)
SecurityEvents.Read.AllApplicationSecurity posture
Reports.Read.AllApplicationGovernance, licensing & usage
AuditLog.Read.AllApplicationGovernance (sign-in/audit logs)
Directory.Read.AllApplicationGeneral 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.