Entra Directory Sync Developer Guide - edmozley/freeitsm GitHub Wiki
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.
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.
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.
TRAP:
source_refis read outside this import. From 4.8.0,intuneAssignPrimaryUsers()joins Intune's deviceuserIdto it to give an unheld laptop to its primary user (Asset reconciliation - Developer Guide ยง6b). Keep it the bare object id; store anything else there and assets silently stop being assigned.
entraFindExisting() looks in this order:
-
source_refequals the object id. This survives a rename. -
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', andentraImportPerson()logs a conflict whateversync_on_conflictsays.
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.
TRAP: the refusal from
uq_user_sso_provider_useris 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.
entraSyncRun() takes dsyncLock() for a live run. Preview does not need it.
TRAP:
GET_LOCKnames are per MySQL server, not per database, sodsyncLockName()includes the database. Two installs on one server each have a provider 1.
-
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_PEOPLElimits who is imported. -
ConsistencyLevel: eventualis sent on every request. Microsoft's documentation says advanced queries do not support$expand, yet$expand=manageralongside this header worked against a real directory, as did thetransitiveMembers/microsoft.graph.usercast 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.
Read-only, in includes/people.php. ๐ A group is seen through its provider's company: peopleGroupScope() adds AND (p.tenant_id IS NULL OR p.tenant_id IN (accessible)) on auth_providers, because a group's name can say a lot about a client. Members are limited separately by peopleScope() on users.tenant_id.
// The People list's group filter - a group the analyst may not see filters to NOBODY:
[$gs, $gArgs] = peopleGroupScope($conn, $analystId, 'p');
$where .= " AND EXISTS (SELECT 1 FROM directory_group_members m
JOIN directory_groups g ON g.id = m.group_id
JOIN auth_providers p ON p.id = g.provider_id
WHERE m.user_id = u.id AND g.id = ? $gs)";| Function | For |
|---|---|
peopleHasGroups() |
The tab, on every People page - one LIMIT 1 row |
peopleGroups() |
The filter: active groups with a member count each |
peopleGroupRows(), peopleGroupDetail()
|
The Groups tab and a group's page (inactive included, flagged) |
peoplePersonGroups() |
A person's page |
peopleCompanyGroups() |
A company's card - by membership, not by the provider's company: with one global Entra shared by several companies, every group belongs to the global provider |
TRAP:
peopleGroups()counts the members of every group. UsepeopleHasGroups()wherever the question is only "is there one?" - an Entra tenant can hold thousands of groups, and the People header is drawn on every page.
Every reader catches SQLSTATE 42S02 (peopleGroupTablesMissing()) and answers "no groups" before Database Verification.
userDeleteBlockedByDirectory() in includes/users.php is the rule for deleting one person by hand, asked by api/tickets/delete_user.php after its company check:
// Not imported, or imported by a source that no longer exists: nothing would bring them back.
if (!$row || (int)$row['is_managed'] !== 1 || $row['provider_id'] === null) return null;- Refused while the source exists, even switched off - switching it on would recreate them.
- Once the source is deleted,
fk_users_auth_provider(ON DELETE SET NULL) clearsauth_provider_id, and they are deletable. That also mends people a deleted source left withis_managed = 1. - ๐ด A tool that undoes an import on purpose (the planned Remove imported people) must not ask it.
- Tickets โ Users greys the button from
is_managed+source_id, whichget_users.phpalready returns.
Group moves: entraLogMembershipChanges() writes one group_join / group_leave entry per person into directory_sync_entries (action is VARCHAR(16)), only for a group imported before - a first import would "join" every member.
There is no per-source schedule: the import is the scheduler's directory_sync job (--all). system/includes/directory_schedule.php โ dsyncScheduleNotice() renders one line under Run from jobScheduleSummary() in includes/job_scheduler.php:
return ['mode' => ..., 'interval' => ..., 'at' => 'HH:MM install time', 'next_due' => 'UTC',
'runner' => 'scheduler' | 'fallback' | 'none'];A source that is off, or has importing off, says it is not imported automatically, because --all skips it. The job registry entry names its own affects_key (affects_directory_sync_entra), since the English of an existing key never changes.
The script is cron/directory_sync.php. ๐ด Never delete scripts/directory_sync.php: release 1.8.0 told administrators to schedule that path, and it is a guarded one-line require of the new one.
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.
| 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 |
tests/people-groups-scope.php |
A disposable database with two companies besides Default. Who sees which groups and members |
tests/directory-managed-users.php |
A disposable database. The delete rule (source on, off, deleted) and group-move entries |
tests/directory-sync-schedule.php |
A disposable database. The schedule line in every state |
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.