Permission setup¶
graph2otel authenticates against each configured tenant as an app-only (client-credentials) Microsoft Entra ID app registration — there is no signed-in user. This page walks through registering that app, granting it the right Graph API application permissions, and the three gotchas that catch most first-run setups. For the per-collector scope list, see collectors.md; for the config schema those collectors are toggled from, see configuration.md.
1. Register the app¶
- In the Entra admin center (or
az ad app create), create an app registration in each tenant you want to poll — or a single multi-tenant app registration reused across tenants, if your tenants are all under your own management. - Under Certificates & secrets, create a client secret, or (preferred for production) upload a certificate.
graph2otelreads whichever you configure viaazidentity.DefaultAzureCredential:
export AZURE_TENANT_ID="<tenant guid>"
export AZURE_CLIENT_ID="<app registration client id>"
export AZURE_CLIENT_SECRET="..." # or, for certificate auth:
# export AZURE_CLIENT_CERTIFICATE_PATH="/path/to/cert.pem"
These are environment variables, never YAML — see configuration.md for why. Ambient workload/managed identity also works if the host provides it. 3. Under API permissions, add the application permissions (not delegated) your enabled collectors need. Start from collectors.md's per-collector scope column, or run graph2otel check (below) once the app registration exists. It compares the roles in the app's token to the collectors this tenant's configuration actually selects.
2. Grant admin consent (gotcha #1)¶
Adding an application permission to the app registration is not enough on its own — application permissions require a tenant administrator to grant admin consent before the permission is actually usable. This is the most common first-run failure: the permission shows as added in the Entra admin center, but every Graph call using it returns HTTP 403 until consent is granted.
In the Entra admin center: API permissions → Grant admin consent for <tenant>. This must be done by a Global Administrator or Privileged Role Administrator (or whoever your tenant's consent policy authorizes) — a plain app-registration owner cannot self-consent to application permissions.
3. Directory-role gating (gotcha #2 — partially confirmed)¶
Most Graph application permissions are sufficient on their own once consented. A smaller set of surfaces — notably Identity Protection (entra.risk, entra.risk_detections) — have been observed in Microsoft's own documentation to additionally expect the calling service principal to hold a directory role, not just the API permission grant. A service principal can have the right permission scope, fully consented, and still get a 403 at runtime if this additional gate applies and the role isn't assigned.
What's confirmed here: graph2otel's collectors request only the documented API permission scope for each endpoint (see collectors.md); none of them require a directory role assignment as a hard prerequisite that's been verified failing without one in this project's own testing. What's still open: which specific endpoints enforce a directory-role check in practice, beyond what Microsoft's docs state, hasn't been exhaustively reproduced against a live tenant by this project. If a collector 403s despite a correctly consented permission, check whether the calling service principal also needs a directory role (e.g., Security Reader) before filing it as a graph2otel bug.
graph2otel check (see below) can only tell you whether a permission is granted and consented — it has no way to enumerate a service principal's directory-role assignments, so it cannot detect this failure mode. Its own help text calls this limitation out explicitly.
4. The export-job ReadWrite caveat (gotcha #3)¶
A group of opt-in Intune collectors — 22 as of this writing, intune.app_install_status, intune.cert_inventory and intune.defender_agents among them — read their data via the Intune Reports Export API (POST /deviceManagement/reports/exportJobs, then poll and download the result). The authoritative, always-current list is the generated table in collectors.md: it is every row whose permissions column reads DeviceManagementManagedDevices.ReadWrite.All. (A hand-kept enumeration lived here and went stale twice — three collectors, then six — so the list now lives only where it is generated.) That API requires DeviceManagementManagedDevices.ReadWrite.All, a write-level scope, purely to create the export job. This is documented Microsoft Graph behavior, not a graph2otel design choice.
If you're setting these collectors up and notice a read-only telemetry exporter asking for a ReadWrite scope, this is why: graph2otel never uses that scope to write any Intune configuration or device state — it creates the export job, polls its status, and reads the exported result back. No collector ever touches DeviceManagementManagedDevices.PrivilegedOperations.All (remote wipe and other destructive actions) or any other write scope; this one ReadWrite grant, needed only by these opt-in export collectors, is the sole exception to graph2otel's read-only posture. Because these collectors are all opt-in (Experimental, see collectors.md), a default/read-only deployment never requests this scope at all.
4b. Some endpoints need a second, non-Graph registration (gotcha #4)¶
A granted Graph scope is not always sufficient. Purview eDiscovery (security/cases/ediscoveryCases) returns 401 with eDiscovery.Read.All present in the token until the app's service principal is separately registered with the Security & Compliance data plane via PowerShell. No Graph scope moves it — the data plane does not know the principal, which is a different failure from a missing scope (that one 403s).
graph2otel check cannot detect this: it reports what is granted and consented, and the grant is not the problem.
Only the two Purview eDiscovery collectors need this today, and both are opt-in. purview.ediscovery_cases ships the eDiscovery (Premium) case inventory — a bounded count of cases by status plus a log twin per case. purview.ediscovery_case_health goes one level down into each case: legal holds by enabled × has_errors, their data sources by source_type × hold_status, and long-running operations by action × status. Both are v1.0 GA, not beta endpoints, but both are off by default because they need two prerequisites a normal collector does not. To enable them:
- Grant + admin-consent
eDiscovery.Read.Allon the app registration (§2 above). - Register the app's service principal in the Security & Compliance data plane via PowerShell — the Graph scope alone returns 401 until you do. See
data-plane-registration.mdfor the exact procedure. - Turn the collector(s) on — both are
Experimental(opt-in), so each runs only when you enable it explicitly in config (quote the dotted key):
collectors:
"purview.ediscovery_cases":
enabled: true
"purview.ediscovery_case_health":
enabled: true
purview.ediscovery_case_health fans out 1 + 4C + 2H requests over some of the slowest endpoints in Graph — an empty legalHolds list has been measured at 11–22 s — so it caps the cases it polls per cycle and reports any shortfall as purview.ediscovery.case_health.cases_covered against …cases_total. A covered count below the total means the cap left part of the tenant unpolled this cycle, not that the tenant is small.
Enabling a collector without step 2 produces a loud 401 on every poll (by design — a swallowed 401 is how a half-configured data plane hides as "no cases"). graph2otel check cannot detect the missing data-plane registration: it reports what is granted and consented, and the grant is not the problem.
4c. One collector authenticates with a static token, not the Entra app (gotcha #5)¶
mdca.discovery_parse (#145) is the single exception to "every scope is a Graph app-role on the poller." It reads the Microsoft Defender for Cloud Apps Cloud Discovery governance log, which lives only on the legacy MDCA portal API (<tenant>.<region>.portal.cloudappsecurity.com) — there is no Graph endpoint for it. That API authenticates with a static portal token in an Authorization: Token <secret> header, NOT DefaultAzureCredential and NOT a Graph token. So:
- There is no Graph scope to grant for it —
RequiredPermissions()is empty.graph2otel checkreports the enabled collector with a manual MDCA-token prerequisite; it cannot verify the token. - The token is supplied per-tenant via
mdca.token_file(a filesystem PATH in config; the token itself is mounted as a file, never in YAML or env). Generate the token in the MDCA portal (Settings → Cloud Discovery → automatic log upload / API tokens) with the least-privilege scope your tenant offers. - The collector is
Experimental(opt-in): the portal API is a legacy surface with no Graph successor. Settingmdca.portal_urlis the whole opt-in.
4d. Teams inventory uses an app-wide scope, not the narrower RSC one (gotcha #6)¶
m365.teams (#121) declares Team.ReadBasic.All (for GET /teams) and TeamSettings.Read.All (for the per-team GET /teams/{id}?$select=summary). The documented least-privilege scope for the summary is TeamSettings.Read.Group, but that is resource-specific consent (RSC) — granted per team by installing a Teams app with an RSC manifest into each team — which cannot serve a tenant-wide poller that must enumerate every team it has never been installed in. TeamSettings.Read.All is the workable application scope for a tenant-wide inventory, and is the deliberate (documented) deviation from narrowest-scope here. The collector degrades to a skip-and-log if these are not granted, so a 403 is a "not granted yet", not a crash.
5. Verify with graph2otel check¶
The check subcommand (landed as part of M1, tracked in #11) is a read-only, side-effect-free permission preflight: it loads your config, reads each configured tenant's granted permission claims via a token, and reports what's missing against what your enabled collectors declare — surfacing a 403 before you find it at runtime instead of after.
Its help text (graph2otel check -h) also prints the least-privilege notes from this page (the ReadWrite exception and the never-request list), the two Graph caveats it cannot verify by itself (admin consent already granted vs. merely added; directory-role gating), and the manual boundary for non-Graph transports. A [MANUAL] line means the collector is selected but a Graph application-token claim cannot prove the remaining prerequisite; it is not a successful validation of that prerequisite.
The check builds its inventory from the same collector registration paths as runtime wiring and honours disabled, source-selected, experimental, high-volume, and licence gates. To make the last one exact, it performs the same read-only GET /subscribedSkus capability lookup runtime performs; it never grants a role or changes the tenant. If that lookup fails, the command warns and uses the same base-tier fallback as runtime, so premium-gated collectors are not included in that run.
The O365 Management Activity roles (ActivityFeed.Read, and ActivityFeed.ReadDlp when selecting DLP.All) belong to the manage.office.com audience, not Microsoft Graph. They are rendered as [MANUAL] prerequisites rather than compared with the Graph-token role claim.
Least-privilege summary¶
- Grant only the scopes your enabled collectors need (see
collectors.md) — a disabled collector makes zero Graph API calls and needs zero permission. - Never grant
DeviceManagementManagedDevices.PrivilegedOperations.All— graph2otel has no use for destructive device actions and never requests it. - The one legitimate write-level exception is
DeviceManagementManagedDevices.ReadWrite.All, and only if you enable one of the export-report collectors (see gotcha #3 above; the current set is everycollectors.mdrow declaring that scope). - See
SECURITY.mdfor the full data-handling and cardinality posture this permission model supports.