---
name: microsoft-entra-directory-oauth-app
description: >-
  The Microsoft Graph permission and query model for reading Microsoft Entra
  ID directory data — users, groups, memberships, managers and org profile —
  for a connector that syncs a customer's directory. Builds on the
  `microsoft-entra-app-registration` base skill; read that first. Covers the
  `User.*`, `Group.*`, `GroupMember.*`, `Directory.*` and
  `Organization.Read.All` families and which need admin consent, the
  least-privilege ladder from `User.ReadBasic.All` to `Directory.Read.All`,
  guests, `$select`/`$filter` and `ConsistencyLevel: eventual`, delta queries,
  manager expansion, on-premises-synced attributes, and throttling. Use when
  asked to scope an Entra/Azure AD directory or employee sync, choose between
  `User.Read.All` and `Directory.Read.All`, fix a directory read returning
  `403 Authorization_RequestDenied` or empty results, or explain why a
  directory connector needs a tenant admin.
---

# Entra ID directory data (users, groups, org) through Microsoft Graph

Getting an Entra app registered is the base skill's job and it is mechanical. **Choosing the directory permissions
is the whole negotiation**, and that is what this file covers. Reading a customer's user and group directory is one
of the most heavily gated things Microsoft Graph exposes: almost every permission that reaches beyond the signed-in
user is admin-restricted, the broadest one in the family is the one security teams refuse by name, and the shape of
what comes back changes depending on who authorized and whether the tenant syncs from on-premises Active Directory.

## Built on: `microsoft-entra-app-registration`

**Read the base skill first.** It is the source of truth for the Entra mechanics this file assumes:

- **Supported account types** — single tenant vs multitenant vs personal Microsoft accounts, the permission ceilings
  each imposes, and `AADSTS50194` when a single-tenant registration is called through `/common`. Directory reads are
  work/school only, so **Multiple Entra ID tenants** is the answer here.
- **Redirect URIs** — Web vs SPA vs public client, exact case-sensitive matching, the count and length limits, and the
  Unified.to callback hosts.
- **Delegated vs application permissions and admin consent** — how the two modes differ, how to send an admin-consent
  URL, and that Microsoft Graph *application* permissions need a **Privileged Role Administrator**, not an Application
  Administrator. Directory connectors stall on that more than on anything else.
- **Client secrets and certificates** — the 24-month cap, the **Value** shown exactly once, `AADSTS7000222` on expiry,
  add-then-delete rotation, and the per-cloud authority and Graph hosts.
- **`offline_access`, publisher verification, the v2.0 endpoints and the generic AADSTS symptom table** — no
  `User.*` or `Directory.*` scope carries a refresh token of its own; `offline_access` is what gets you one.

The base also holds the inputs to collect, the run order, tenant ownership, the credential hand-off and the generic
stop-and-ask list. **If this file is all you have loaded, you are missing all of it** — read the base before you touch
the portal, then come back here for the permissions. Everything below is directory-specific.

## Extra inputs to collect

On top of the base skill's batch:

| Input | Notes |
| --- | --- |
| **Read-only, or write back to the directory?** | Write means `*.ReadWrite.*`, which roughly doubles the review difficulty (§1) |
| **Which objects: users only, or users + groups + memberships?** | Groups cost a second permission family, and `Group.Read.All` reaches further than it sounds (§1, §2) |
| **Are managers / reporting lines in scope?** | Own permission, and app-only has a documented gap (§7) |
| **Delegated (a user authorizes) or application (app-only sync)?** | Decides who consents and what a guest authorizer can see (§3, §4) |
| **Full sync every run, or incremental?** | Decides whether you build on delta and store tokens (§6) |
| **Does the customer sync from on-premises Active Directory?** | Changes which attributes exist and which are writable (§8) |
| **Is a Privileged Role Administrator available in each customer tenant?** | Blocking for app-only directory sync (§3) |

## Platform state (verified 2026-09-20 — re-verify before trusting)

- **Microsoft's own permissions reference now carries a standing caution on the `Directory.*` family**: directory
  permissions "grant broad access to directory (Microsoft Entra ID) resources such as user, group, and device",
  you should "choose permissions specific to these resources and avoid using directory permissions", and
  **"Directory permissions might be deprecated in the future."** Treat `Directory.Read.All` as a legacy convenience
  with an expiry risk attached, not as the default.
- **Granular user permissions now exist** and are marked least-privileged on the user APIs:
  `User-Mail.ReadWrite.All`, `User-Phone.ReadWrite.All`, `User-PasswordProfile.ReadWrite.All`,
  `User.EnableDisableAccount.All`, `User.ReadUpdate.All` and `User-LifeCycleInfo.Read.All`. If a connector needs one
  narrow write, these are how you avoid asking for `User.ReadWrite.All`.
- **`GET /groups` currently lists `Group-NestingSupport.ReadWrite.All` as its "least privileged" permission**, which
  reads like a documentation artifact rather than advice — it is a *write* permission. The permissions actually worth
  requesting to list groups are `Group.ReadBasic.All`, `GroupMember.Read.All` and `Group.Read.All` (§1).
- **Legacy directory-role residue from before December 2020**: granting `Directory.Read.All` / `Directory.ReadWrite.All`
  as an *application* permission used to also assign the Directory Readers / Directory Writers directory role to the
  app's service principal, and **that role is not removed when consent is revoked**. Microsoft disabled the behavior
  between 2020-12-03 and 2021-01-11. In a long-lived customer tenant, revoking consent may not fully revoke access —
  the admin has to remove the directory role too.
- **Delta (change tracking) is not supported in Microsoft Entra External ID external tenants or Azure AD B2C tenants.**
  If a customer is on one of those, incremental sync is off the table (§6).
- **`$count` and `$search` are not available in Azure AD B2C tenants** either, which removes most advanced queries.

If Graph or the Entra admin center does not behave like this, stop and report what you actually see.

## 1. The permission families

All of these are Microsoft Graph permissions, requested as space-delimited scopes in the authorize request (delegated)
or granted as app roles and requested as `.default` (application). Admin-consent status is from the Graph permissions
reference, verified 2026-09-20.

| Permission | Delegated | Application | Admin consent |
| --- | --- | --- | --- |
| `User.Read` | Yes | — | **No** — the signed-in user's own profile only |
| `User.ReadBasic.All` | Yes | Yes | **Delegated: no. Application: yes.** The only `.All` here a user can self-consent |
| `User.Read.All` | Yes | Yes | Yes |
| `User.ReadWrite.All` | Yes | Yes | Yes |
| `Group.ReadBasic.All` | Yes | Yes | Yes |
| `GroupMember.Read.All` | Yes | Yes | Yes |
| `Group.Read.All` | Yes | Yes | Yes |
| `Directory.Read.All` | Yes | Yes | Yes |
| `Directory.ReadWrite.All` | Yes | Yes | Yes |
| `Organization.Read.All` | Yes | Yes | Yes |

What each actually returns — this is the part that decides the ask:

- **`User.ReadBasic.All`** is deliberately constrained to a fixed property set: `displayName`, `givenName`, `id`,
  `mail`, `photo`, `securityIdentifier`, `surname`, `userPrincipalName`. Nothing else. No `jobTitle`, no `department`,
  no `employeeId`, no `accountEnabled`, no manager. For a people-directory feature that is often enough, and it is the
  only rung on this ladder that an ordinary user can consent to without an admin.
- **`User.Read.All`** reads the full user profile for every user in the tenant. This is the real floor for an
  HRIS-style employee sync, because `department`, `jobTitle`, `employeeId`, `employeeType`, `employeeHireDate`,
  `officeLocation`, `accountEnabled` and the manager relationship all live above the basic set.
- **`GroupMember.Read.All`** reads memberships and *basic* group properties. It is the right permission when you want
  group membership and nothing else.
- **`Group.Read.All`** reads group properties **and, for Microsoft 365 groups, the group's content** — Microsoft
  states plainly that `Group.*` permissions grant access to conversations, files, notes and calendar for those groups.
  A customer who hears "we need to read your groups" and then reads that description in the consent screen will
  escalate. Ask for `GroupMember.Read.All` unless you genuinely need group properties beyond membership.
- **`Directory.Read.All`** reads "data in your organization's directory, such as users, groups and apps" — users,
  groups, devices, service principals, applications, roles, domains, policies. It is the permission a security team
  refuses by name, and the one Microsoft itself now tells you to avoid. **It is almost never necessary**: the
  resource-specific permissions above cover users and groups, and Graph's `directoryObject` relationships return
  limited information (the object's `@odata.type` and `id`, other properties null) rather than erroring when your app
  cannot read a member type — which is exactly the design that lets you skip `Directory.*`.
- **`Directory.ReadWrite.All`** does not allow deleting users or groups, and in delegated mode does not allow password
  resets. It allows essentially everything else across the directory. Expect this one to end a conversation.
- **`Organization.Read.All`** reads the tenant's own profile and related resources — subscribed SKUs, tenant branding.
  Note the cheap alternative: with only `User.Read`, `GET /organization` still returns `id`, `displayName` and
  `verifiedDomains`, with every other property null. If all you need is the company name and domains, you do not need
  this permission at all.

**Mixing defeats the point.** Every scope in the token is honored, so requesting `User.ReadBasic.All` *alongside*
`Directory.Read.All` is not a least-privilege app — it is a broad app with a least-privilege label, and the reviewer
reading the consent screen will see the broad one. Narrowing means the broad scopes come out of the request.

## 2. The least-privilege ladder

Climb it one rung at a time, and stop at the first rung that satisfies the feature:

```
User.Read                    the signed-in user only              no admin needed
User.ReadBasic.All           8 fixed properties, all users        no admin needed (delegated)
User.Read.All                full user profiles, all users        admin
  + GroupMember.Read.All     memberships + basic group props      admin
  + Group.Read.All           group properties (and M365 content)  admin
Directory.Read.All           everything in the directory          admin, and refused on sight
```

Two rules that survive every review:

1. **Every rung below `Directory.Read.All` exists precisely so you do not have to request `Directory.Read.All`.**
   If someone proposes it, the question to ask is which specific object the resource-specific permissions fail to
   reach. Usually the answer is "none" and the real reason is that a single scope was easier to configure.
2. **The jump from `User.ReadBasic.All` to `User.Read.All` is the expensive one** — not because of the data, but
   because it is the step where the connector stops being self-serve (§3). If a feature can ship on the basic property
   set, it can ship without a tenant admin, and that is worth real product effort to preserve.

## 3. Admin consent: why directory sync cannot be self-serve

Only two things in this whole family are user-consentable: `User.Read`, and delegated `User.ReadBasic.All`. Everything
else — every `.All` permission in delegated mode past the basic one, and **every** application permission without
exception — requires a tenant administrator. That is a documented property of the permissions, not a configuration
you can change, and no amount of re-registering alters it.

The practical consequences, which belong in the product conversation before anyone writes code:

- **An HRIS-style directory sync cannot be a self-serve "connect" button.** The end user clicks, hits
  **`AADSTS90094: AdminConsentRequired`**, and the flow dead-ends. Onboarding has to include a step that only the
  customer's tenant administrator can complete — either the admin runs the connect flow themselves, or you send them
  an admin-consent URL (base skill §6), or their tenant has an admin-consent request workflow and the user files a
  request and waits.
- **App-only sync raises the bar again.** Application permissions on Microsoft Graph cannot be consented by an
  Application Administrator or a Cloud Application Administrator — it takes a **Privileged Role Administrator**.
  Find out whether the customer has one available *before* scheduling the call.
- **An admin who signs in without `prompt=consent` consents only for themselves.** Other users in that tenant still
  dead-end. If your design is "the admin unlocks the tenant once", send `prompt=consent` or the admin-consent URL.
- **Re-consent is not purely additive.** Granting tenant-wide admin consent can revoke permissions already granted
  tenant-wide for the same app, so changing the directory scope set on a live connector is a migration, not an edit.
- **The tenant may block user consent entirely**, in which case even `User.ReadBasic.All` needs an admin. Their
  policy, not your permission set.

Design the connect flow so the admin requirement is stated *before* the user clicks, and so the failure is a readable
"your Microsoft administrator has to approve this" rather than a raw AADSTS code.

## 4. Guests and B2B users

Directory results are not a clean list of employees, and delegated results depend on who authorized.

- **Guests appear in `/users`.** A B2B guest is a full user object with `userType: "Guest"`, plus
  `externalUserState` (`PendingAcceptance`, `Accepted`, or null) and `externalUserStateChangeDateTime`. In a tenant
  that collaborates with partners, guests can be a large fraction of the directory. An employee sync that does not
  filter them will import consultants, vendors, and the other side of every shared channel as staff. Filter on
  `userType` — it requires `$select` to retrieve — and decide deliberately, rather than discovering it in a customer's
  data.
- **A guest cannot call `GET /users` at all.** Microsoft states it outright on the list-users API. If your delegated
  connector is authorized by a guest account — easy to do by accident when the person connecting is a contractor in
  the tenant — the call fails regardless of the scopes on the token.
- **Default guest permissions are a reduced directory view**, and tenants can reduce it further to "restricted
  guest". A default guest can read only display name, email, sign-in name, photo, UPN and user type of other users
  (plus manager and direct-report information); a restricted guest can read essentially nothing about other users, and
  only the object ID of groups they joined. A delegated connector authorized by a guest can therefore return a small,
  strange subset of the directory with no error at all — which reads as a bug and is not one.
- **Member users are not unrestricted either.** By default a member can enumerate users and groups, but tenants
  restrict this; if `/users` returns `403 Authorization_RequestDenied` with correct scopes, the tenant's default user
  permissions are the thing to check.

## 5. Querying: `$select`, `$filter`, and `ConsistencyLevel: eventual`

Directory collections do not behave like a normal REST list endpoint, and three of these will bite.

- **`GET /users` returns a fixed short property set by default**: `businessPhones`, `displayName`, `givenName`, `id`,
  `jobTitle`, `mail`, `mobilePhone`, `officeLocation`, `preferredLanguage`, `surname`, `userPrincipalName`. Every
  other property — `department`, `employeeId`, `employeeType`, `accountEnabled`, `userType`, `createdDateTime`,
  `onPremises*`, and all extension attributes — **requires `$select` and is silently absent without it.** A connector
  that "loses" fields between a single-user read and a list read is almost always missing `$select`.
- **Some properties can only be read one user at a time.** `aboutMe`, `birthday`, `hireDate`, `interests`, `mySite`,
  `pastProjects`, `preferredName`, `responsibilities`, `schools`, `skills` and `mailboxSettings` are not returnable in
  a user collection, and `$select`-ing them on `/users` returns **`501 Not Implemented`**.
- **Advanced queries need two things together.** `$search`, `$count`, and the `ne` / `not` / `endsWith` and
  `$filter`-on-null forms are served from a separate index and require the header **`ConsistencyLevel: eventual`** —
  and, for everything except `$search`, the `$count` query parameter as well. Without them the request fails rather
  than degrading. Plain `eq` filters (for example `$filter=accountEnabled eq false`) work without either.
  `$search` on groups tokenizes only `displayName` and `description`.
- **Paging**: default page size 100, maximum 999, for both `/users` and `/groups`. Follow `@odata.nextLink` verbatim
  until it stops appearing; never construct the next URL. `$skip` is not supported on these collections.
  `$select=signInActivity` (or filtering on it) drops the maximum page size to **500** and triggers stricter
  throttling — `$top=500` is the documented way to keep that case cheap.
- **`$expand` returns a maximum of 20 objects**, which quietly truncates direct reports and group members. Page the
  relationship instead of expanding it whenever the count can exceed 20.
- **Extension attributes each need their own treatment**: `onPremisesExtensionAttributes` 1–15, schema extensions and
  directory extensions are returned only with `$select`; open extensions only with `$expand=extensions`.

## 6. Delta queries — the right way to sync

`GET /users/delta` and `GET /groups/delta` are change tracking, and they are what an incremental directory sync should
be built on.

- The call pattern: request `/delta`, follow `@odata.nextLink` until a **`@odata.deltaLink`** arrives, store the
  deltaLink, and call it next round to get only what changed. A page never carries both links. Tokens are opaque —
  copy the URL, do not parse it.
- **Deletions arrive as `@removed`**, on an object that carries only its `id`. `"reason": "changed"` means deleted (to
  the recycle bin); `"reason": "deleted"` means permanently deleted. Creations and restores carry no annotation, so
  treat "seen in delta without `@removed`" as upsert.
- **Query parameters are encoded into the token.** Specify `$select` on the *initial* request only, then stop sending
  parameters; repeating them on subsequent calls is wrong. And note the consequence: if a property is not in your
  initial `$select`, a change to it will not surface the object in later delta responses at all. Widening the synced
  field set means starting a new delta chain.
- **For users and groups, `$expand`, `$top` and `$orderby` are not supported** in delta. Do not assume any ordering —
  the same object can appear anywhere in the `nextLink` sequence, so merge logic must be idempotent.
- **`$select` does support the `manager` (users) and `members` (groups) navigation properties**, which is how you
  track reporting-line and membership changes incrementally.
- **"Sync from now"**: appending `$deltatoken=latest` returns a deltaLink with no data, skipping the initial full
  read. Useful when a customer only wants changes from the moment they connected.
- Delta is unavailable in External ID external tenants and Azure AD B2C tenants (Platform state).

## 7. Managers and direct reports

Reporting structure is a separate permission and a separate shape, and it has one documented gap worth planning
around.

- **`GET /users/{id}/manager`** — delegated least privilege is **`User.Read.All`**, so org-chart features cannot ride
  on `User.ReadBasic.All`. As documented today, **application permissions are listed as "Not supported"** on this
  endpoint.
- **`GET /users/{id}/directReports`** — delegated least privilege is `User.Read` *and* `User.ReadBasic.All`;
  application least privilege is `User.Read.All`. So app-only reads work here.
- For an app-only sync, get reporting lines by expanding on the users collection —
  `GET /users?$expand=manager` and `GET /users/{id}?$expand=manager($levels=n)` for a management chain — which is
  governed by the list/get permissions rather than the `/manager` endpoint's. **`$levels=n` requires
  `ConsistencyLevel: eventual`.** Confirm this against live behavior for your permission set before designing around
  it; the `/manager` permission table and the `$expand` route do not obviously agree.
- `$expand`'s 20-object cap (§5) applies to direct reports. A VP with 30 reports silently returns 20.
- `employeeLeaveDateTime` is gated separately again: reading it needs **`User-LifeCycleInfo.Read.All`**, and in
  delegated scenarios the admin needs the Lifecycle Workflows Administrator or Global Reader role. `employeeHireDate`
  is an ordinary `$select`-able property; the pair is not symmetric, which surprises HRIS-style connectors.
- Memberships: prefer `GET /users/{id}/transitiveMemberOf` and `GET /groups/{id}/transitiveMembers` when nested groups
  matter — the direct `memberOf` / `members` relationships do not expand nesting — and price them accordingly (§9).

## 8. On-premises-synced attributes

If the customer runs Microsoft Entra Connect (directory synchronization from on-premises Active Directory), the
directory you are reading is partly a mirror, and that changes what you can write.

- **`onPremisesSyncEnabled`** tells you whether a given user is synced. It is the flag to branch on, per user, not
  per tenant — most hybrid tenants hold both synced and cloud-only accounts.
- **For a synced user, the on-premises directory is the source of authority and the synced properties are read-only
  in Graph.** A write against one fails or is silently overwritten on the next sync cycle. This is the single most
  common cause of "our update went through and then reverted".
- **`onPremisesExtensionAttributes` 1–15** (the Exchange custom attributes) each hold up to 1024 characters and are
  where customers most often park employee IDs, cost centers and org codes. They require `$select`, support `$filter`
  with `eq` / `ne` / `not` / `in`, and follow the same source-of-authority rule: writable only for cloud-only users.
  A user previously synced and then cut over to cloud-only stays read-only in Graph and is managed from Exchange
  instead.
- Other on-premises fields worth knowing: `onPremisesImmutableId` (the anchor back to the AD account; subject to
  sensitive-action restrictions so only privileged roles can update it), `onPremisesSamAccountName`,
  `onPremisesDomainName`, `onPremisesUserPrincipalName`, `onPremisesLastSyncDateTime` and
  `onPremisesProvisioningErrors`. All read-only, all requiring `$select`.
- Ask during onboarding whether the tenant is hybrid. It changes which fields are authoritative, which are writable,
  and where a customer's real employee identifier lives.

## 9. Throttling — directory economics

Directory calls are metered by **resource units** on a token-bucket, not by request count, and the costs are not
uniform.

- **Quota per application+tenant pair**, scaled by tenant size: **3,500** resource units per 10 seconds for tenants
  under 50 users (S), **5,000** for 50–500 (M), **8,000** above 500 (L). Per application across all tenants:
  **150,000** units per 20 seconds. Writes are counted separately — 3,000 requests per 2.5 minutes per
  application+tenant pair, 18,000 per 5 minutes per tenant across all apps. Exceeding any of them returns
  **`429 Too Many Requests`**, and 429s can also arrive below the limits under load.
- **Base costs that matter for directory sync**: `GET /users` costs **2**; `GET /groups/{id}/members` **3**;
  `GET /groups/{id}/transitiveMembers` **5**; `POST /directoryObjects/getByIds` **5**; `GET /me/memberOf` and
  `/transitiveMemberOf` **2**; anything else on an identity path **1**.
- **The modifiers are worth real money**: `$select` **decreases** cost by 1, `$top` under 20 decreases by 1,
  `$expand` **increases** by 1. A cost can never fall below 1. So `$select` is not only how you get the fields you
  need (§5) — it is also the cheapest way to call the endpoint, and `$expand` is the most expensive.
- **Reports are throttled far harder than the directory**: identity and access reports allow only **5 requests per
  10 seconds** per app per tenant, and `GET signInActivity` **10 per minute**, full stop. Do not fold sign-in activity
  into a user sync.
- **Honor `Retry-After`**, and prefer delta-with-token over repeated full reads — Microsoft's own stated reason for
  delta is that it lowers request cost and the chance of being throttled (§6).
- `x-ms-throttle-priority` exists; reserve any high priority for user-initiated requests, not background sync.

## Product fact — Unified.to's Entra directory connector, as of 2026-09-20

Recorded from the connector's own configuration on this date. **Scope lists change; confirm the current set with the
connector's owner before you register anything.**

- The connector supports **both** modes against the same platform: a **delegated OAuth2** flow where a user
  authorizes, and an **app-only client-credentials** connection where the customer supplies a client ID, a client
  secret and a tenant ID, requesting `https://graph.microsoft.com/.default`.
- **The delegated scope set is the broad `Directory.*` kind.** Every employee and group scope set carries
  `Directory.Read.All` (read) or `Directory.ReadWrite.All` (write) *alongside* the resource-specific permission — for
  employees, `User.Read` plus `User.Read.All` or `User.ReadWrite.All`; for groups, the same plus `Group.Read.All`.
  Device scope sets are narrower, carrying only `Device.Read.All` or `Device.ReadWrite.All`.
- Every data-bearing scope set includes **`offline_access`** (so refresh tokens come back) together with `openid`,
  `email` and `profile`. The sign-in-only variant requests just `openid`, `email`, `profile`, `User.Read` — and
  **not** `offline_access`, which is correct for a login-only flow and worth not "fixing".
- The authorize and token endpoints are the multitenant `/common` v2.0 ones, and the authorize request sends
  `prompt=select_account`.
- The connector is categorized for HRIS, auth and SAML, so the same registration may be in play for single sign-on as
  well as directory sync.

**What that predicts.** Every one of those delegated scope sets is admin-consent-only, so no customer connects this
self-serve (§3) — and `Directory.Read.All` / `Directory.ReadWrite.All` in the list is exactly the pairing a security
review escalates (§1), especially now that Microsoft's own reference tells customers to avoid directory permissions.
If a customer pushes back, the conversation to have is whether the resource-specific permissions alone would serve
the objects they actually need. Do not quietly drop a scope from a live connector: re-consent is not additive (§3).

## 10. Verify the directory-specific parts

Run the base skill's end-to-end check first (authorize from a second tenant, confirm `refresh_token`, decode `scp` or
`roles`). Then:

1. `GET /users?$top=1` — proves the permission reaches the collection at all.
2. The same call **with `$select`** for every field your connector maps, and confirm each one comes back. Fields
   missing here are a `$select` bug, not a permission bug (§5).
3. `GET /users?$count=true&$filter=userType eq 'Guest'` with `ConsistencyLevel: eventual` — tells you how much of this
   tenant is guests before you sync them as employees (§4).
4. One membership read, transitive: `GET /users/{id}/transitiveMemberOf`.
5. One manager read, in the mode you will actually ship (§7).
6. `GET /users/delta`, then call the returned `@odata.deltaLink` immediately and confirm a near-empty response (§6).
7. If the tenant is hybrid: `$select=onPremisesSyncEnabled,onPremisesLastSyncDateTime` on a synced user, and confirm
   nobody is planning to write to a synced attribute (§8).

| Symptom | Cause |
| --- | --- |
| `403 Authorization_RequestDenied` on `/users` with the scope in the token | Admin consent never granted tenant-wide, or the tenant restricted default user permissions (§3, §4) |
| `AADSTS90094` at the consent screen, ordinary user | An admin-restricted permission; only `User.Read` and delegated `User.ReadBasic.All` avoid this (§3) |
| Admin clicks "Grant admin consent" and it fails | Graph **application** permissions need a Privileged Role Administrator (base §6, §3) |
| `/users` returns 403 for one particular connecting user only | That user is a guest — guests cannot call list users (§4) |
| Delegated connector returns a small, odd subset of the directory | Authorized by a guest, or by a member in a tenant with restricted default permissions (§4) |
| Fields present on `GET /users/{id}` but missing from `GET /users` | No `$select`; the collection returns a fixed short property set (§5) |
| `501 Not Implemented` on a `$select` | A property that can only be read one user at a time (§5) |
| `$filter` with `ne` / `not` / `endsWith`, or `$count`, fails | Missing `ConsistencyLevel: eventual` and `$count` (§5) |
| Direct reports or members capped at exactly 20 | `$expand` returns at most 20 objects — page the relationship (§5, §7) |
| Nested group members missing | `members` is direct-only; use `transitiveMembers` (§7) |
| A property changes upstream but never appears in delta | It was not in the initial `$select`, which is baked into the token (§6) |
| Updates to a user revert after a while | The user is `onPremisesSyncEnabled`; on-premises is the source of authority (§8) |
| Sudden `429` under load | Resource-unit quota; honor `Retry-After`, add `$select`, drop `$expand`, move to delta (§9) |
| Revoking consent does not fully stop directory access | Pre-2021 residue: a Directory Readers/Writers role left on the service principal (Platform state) |
| Delta endpoint unavailable in a customer tenant | External ID external tenant or Azure AD B2C — change tracking is unsupported there (Platform state) |

## Hand off — the directory additions

The base skill's hand-off list applies. Add to the closing summary: the exact directory permission strings requested
and whether each is delegated or application; **which of them required admin consent and which admin role granted
them, per customer tenant**; whether guests are included or filtered in the sync; whether the tenant is hybrid and
therefore which attributes are read-only; and whether the connector runs full or delta sync, plus where the delta
tokens are stored (losing them forces a full re-read).

## Stop and ask

On top of the base skill's conditions, hand back to a human when:

- `Directory.Read.All` or `Directory.ReadWrite.All` is proposed and nobody has checked whether the resource-specific
  permissions would do. That is the permission that ends customer security reviews, and Microsoft has flagged the
  family as possibly deprecated in future.
- The product plan assumes a self-serve connect button for a directory sync. It cannot be one (§3); that is a
  roadmap conversation, not a scope tweak.
- App-only sync is required and no customer tenant has a Privileged Role Administrator available.
- Someone wants to change the scope set on a live connector — re-consent can revoke existing tenant-wide grants.
- Write access to the directory is requested. `User.ReadWrite.All` and `Directory.ReadWrite.All` are a different
  category of ask, and the granular `User-*` write permissions may cover the actual need instead (Platform state).
- The customer is hybrid and the design writes back to attributes owned by on-premises Active Directory (§8).
- Guest/B2B users are in the directory and nobody has decided whether they are in scope (§4).
- The tenant is an Entra External ID external tenant or an Azure AD B2C tenant — different query and sync
  capabilities, effectively a different task.

## References

The base skill carries the shared Entra registration references. These are the directory-specific ones; verified
2026-09-20, every link returned HTTP 200.

- Microsoft Graph permissions reference (`User.*`, `Group.*`, `Directory.*`, `Organization.*`) — https://learn.microsoft.com/en-us/graph/permissions-reference
- Microsoft Graph permissions overview, incl. limited information for inaccessible member objects — https://learn.microsoft.com/en-us/graph/permissions-overview
- user resource type (every property, and which require `$select`) — https://learn.microsoft.com/en-us/graph/api/resources/user
- List users (default property set, page sizes, guest restriction) — https://learn.microsoft.com/en-us/graph/api/user-list
- Get a user — https://learn.microsoft.com/en-us/graph/api/user-get
- Update a user — https://learn.microsoft.com/en-us/graph/api/user-update
- group resource type — https://learn.microsoft.com/en-us/graph/api/resources/group
- List groups — https://learn.microsoft.com/en-us/graph/api/group-list
- List group members — https://learn.microsoft.com/en-us/graph/api/group-list-members
- List group transitive members — https://learn.microsoft.com/en-us/graph/api/group-list-transitivemembers
- List a user's transitive memberOf — https://learn.microsoft.com/en-us/graph/api/user-list-transitivememberof
- Get a user's manager — https://learn.microsoft.com/en-us/graph/api/user-list-manager
- List a user's direct reports — https://learn.microsoft.com/en-us/graph/api/user-list-directreports
- directoryObject resource type — https://learn.microsoft.com/en-us/graph/api/resources/directoryobject
- Get directory objects by ID — https://learn.microsoft.com/en-us/graph/api/directoryobject-getbyids
- Get organization — https://learn.microsoft.com/en-us/graph/api/organization-get
- onPremisesExtensionAttributes resource type — https://learn.microsoft.com/en-us/graph/api/resources/onpremisesextensionattributes
- Advanced query capabilities on Microsoft Entra ID objects (`ConsistencyLevel: eventual`) — https://learn.microsoft.com/en-us/graph/aad-advanced-queries
- OData query parameters — https://learn.microsoft.com/en-us/graph/query-parameters
- Paging Microsoft Graph data — https://learn.microsoft.com/en-us/graph/paging
- Use delta query to track changes — https://learn.microsoft.com/en-us/graph/delta-query-overview
- Get incremental changes for users — https://learn.microsoft.com/en-us/graph/delta-query-users
- Get incremental changes for groups — https://learn.microsoft.com/en-us/graph/delta-query-groups
- user: delta — https://learn.microsoft.com/en-us/graph/api/user-delta
- group: delta — https://learn.microsoft.com/en-us/graph/api/group-delta
- Microsoft Graph throttling guidance — https://learn.microsoft.com/en-us/graph/throttling
- Microsoft Graph service-specific throttling limits (identity resource units) — https://learn.microsoft.com/en-us/graph/throttling-limits
- Microsoft Graph best practices — https://learn.microsoft.com/en-us/graph/best-practices-concept
- Default user permissions in Microsoft Entra ID (member vs guest vs restricted guest) — https://learn.microsoft.com/en-us/entra/fundamentals/users-default-permissions
- Restrict guest access permissions — https://learn.microsoft.com/en-us/entra/identity/users/users-restrict-guest-permissions
- B2B collaboration overview — https://learn.microsoft.com/en-us/entra/external-id/what-is-b2b
- Microsoft Entra Connect Sync overview (hybrid/on-premises attributes) — https://learn.microsoft.com/en-us/entra/identity/hybrid/connect/how-to-connect-sync-whatis
- Microsoft Entra service limits and restrictions — https://learn.microsoft.com/en-us/entra/identity/users/directory-service-limits-restrictions
- Privileged roles and permissions in Microsoft Entra ID — https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/privileged-roles-permissions
