Skip to content

Fresh Intune Sync

Sujay Singh edited this page Sep 1, 2026 · 8 revisions

https://developer.microsoft.com/en-us/graph/graph-explorer

Function Area Graph Permission Read managed devices DeviceManagementManagedDevices.Read.All Read/modify Autopilot settings DeviceManagementServiceConfig.ReadWrite.All Read users User.Read.All Manage group membership GroupMember.ReadWrite.All Read directory objects Directory.Read.All

No Intune server exists. Intune is SaaS, and its API is Microsoft Graph. Everyone talks to the same two URLs regardless of tenant:

Token:  https://login.microsoftonline.com/<tenant_id>/oauth2/v2.0/token
Data:   https://graph.microsoft.com/v1.0/deviceManagement/managedDevices

What the admin meant: the app registration is an object in Entra, and with your account you can open Entra admin center, App registrations, find the app, and see everything yourself: client id, tenant id, and crucially the API permissions tab. There are no custom endpoints for him to share. The only per tenant piece is the tenant id in the token URL, which you already hold in your secrets.

Test in two curl calls from any box with internet:

TOKEN=$(curl -s -X POST \
  "https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token" \
  -d "client_id=${CLIENT_ID}" \
  -d "client_secret=${CLIENT_SECRET}" \
  -d "scope=https://graph.microsoft.com/.default" \
  -d "grant_type=client_credentials" | python3 -c "import sys,json;print(json.load(sys.stdin)['access_token'])")


curl -s -X POST \
  "https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token" \
  -d "client_id=${CLIENT_ID}" \
  -d "client_secret=${CLIENT_SECRET}" \
  -d "scope=https://graph.microsoft.com/.default" \
  -d "grant_type=client_credentials" | python3 -m json.tool


curl -s -H "Authorization: Bearer ${TOKEN}" \
  "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?\$top=1"

# Test MVP query
curl -s -H "Authorization: Bearer ${TOKEN}" \
  "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?\$filter=managedDeviceOwnerType%20eq%20'company'%20and%20operatingSystem%20eq%20'Windows'&\$select=id,deviceName,serialNumber,azureADDeviceId,userPrincipalName,complianceState,lastSyncDateTime&\$top=5" \
  | python3 -m json.tool

"value": [
        {
            "id": “6876ry-cfg69-46c9-94e6-004c77879yu77”,
            "deviceName": “BUA-78TYJ,
            "serialNumber": "8CCS0M3",
            "azureADDeviceId": “f5rt0f46e-97a3-40e3-910b-79887546489”,
            "userPrincipalName": “user”.lastname@example.com,
            "complianceState": "configManager",
            "lastSyncDateTime": "2026-08-31T12:25:34Z"
        },


curl -s -H "Authorization: Bearer ${TOKEN}" \
  "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?\$filter=managedDeviceOwnerType%20eq%20'company'%20and%20operatingSystem%20eq%20'Windows'&\$select=complianceState&\$top=999" \
  | python3 -c "import sys,json;from collections import Counter;print(Counter(d['complianceState'] for d in json.load(sys.stdin)['value']))"

'compliant': 724, 'configManager': 208, 'noncompliant': 55, 'inGracePeriod': 12

Reading the results:

  1. First call fails: bad tenant id, client id, or secret. Error body says which.
  2. First call succeeds, second returns 403: token is good but the permission is missing or admin consent was not granted. Go to the registration in Entra, API permissions tab, confirm DeviceManagementManagedDevices.Read.All is listed as Application type, not Delegated, with a green granted check. If not, that is your ask back to the admin, one click on Grant admin consent.
  3. Second call returns one device JSON: done, permissions proven, collector can be built on it.

Optional sanity check between the two: paste the token into jwt decode (python3 -c with base64, or jwt.ms if allowed) and look at the roles claim. It should contain DeviceManagementManagedDevices.Read.All. If roles is empty, consent is missing, no need to even try the second call.

One environment note: cp01 must reach login.microsoftonline.com and graph.microsoft.com on 443 through your egress proxy. Worth testing from cp01 specifically, since that is where the collector will run.

application permission DeviceManagementManagedDevices.Read.All confirmed with admin consent, client credentials flow tested successfully, managedDevices endpoint returning corporate device data with filters, verified from the execution host, secret expiry noted as . That last line also quietly puts the rotation responsibility on record.

Design Doc changes

Diagram: no changes. Nothing we learned touched the flow, Graph to collector to snapshot to engine to PUT and gap report holds exactly as drawn.

Doc: three small edits, all in existing sections.

  1. Fresh fields table. intune_compliance was specced as compliant, noncompliant, unknown. Must carry the full Graph enum: compliant, noncompliant, conflict, error, inGracePeriod, configManager, unknown. Plus one interpretation line: configManager means compliance workload sits with SCCM for that device, answer arrives via the sccm_* fields. Prevents the Fresh dev building a three value dropdown that chokes on day one.
  2. Open items before build. Item 1, Entra registration, closed: permission confirmed with admin consent, client credentials flow tested from cp01, endpoint returning filtered corporate data. Item 3, serial quality, downgrade from open to nearly closed: live pulls show real serials, blocklist ships as guard regardless. Add one new operational line: secret expiry date noted, rotation owner named.
  3. Optional, one sentence under agent gap report or acceptance criteria. The noncompliant device list is an immediate day one deliverable from the MVP fields, first visible win for the golden record project. Worth having in writing before someone asks what this workstream produces.

That is the entire delta. The design survived first contact with the real API without a structural scratch, which is the pattern working as intended: findings landed in field definitions and status lines, not architecture.

Want me to output the updated doc in full with the edits applied, ready to replace the version in project files?

Difference between compliant/non-compliant

All three answer the same question, does this device meet your compliance policies, but they differ in who evaluated it and what the verdict was.

compliant. Intune evaluated the device against your assigned compliance policies, things like BitLocker on, firewall enabled, minimum OS build, and it passed all of them. Intune's own verdict, positive.

noncompliant. Same evaluator, failed verdict. Intune checked and at least one policy rule failed, disk not encrypted, OS below minimum, whatever the policy defines. Which rule failed is visible per device in the Intune console, not in this field.

configManager. No verdict at all. It means Intune did not evaluate this device because the compliance workload is assigned to Configuration Manager under co management. SCCM is the judge for that device, and Intune just points at it. The device could be perfectly healthy or a mess, this value tells you nothing either way, only where to ask.

So the first two are answers, the third is a referral. That is why the doc edit matters: a report counting configManager devices as noncompliant would be flat wrong, and one counting them as compliant would be worse. They are unevaluated by Intune, full stop, and their real answer arrives when the SCCM collector lands and populates the sccm_* fields.

Why your fleet shows all three: co management assigns workloads per collection, and your rollout has moved the compliance workload to Intune for some device collections while others still sit with SCCM. Mixed values are the signature of a staged migration, not an error.

Data analysis:

First, a catch: your four counts sum to exactly 999, which is the page cap, not your fleet. Graph returned page one and the counter stopped there. Real totals need pagination.

Total count, quick attempt:

curl -s -H "Authorization: Bearer ${TOKEN}" -H "ConsistencyLevel: eventual" \
  "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?\$filter=managedDeviceOwnerType%20eq%20'company'%20and%20operatingSystem%20eq%20'Windows'&\$count=true&\$top=1" \
  | python3 -c "import sys,json;print(json.load(sys.stdin).get('@odata.count'))"

If that prints a number, done. If it prints None, this endpoint is ignoring $count, so use the paginated version, which also gives the true distribution:

python3 - <<'PY'
import json, os, urllib.request
from collections import Counter
tok = os.environ["TOKEN"]
url = ("https://graph.microsoft.com/v1.0/deviceManagement/managedDevices"
       "?$filter=managedDeviceOwnerType%20eq%20'company'%20and%20operatingSystem%20eq%20'Windows'"
       "&$select=complianceState&$top=999")
c, total = Counter(), 0
while url:
    req = urllib.request.Request(url, headers={"Authorization": "Bearer " + tok})
    data = json.load(urllib.request.urlopen(req))
    for d in data["value"]:
        c[d["complianceState"]] += 1
        total += 1
    url = data.get("@odata.nextLink")
print("total:", total)
print(dict(c))
PY

That loop is also the skeleton of the real collector, so running it doubles as a pagination proof. For the org wide number including mobile, macOS, and BYOD, run it once more with the $filter line deleted. The gap between the two numbers is itself useful for the doc.

Full data for 5 devices, drop the select, keep top:

curl -s -H "Authorization: Bearer ${TOKEN}" \
  "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?\$filter=managedDeviceOwnerType%20eq%20'company'%20and%20operatingSystem%20eq%20'Windows'&\$top=5" \
  | python3 -m json.tool

No select means Graph returns the full default property set, 40 plus fields per device: model, manufacturer, enrollment dates, management agent, OS build, storage, encryption state, ownership, user details, the lot. Good browsing material for deciding whether the payload wants more fields than my default list.

One Graph quirk worth knowing while you browse: a few deep properties, notably hardwareInformation with TPM, IPv4, and detailed serials, only come back on a single device GET with an explicit select, never on list calls:

curl -s -H "Authorization: Bearer ${TOKEN}" \
  "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices/<device-id>?\$select=hardwareInformation" \
  | python3 -m json.tool

If a field you want lives only there, that is one extra call per device and changes the collector's runtime math, so flag anything from that bucket before we lock the field list.

Provisional read on your page one ratios, pending the full run: roughly 72 percent Intune evaluated compliant, 21 percent deferred to SCCM, 5 percent noncompliant, 1 percent in grace. If that holds fleet wide, Intune compliance is dashboard worthy now, not a footnote, with SCCM filling the 21 percent later.

Clone this wiki locally