Entra Directory Sync Developer Guide - edmozley/freeitsm GitHub Wiki

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.

TRAP: source_ref is read outside this import. From 4.8.0, intuneAssignPrimaryUsers() joins Intune's device userId to 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.

๐Ÿ”ด 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.

Groups in People (PR #237)

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. Use peopleHasGroups() 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.

Deleting an imported person (PR #238)

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) clears auth_provider_id, and they are deletable. That also mends people a deleted source left with is_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, which get_users.php already 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.

The schedule line (PR #239) and cron/ (PR #240)

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.

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
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.

โš ๏ธ **GitHub.com Fallback** โš ๏ธ