Skip to content

Entra Directory Sync Developer Guide

Ed Mozley edited this page Nov 2, 2026 · 1 revision

Entra directory sync - developer guide

How FreeITSM imports people and groups from Microsoft Entra ID through Microsoft Graph, and the identity rules that keep one person's record from going to somebody else.

For how to use it, see Importing people from Microsoft Entra ID. For the rules every source shares, see Directory sync - developer guide and Extending directory sync.

What it is

It is a third transport beside LDAP and CardDAV, not a new protocol. An OpenID Connect row in auth_providers is a people source when its issuer is Entra (entraIsEntraIssuer()) and sync_enabled is on.

FreeITSM pulls from Graph. It is not SCIM, where the identity provider pushes to an address FreeITSM would have to expose, so it also works on an install the internet cannot reach.

File Job
includes/entra_sync.php Graph calls, mapping, matching, groups, entraSyncRun()
includes/directory_sync.php The shared policy layer, plus dsyncLock()
includes/oidc.php oidcLinkRequester(), the first sign-in link
api/auth/oidc_callback.php Finds an imported person by the oid claim
system/sso/entra.php The admin page
api/system/save_entra_sync.php Writes only the import half of the provider row
api/system/test_entra_*.php, search_entra_groups.php Connection test, mapping test, group picker

Schema: the graph_* columns on auth_providers, plus directory_groups and directory_group_members.

πŸ”΄ Identity

Graph and sign-in know one person by two different names.

Value Where it lives
Graph the object id (oid) user_sso_identities.source_ref
Sign-in a pairwise sub, different per application user_sso_identities.subject

uq_user_sso_provider_user allows one identity row per person per provider. Storing the object id as the subject would give one person two subjects and break their next sign-in. So a person the import creates gets the placeholder subject entra:<oid>, which no real sub can equal, and oidc_callback.php swaps in the real sub at first sign-in.

That is why entraMapUser() returns guid = '': it keeps dsyncLinkIdentity(), which writes subject, out of it.

πŸ”΄ Matching

entraFindExisting() looks in this order:

  1. source_ref equals the object id. This survives a rename.
  2. dsyncFindExisting(): directory username (the UPN), then email.

A match in step 2 is on a name, and names get reused. A leaver's address given to a new starter arrives with a new object id. So:

TRAP: a record already linked to a different object id is never matched. entraFindExisting() returns 'taken', and entraImportPerson() logs a conflict whatever sync_on_conflict says.

It cannot create a second record instead: uq_users_dir_username and uq_users_email both refuse. The new person is left out and the conflict is visible in the run. There is no screen yet to separate them.

Entra itself refuses two accounts with the same mail, because proxyAddresses must be unique. Sharing an address therefore comes from reuse over time, or from mapping the email field to an attribute with no such rule, such as otherMails.

πŸ”΄ The first sign-in

TRAP: the refusal from uq_user_sso_provider_user is a protection. Somebody whose email matches an account already linked to a different identity must not take it over.

oidcLinkRequester() never replaces a real subject. It replaces a placeholder only, and when the oid claim is present it must match source_ref. It returns false otherwise, and the callback shows an error. The oid lookup in the callback requires the placeholder for the same reason.

This code runs for every OpenID Connect provider, not only Entra.

One import at a time

entraSyncRun() takes dsyncLock() for a live run. Preview does not need it.

TRAP: GET_LOCK names are per MySQL server, not per database, so dsyncLockName() includes the database. Two installs on one server each have a provider 1.

Graph

  • Paging is required. entraGetAll() follows @odata.nextLink. An unpaged read imports the first page and reports success.
  • Chosen groups still read the whole directory. Graph lists users and FreeITSM filters them, up to ESYNC_MAX_SCAN. ESYNC_MAX_PEOPLE limits who is imported.
  • ConsistencyLevel: eventual is sent on every request. Microsoft's documentation says advanced queries do not support $expand, yet $expand=manager alongside this header worked against a real directory, as did the transitiveMembers/microsoft.graph.user cast without $count. Both were checked on one small tenant, so look here first if a Graph update breaks an import.
  • The stored secret only goes to Microsoft. The test and search endpoints refuse a provider whose issuer is not Entra.

Adding a mapped field

Follow ESYNC_ATTR_DEFAULTS, then the steps in Extending directory sync. A mapped name reaches a $select, so entraAttrNameValid() refuses anything that is not plainly a name.

Tests

Test Needs
tests/entra-sync-mapping.php Nothing. Graph user to person array
tests/entra-sync-identity.php A disposable database. Matching, linking, first sign-in, the lock

See Developer tests - integrations. Neither calls Graph: a change to a query needs a run against a real directory. The review that found these rules is in Entra directory sync - PR review.

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally