Non-Graph data-plane registration — when a Graph scope is only half the permission¶
A few Microsoft Graph endpoints are a front end over a data plane that keeps its own principal registry. For those, granting the Graph application permission puts the scope in the poller's token and changes nothing: the service behind the endpoint has never heard of your service principal and refuses it. The other half is a registration in Security & Compliance PowerShell — not a Graph scope, not consentable in the Entra admin center, and not something graph2otel can do for you.
This page is the operator procedure for that second half, and the diagnostic that tells you whether you need it. Read it before granting eDiscovery.Read.All — the grant alone will not produce data, and the failure gives you nothing to work with.
Evidence tags, as in graph-api-gotchas.md: [live YYYY-MM-DD, #issue] was measured on the wire against a real tenant under the poller's own identity; [docs-only] is believed from Microsoft's documentation and cheap to re-open; [n=1] is a single observation; [unverified] is not established either way. Microsoft's documentation has been wrong on essentially every load-bearing detail on this path — wire over docs.
The diagnostic: the status code is the whole tell¶
Both outcomes below were observed in the same tenant in the same session, on two Purview endpoints. They look alike and are different systems failing for different reasons.
| symptom | meaning | fix |
|---|---|---|
403 InsufficientGraphPermissions | Graph is refusing you. The scope is genuinely missing or not admin-consented. | Grant + consent the endpoint's documented Application permission [live 2026-07-16, #126] |
| 401, scope already in the token | Graph let it through. A non-Graph data plane behind the endpoint does not know your principal. | This page. No amount of Graph scope will move it [live 2026-07-16, #102] |
Corroborating detail that rules out the alternatives [live 2026-07-16, #102]:
GET /v1.0/security/cases→ 200, butGET /v1.0/security/cases/ediscoveryCases→ 401UnknownError(empty message), v1.0 and beta alike. The parent is Graph-served; the child hits the Purview data plane. So this is not routing, and not the token being rejected wholesale.GET /v1.0/compliance/ediscovery/cases→ 401UnauthorizedwithServiceFabricGraphAuthenticationMiddleware.ValidateTokenin the message — the compliance data plane rejecting the principal, exactly as the middleware name says.- 401 is not 403. It is not "you lack a scope", it is "I cannot identify you", from a service where the principal does not exist.
Do not generalize either result. A 403 means missing consent far more often than product limitation (#109 called a missing scope a permanent gap and was wrong). But a grant can also be insufficient (#102). Verify, never infer, in both directions.
graph2otel check cannot see this failure mode: it reports whether a permission is granted and consented, which for a token holding eDiscovery.Read.All is a clean pass while the endpoint 401s. See permissions.md for the caveats it documents about itself.
Who needs it¶
| surface | Graph half | data-plane half | status |
|---|---|---|---|
Purview eDiscovery (security/cases/ediscoveryCases) | eDiscovery.Read.All | required — this page | 401 → 200 once run [live 2026-07-17, #102/#148] |
Purview sensitivity labels (security/dataSecurityAndGovernance/sensitivityLabels) | SensitivityLabel.Read | not needed | 200 app-only on the Graph scope alone [live 2026-07-16, #126] |
O365 Management Activity API (m365.activity) | — | not needed | A non-Graph data plane that authorizes on Entra app roles (ActivityFeed.Read) directly — see o365-management-api.md |
| Everything else shipped | Graph scopes only | not needed | No other shipped collector sits behind an S&C-registered data plane (audit, #149) |
Audit result: eDiscovery is the only surface on graph2otel's map that needs this. Every other Purview and M365 collector either takes a plain Graph application permission, or takes none because it reads a different data plane with its own Entra app roles. Recorded so the absence of findings is on the record rather than assumed.
What this does not fix¶
Registering the principal makes a data plane recognize you. It does not create an API where none exists. These are named so you do not run the procedure hoping:
- Purview retention labels —
GET /security/labels/retentionLabels→ 500DataInsightsRequestError+ "Forbidden" on v1.0 and beta withRecordsManagement.Read.Allgranted and in the token[live 2026-07-16, #109/#126]. Microsoft's permission table says Application: Not supported[docs-only]. This is a product gap, not a registration gap. Retention label definitions being Graph-exposed does not change the app-only verdict. - Retention policy location bindings — S&C PowerShell only (
Get-RetentionCompliancePolicy), no Graph list equivalent[live, #99].
Correction (
live-measured 2026-07-23, #237): an earlier version of this list said "DLP policy enumeration — the Purview APIs evaluate content against policy; they never enumerate policy." That generalization was true of the #99 Purview Ecosystem roles and is false of Graph:GET /beta/security/dataSecurityAndGovernance/policyFilesreturns the full DLP policy set app-only, on scopes the poller already holds — no data-plane registration needed. #246 builds it. Only retention policy bindings remain a genuine gap.
The distinction that matters: this procedure only helps where a Graph endpoint exists and the data plane behind it refuses you. Retention bindings are reachable from S&C PowerShell — but graph2otel speaks Graph at runtime, never PowerShell, so a reachable-only-from-PowerShell surface is out of reach regardless of what you register.
The procedure¶
Run once per app registration, per tenant. It mutates the tenant. Every value below is a placeholder except the device-code client id in step 1.
0. Resolve the object id — this is the trap¶
The role-group cmdlets take the enterprise-application object id, not the appId. They are different GUIDs for the same app, and using the appId is the documented common mistake [docs-only]. On the reference tenant the two values are unrelated strings and neither is derivable from the other [live 2026-07-17, #102/#148].
Resolve it from Graph and read .id:
The id this returns is the value to paste into -ObjectId, -Member, and -User below. It was confirmed correct against the live directory before the cmdlets ran [live 2026-07-17, #102/#148].
1. Connect — app-only does not work; use a delegated device-code token¶
Do not plan around app-only Connect-IPPSSession. On the reference tenant it fails, and a Windows host does not fix it [live 2026-07-17, #102/#148]:
| environment | result |
|---|---|
Windows Server, PowerShell 5.1, EXO 3.10.0, certificate in store (-CertificateThumbprint) | Object reference not set to an instance of an object. |
macOS, pwsh 7.6.3, EXO 3.10.0, pfx (-Certificate) | identical NullRef |
Same module version, same identity, same org, two operating systems, byte-identical failure. -Verbose places it after authentication — Successfully got a token from AAD, then a NullRef in Microsoft.Exchange.Management.ExoPowershellSnapin.NewEXOModule.ProcessRecord(). Ruled out with evidence: not the platform (Windows reproduces it); not the certificate or the identity (Connect-ExchangeOnline with the same pfx from the same host connects and returns live data); not a missing role (the identity held Compliance Administrator, Security Administrator, Exchange Administrator and Exchange.ManageAsApp, live-read); not replication lag (three days). Why app-only fails is open [unverified] — the obvious explanation, "the principal is unknown to compliance RBAC", is refuted: that principal was already registered and it still NullRefs [live 2026-07-17, #102].
A delegated device-code token connects first try from the same host that fails app-only [live 2026-07-17, #102/#148]. There is also a bootstrap argument for it: New-ServicePrincipal is itself an S&C cmdlet, so registering a principal for app-only access appears to require an already-working session, which app-only cannot give you. That is a deduction from the cmdlet's location, not a measurement [unverified].
The device-code client id matters [live 2026-07-17, #149]:
| client id | name | result |
|---|---|---|
a0c73c16-a7e3-4564-9a95-2bdf47383716 | Microsoft Exchange Online Remote PowerShell | AADSTS7000112: Application is disabled |
fb78d390-0c51-40cd-8e17-fdbfab77341b | Microsoft Exchange REST API Based Powershell | works |
Scope: https://ps.compliance.protection.outlook.com/.default.
Install-Module ExchangeOnlineManagement # 3.10.0 is the measured version
Import-Module ExchangeOnlineManagement
# $token: delegated device-code token for client fb78d390-… , scope above.
Connect-IPPSSession -AccessToken $token -Organization "<tenant>.onmicrosoft.com"
The signing-in user must be able to run New-ServicePrincipal and Add-RoleGroupMember; on the reference tenant this was a Global Administrator [live 2026-07-17, #102/#148]. The minimum role that suffices was not established [unverified] — if you establish it, that is worth recording. The -AccessToken / -Organization parameter spelling above is also [unverified]: the issues record that the token was acquired and the session connected, not the cmdlet's exact parameter form.
2. Register the principal and grant the role¶
Get-ServicePrincipal first — it lists what the compliance data plane already knows, and an app registered by some earlier process will already be there [live 2026-07-17, #102].
Get-ServicePrincipal
New-ServicePrincipal -AppId "<appId>" `
-ObjectId "<enterprise app object id, from step 0>" `
-DisplayName "graph2otel-poller"
Add-RoleGroupMember -Identity "eDiscoveryManager" -Member "<object id>"
Get-RoleGroupMember -Identity "eDiscoveryManager"
Add-eDiscoveryCaseAdmin -User "<object id>"
Get-eDiscoveryCaseAdmin
On least privilege. These three ran as one batch, so which subset is minimally sufficient is unverified — the 401 → 200 transition is attributable to the batch, not to any one cmdlet. eDiscoveryManager is not a read-only role group; Microsoft documents it as able to create and manage cases [docs-only], which is more than a telemetry poller needs. A Reviewer role group was named as the lower-privileged alternative when the fix was scoped [#102] and was not tested [unverified]. If you want strict read-only, try Reviewer first, and record what happens — a narrower working configuration is a finding this page should carry.
3. Verify — re-request the endpoint as the poller¶
Mint a fresh token (app-only tokens embed roles at issue time) with the poller's own credentials, and request the endpoint that was failing:
Expect 200 where you had 401. Measured on the reference tenant, v1.0 and beta both moved 401 → 200, one live case returned [live 2026-07-17, #102/#148].
Do not wait 24 hours. The ≤24h compliance-RBAC replication lag that Microsoft's guidance implies did not apply: the probe returned 200 on the first attempt, immediately after the cmdlets, with no wait [live 2026-07-17, #102/#148]. If yours still 401s, then replication is a candidate — as a fallback explanation, not as an instruction.
Probe as the poller identity itself, never with another app. A different app's access answers a different question — that is how #109 reached a wrong verdict, and the verdict then suppressed re-investigation until #126 re-probed correctly.
Runtime: graph2otel never speaks PowerShell¶
This is a one-time setup action, not a dependency. graph2otel reads eDiscovery over Graph as the poller, with eDiscovery.Read.All, like every other collector [live 2026-07-17, #102]. Nothing in the binary imports, shells out to, or requires the ExchangeOnlineManagement module. If you rotate to a new app registration, the new principal needs its own registration — the compliance data plane keys on the principal, not the tenant. Whether a registration ever expires or needs re-running is [unverified]; the reference tenant's is one day old.
eDiscovery.Read.All stays granted after this runs: it is read-only, and it is half of a working configuration rather than a permission the poller cannot use.