Microsoft Entra ID Connector
The Entra ID connector imports users, groups, and group memberships
from a Microsoft Entra (formerly Azure AD) tenant into IdentityMesh
through the Microsoft Graph API. It runs as a standard IdentityMesh
plug-in DLL — drop it into the engine’s Connectors/ folder, give it
a tenant ID and an app-only client secret, and the engine handles
scheduling, watermarking, and projection into the mesh.
This runbook covers import, which is unchanged since phase 1, and the group write-back that shipped in phase 2: the connector creates Security and Microsoft 365 groups, and converges their members and owners, through Microsoft Graph. User write-back (creating users, updating attributes, disabling accounts) is still on the roadmap and is called out explicitly at the end of this document.
At a glance
| Property | Value |
|---|---|
| Connector name | EntraId |
| Direction | Import users, groups and memberships; export groups (new in this release) |
| Object types | User, Group (membership imports as the group’s member attribute); GroupMember on export only |
| Authentication | App-only — ClientSecretCredential against Graph |
| Watermark style | Per-object-type Graph delta cursors (@odata.deltaLink) |
| Delta lifetime | ~30 days; full re-sync if the connector idles longer |
| Throttling behaviour | Honours Retry-After on HTTP 429 / 503, up to 5 retries |
| Required permissions | Import: User.Read.All, Group.Read.All, GroupMember.Read.All. Group export: Group.ReadWrite.All, GroupMember.ReadWrite.All (Graph application permissions) |
If you operate both an on-premises Active Directory and Entra, run this connector alongside the bundled Active Directory connector — they project into the same mesh objects, and the merge behaviour is governed by the join rules configured in the Admin UI.
Prerequisites
You will need:
-
An Entra tenant where you can create app registrations. The tenant ID is the directory GUID visible at the top of the Entra portal.
-
A Global Administrator (or someone with the targeted directory role to grant Microsoft Graph application permissions). Permission grants are tenant-wide, so this step is usually performed by a directory admin and not the connector operator.
-
An IdentityMesh deployment with:
- The Entra ID connector DLL in the host’s
Connectors/folder. Both the Sync Engine MSI and the Relay Agent MSI install it; nothing to copy (seerelay-agent.md). - Read access to the
IM_Secretstable (or to your Key Vault / Vault backend if you’ve migrated secrets persecrets-keyvault.md/secrets-vault.md).
- The Entra ID connector DLL in the host’s
-
Outbound HTTPS to
graph.microsoft.comfrom whichever host runs the import — the central sync engine, or a relay agent if you have routed this connector through one. No inbound firewall rules are needed.
Step 1 — Register an Entra application
-
Sign in to https://entra.microsoft.com as a Global Administrator.
-
Navigate to Identity → Applications → App registrations → New registration.
-
Use the following values:
Field Value Name IdentityMesh-EntraConnectorSupported account types Single tenant Redirect URI (leave blank — app-only auth) -
After creation, note the Application (client) ID and the Directory (tenant) ID from the Overview blade. You will need both later.
Step 2 — Grant Microsoft Graph application permissions
On the new app’s API permissions blade:
-
Click Add a permission → Microsoft Graph → Application permissions.
-
Add the following permissions:
Permission Why it’s needed User.Read.AllRead users via the /users/deltaendpointGroup.Read.AllRead groups via the /groups/deltaendpointGroupMember.Read.AllEnumerate group members via /groups/{id}/membersGroup.ReadWrite.AllGroup export only — create groups, set owners, delete groups GroupMember.ReadWrite.AllGroup export only — add and remove group members -
Click Grant admin consent for <your tenant> to authorise the permissions. The Status column should change to “Granted for <tenant>” with a green check mark.
If you only import, grant the three read permissions and stop there.
With those alone the connector cannot create, modify, or delete
anything in your tenant — Graph rejects any write because no write
permission has been granted. Add the two ReadWrite permissions only
when you enable group write-back (see “Group write-back” below), and
grant admin consent again after adding them.
User-PasswordProfile.ReadWrite.All is not part of this connector.
It is needed only if you use the IdentityMesh Vault to rotate Entra
user passwords through the same app registration; see
vault-operations.md.
Step 3 — Create a client secret
On the Certificates & secrets blade:
- Click New client secret.
- Description:
IdentityMesh import — rotated YYYY-MM-DD. - Expiry: choose 12 months to align with the rotation cadence in
secret-rotation.md. Shorter is fine; longer is discouraged. - Copy the secret VALUE immediately — Entra only displays it once. The Secret ID is not the secret; you want the column labelled Value.
Calendar the rotation now. The Entra portal will silently let the
secret expire and the connector will start emitting 401 Unauthorized
errors at the time the secret crosses its expiry. Treat the rotation
the same way as any other Tier 1 secret in your environment.
Step 4 — Provision the secret in IdentityMesh
The connector reads its client secret through IdentityMesh’s secret
store. Use a stable secret reference name like
secret://entra/<tenant-name>/clientsecret.
Store it with the secretscli tool that ships with the Sync Engine,
run on the engine host:
secretscli set secret://entra/contoso/clientsecret "<paste-the-Value-from-step-3>"
The tool DPAPI-encrypts the secret with the scheme described in
secrets-and-dpapi.md and stores it in
IM_Secrets. Confirm it with secretscli exists <ref>.
If you have migrated to Azure Key Vault
(secrets-keyvault.md) or HashiCorp Vault
(secrets-vault.md) instead of DPAPI, store the
secret in your vault under the same reference name. IdentityMesh
resolves the reference at run time — the connector configuration is
the same either way.
Step 5 — Configure the connector
Create the connector through the Admin UI or by POSTing to
/api/connectors. The configuration is two JSON documents.
ConfigJson
{
"TenantId": "00000000-0000-0000-0000-000000000000",
"GroupSelectionFilter": null,
"PageSize": 999,
"ImportUsers": true,
"ImportGroups": true,
"ImportMemberships": true,
"ExternalIdAttribute": "id",
"ObjectTypes": [
{ "name": "User", "attributes": ["id", "userPrincipalName", "displayName", "mail", "department", "jobTitle", "accountEnabled", "onPremisesSyncEnabled", "onPremisesSecurityIdentifier"] },
{ "name": "Group", "attributes": ["id", "displayName", "mailNickname", "groupTypes", "securityEnabled", "onPremisesSyncEnabled"] }
]
}
| Field | Default | Notes |
|---|---|---|
TenantId | (req) | The directory GUID from Step 1 |
ObjectTypes | absent | Optional per-type attribute lists — see “Choosing attributes per type” below. When present it decides which types import, and ImportUsers / ImportGroups are ignored |
GroupSelectionFilter | null | Optional Graph OData filter — see “Scoping the group set” below |
PageSize | 999 | Graph caps users at 999. Reduce if you see request-timeout pressure |
ImportUsers | true | Set to false to skip the user delta query entirely |
ImportGroups | true | Set to false to skip the group delta query entirely |
ImportMemberships | true | Set to false for very large tenants — see “Throttling and retries” |
ExternalIdAttribute | "id" | id (object GUID) is recommended. userPrincipalName is allowed but UPN can change |
Choosing attributes per type
Without ObjectTypes, the connector selects a built-in property
list for users and another for groups, which is what every connector
saved before this key existed still does. With it, each entry names
a type (User or Group, nothing else is accepted) and the Graph
properties to select; an empty list means the built-in one, never an
attribute-less import. A type that is absent from the array is not
imported at all.
The Admin Portal’s Entra form edits this array with a Discover
button that lists Graph v1.0’s properties for each type. Discovery
reads Graph’s public schema, so it works before the connector has
credentials. The properties the connector itself needs are always
loaded whatever the list says and are locked in the editor: id,
the anchor attribute and the hybrid provenance trio
(onPremisesSyncEnabled, onPremisesSamAccountName,
onPremisesSecurityIdentifier) on users; id, groupTypes,
securityEnabled, mailEnabled and onPremisesSamAccountName on
groups. The effective list becomes the $select on the type’s delta
query, which is one request for the whole type, so a property the
tenant rejects fails that type’s import rather than one attribute;
the connector logs the $select it sent when that happens.
Changing a type’s list invalidates that type’s delta link: the connector keeps a fingerprint of the selected properties in the watermark and, when it changes, runs a full read of that type on the next import rather than resuming a delta that would carry the old projection. The form says so when you change the list. If a relay agent older than this release ran the connector while the list was being changed, run one full import after upgrading the agent.
AuthJson
{
"Mode": "ClientSecret",
"ClientId": "11111111-1111-1111-1111-111111111111",
"ClientSecret": "secret://entra/contoso/clientsecret"
}
The ClientSecret field is a secret reference, not the plaintext
secret. IdentityMesh resolves it at run time and only the
resulting bearer token is held in memory for the lifetime of one
import.
What gets synced
User attributes
| Graph property | Mesh attribute | Notes |
|---|---|---|
id | ExternalId | Default external ID; immutable GUID |
displayName | displayName | |
userPrincipalName | userPrincipalName | Sign-in name; can change |
mail | mail | May be null for accounts without a mailbox |
givenName | givenName | |
surname | surname | |
jobTitle | jobTitle | |
department | department | |
companyName | companyName | |
officeLocation | officeLocation | |
mobilePhone | mobilePhone | |
businessPhones | businessPhones | Array → comma-joined string in the mesh |
accountEnabled | accountEnabled | Boolean |
userType | userType | Member or Guest |
usageLocation | usageLocation | Two-letter country code |
Manager hierarchy ($expand=manager), profile photos, mailbox-only
properties, and custom security attributes are not imported in
phase 1 — see “What’s still deferred”.
Group attributes
| Graph property | Mesh attribute | Notes |
|---|---|---|
id | ExternalId | |
displayName | displayName | |
mailNickname | mailNickname | |
mail | mail | |
description | description | |
groupTypes | groupTypes | Array → comma-joined; Unified for M365 groups |
securityEnabled | securityEnabled | |
mailEnabled | mailEnabled | |
visibility | visibility | Public / Private / HiddenMembership |
onPremisesSyncEnabled | onPremisesSyncEnabled | New in this release. true for a group synced from on-premises AD, so the portal can show it as AD-managed |
onPremisesSecurityIdentifier | onPremisesSecurityIdentifier | New in this release. The on-premises SID (S-1-5-...) when the group is synced from AD |
Group members
Membership is imported on the group, as a member attribute holding
the members’ object GUIDs joined with ; — the same shape as an Active
Directory group’s member. Users and nested groups are both listed. A
group with no members has no member attribute at all.
Membership is deliberately not imported as objects of its own. A
standalone membership row can never become a mesh relationship: the
join engine only joins an object to a mesh object of the same type, and
an inbound flow holds one value per attribute. Edges come from a
multi-valued reference attribute on the group, so that is what the
connector imports. (GroupMember still exists as an export type —
it is how membership adds and removes are written back to Graph.)
To turn the list into mesh membership you need three things:
- The
member/Groupschema row:reference, multi-valued. The installer’s database step creates it on every install and upgrade, so an upgraded system already has it. A database updated outside the installer needs the database step run (see Running the database step yourself) before the services start. - An inbound attribute flow on this connector for the
Grouptype:member→member. Inbound flows are rule-driven; without the rule the attribute never reaches the mesh. - Join rules for
UserandGroupthat anchor onid. The engine matches each member GUID against the mesh anchor, so users anchored onuserPrincipalNamenever resolve.
The engine then writes one member relationship from the group to each
resolved member, prunes the ones no longer listed on the next import,
and skips members with no mesh object yet (they resolve on a later run).
Vault scopes and self-service groups count these edges as membership.
Scoping the group set
A typical Entra tenant has many more groups than IdentityMesh needs to
manage — distribution lists, dynamic security groups, license-bearing
groups, and so on. Use GroupSelectionFilter to narrow the set.
Examples:
{ "GroupSelectionFilter": "startswith(displayName,'IM-')" }
Only groups whose display name begins with IM-. A common convention
is to prefix groups that should flow through IdentityMesh.
{ "GroupSelectionFilter": "securityEnabled eq true and mailEnabled eq false" }
Only pure security groups (skip distribution lists and Microsoft 365 groups).
{ "GroupSelectionFilter": "groupTypes/any(c:c eq 'Unified')" }
Only Microsoft 365 (Unified) groups.
The filter is a Graph OData filter — see the Graph $filter docs for the full grammar.
Watermark behaviour
Graph delta queries return an @odata.deltaLink URL when pagination
exhausts. IdentityMesh stores that URL verbatim as the connector
watermark and passes it back on the next run; Graph then returns only
the rows that have changed since.
The connector encodes one delta link per object type into a tiny JSON wrapper so a single watermark string covers everything:
{ "u": "<users delta link>", "g": "<groups delta link>" }
Memberships have no cursor of their own. Graph’s group delta reports a
membership change as the group itself reappearing in the feed, so the
connector re-reads the full member list of every group the delta
returns and the group’s member attribute always carries the whole
list.
Delta link expiry
Graph delta links are valid for approximately 30 days of idle time (Microsoft does not publish an exact value, and the practical ceiling depends on tenant load). If a delta link expires, the next request returns HTTP 410 (Gone) and the connector surfaces the error.
Operator action when a delta link expires:
- Note the expiry in the run history.
- Trigger a one-off full sync for the connector through the
Admin UI (“Run now” with mode = Full). This bypasses the stored
watermark and walks the entire
/users/deltaand/groups/deltafeeds end to end, then stores fresh delta links. - Resume normal scheduled delta sync.
If your operational schedule guarantees runs at least every 7 days, delta-link expiry should never be observed.
Throttling and retries
Graph enforces per-application rate limits. Bulk delta queries on large tenants can occasionally trip them.
The connector handles Retry-After automatically:
- On HTTP 429 (Too Many Requests) or 503 (Service Unavailable), the
connector sleeps for the duration in the
Retry-Afterresponse header (or 5 s × attempt if the header is absent). - It retries up to 5 times before surfacing the error to the engine.
If you see Retry-After log entries during normal operation, lower
PageSize from 999 to 250 — fewer rows per request reduces the
chance of long-running queries that count against the per-resource
budget.
For very large tenants, consider:
- Splitting users and groups across two connectors (one with
ImportUsers=true, ImportGroups=falseand one inverse). - Disabling membership reads on the user/group connector and adding a third connector dedicated to memberships.
Security model
- App-only auth — no user impersonation, no consent prompts. The connector signs in as itself, so audit logs in Entra attribute the reads to the registered app, not to a human.
- Outbound only — the connector calls
https://graph.microsoft.com. No inbound paths. - Read-only unless you opt in — the import path never calls a
write endpoint. Group write-back needs the two
ReadWritepermissions from Step 2 and only ever touches groups; the connector has no code path that writes a user. - Secret in transit — the client secret never leaves the IdentityMesh secret store unencrypted; a bearer token resolved from it is held only in memory for the duration of one import.
- Logging — bearer tokens, secret values, and refresh tokens are never written to logs. Delta links are truncated in informational log entries (full values still go to the watermark store, which is protected the same way connector configs are).
Group write-back
Group write-back is new in this release; treat the first rollout as a pilot on a scoped group set.
The exporter handles the Group object type: it creates groups and
converges their members and owners through Microsoft Graph. A
User change in an export batch is not thrown as an error; it comes
back as a failed confirmation with an explanatory message, so the
engine records the limitation against that queue item instead of
confirming a change it never made or aborting the batch.
What the exporter does for a group:
- Create — creates a Security or Microsoft 365 group according to
the
entraGroupKindattribute the projection flows, and records the new object id as the connector link.mailNicknameis set on create only; later updates never rewrite it. - Retry after a partial create — if a create wrote the group but the confirmation was lost (a timeout, a restart), the retry looks the group up by mail nickname and adopts it rather than creating a duplicate. Adoption applies only to a group IdentityMesh created; the connector never claims a pre-existing tenant group by name.
- Members — reads the group’s current user members and converges
them to the projected
memberset. Membership is authoritative only when the projection says so (self-service groups flowimMembershipAuthoritative); an authoritative empty set empties the group. Non-user members that authoritative membership leaves behind are reported in the confirmation. A convergence of more than 500 membership changes logs a warning before it starts, so a runaway projection is visible in the log rather than only in Graph. - Owners — when the rule flows a target attribute named exactly
owners, everymanagedByedge is resolved to an Entra object id and the owner set is converged. Owner removal is narrowed so the exporter never strips an owner it did not add. - Delete — removes the group when its projection precondition stops holding, for example when a self-service group is purged.
A group export that wrote nothing is never confirmed, so a transient Graph failure is retried rather than marked done. The Graph client is keyed on its credential and shared across the batch.
Additional application permissions are required beyond the read-only
set in Step 2: Group.ReadWrite.All and GroupMember.ReadWrite.All.
Grant admin consent again after adding them.
The two self-service projection templates (Self-Service Group Export (AD) and Self-Service Group Export (Entra ID)) are applied from the
connector’s Projection → Apply template page and are the intended
way to drive this exporter; see
self-service-groups.md.
What’s still deferred
The following are not implemented and will be added in a follow-up phase:
- User export / write-back — creating users, updating attributes,
and disabling accounts. A
Userchange in an export batch is returned as a failed confirmation with an explanatory message, so the engine surfaces the limitation cleanly rather than silently dropping changes or failing the whole batch. - Manager hierarchy —
$expand=manageradds an extra cost per user. Phase 2 will add it as an opt-in flag. - Profile photos — large binary blobs are out of scope for the generic attribute store.
- Custom security attributes — Entra’s tenant-scoped custom attribute schema differs from the standard property bag and needs its own projection rules.
- Mailbox properties —
aboutMe,responsibilities, etc. live on the user but are populated by Exchange Online and are inconsistent across mailbox-less accounts. - Batched membership reads — phase 1 issues one
/groups/{id}/memberscall per group. Phase 2 will batch up to 20 of these into a single/$batchrequest, materially reducing latency on large tenants.
Troubleshooting
401 Unauthorized on every page
- The client secret has expired or been rotated outside IdentityMesh. Generate a new secret and update the secret store entry.
- The app’s admin consent has been revoked. Re-grant on the API permissions blade.
403 Forbidden on /users/delta or /groups/delta
- The required Graph application permission is missing or not granted. Re-check the API permissions blade and the Status column.
410 Gone
- The stored delta link has expired. Trigger a Full-mode run; see “Delta link expiry” above.
Slow imports
- Check the connector log for
Retry-Afterwarnings — Graph is throttling. LowerPageSizeor split the connector. - Disable
ImportMembershipsif you don’t yet need group-membership data in the mesh. Group-member reads dominate runtime on tenants with thousands of groups.
High row count on every delta run
- The user delta endpoint emits a row whenever any tracked
property changes — including transient changes like
lastSignInDateTime. The connector requests a tight$selectset to limit this, but property changes outside that set still count. This is expected Graph behaviour and not a bug.
Cross-references
secret-rotation.md— rotating the Entra client secret on a 12-month cadence.secrets-and-dpapi.md— the default DPAPI-backed secret store used by the helper script.secrets-keyvault.md— pointing the connector at Azure Key Vault instead of DPAPI.secrets-vault.md— pointing the connector at HashiCorp Vault instead of DPAPI.relay-agent.md— running this connector on a relay agent in a remote network instead of the central engine.