-
Notifications
You must be signed in to change notification settings - Fork 15
Directory Sync Developer Guide
How importing people from a directory is built: the scope model, the safety rules, why preview runs the same code, and the traps that produce a wrong answer looking exactly like a working feature.
The plain-language version is Importing people from Active Directory or LDAP. The design conversation β the questions, the reasoning, and the places the first answer was wrong β is Directory sync β the design. To change the feature, see Extending directory sync.
Sign-in is a separate story with separate code: LDAP β Developer Guide.
Asked for by email, quoted in full on the design page. Engine in 9f6f2b-era slice 2; field mapping b2f18f41; preview detail 652c645c; OU browser 93501943; renamed-branch detection 3dfe8728.
| File | Role |
|---|---|
includes/directory_sync.php |
Everything. Scope arithmetic, paged fetch, matching, apply, managers, deactivation, the brake, the run log |
includes/ldap.php |
Reused, not duplicated: ldapOpen(), ldapBindService(), ldapStringifyId()
|
includes/users.php |
USER_PERSON_FIELDS, USER_DIRECTORY_OWNED, userManagerIsSafe()
|
scripts/directory_sync.php |
CLI: --provider=N, --all, --preview, --quiet
|
| File | Role |
|---|---|
api/system/run_directory_sync.php |
Runs one provider, live or preview. Returns the whole run row |
api/system/get_directory_sync_log.php |
?provider_id= recent runs Β· ?run_id= one run, person by person |
api/system/browse_directory_ous.php |
The OU tree plus a head count per branch |
api/system/test_directory_mapping.php |
Reads ONE person and applies the mapping from the posted form values |
api/system/save_sso_provider.php |
Writes the provider, including sync_ou_includes / _excludes via ssoDnLines()
|
| File | Role |
|---|---|
system/sso/provider.php |
The five-tab provider page: connection, sign-in, importing, field mapping, history. The OU tree and the run modal are here |
system/sso/index.php |
The provider list. OIDC still uses its modal; LDAP providers link out to the page above |
| Table | Role |
|---|---|
auth_providers |
sync_* columns, and the ldap_attr_* mapping shared with sign-in |
users |
The person fields β see Extending directory sync Β§1 |
user_sso_identities |
subject = the stringified objectGUID. The identity anchor
|
directory_sync_runs |
One row per run, preview included |
directory_sync_entries |
One row per person per run: action, detail
|
| File | Needs the fixture? | Covers |
|---|---|---|
tests/directory-sync-scopes.php |
No | Scope arithmetic. 19 assertions, runs anywhere |
tests/directory-sync.sh |
Yes | End to end against Samba AD. 26 assertions, exits 0 with SKIP when the container is absent |
tests/people-fields.sh |
Yes | The person fields and the users screen |
docker/ldap-test/seed-ad-people.sh |
β | The fixture: ~30 people, 8 OUs, awkward on purpose |
directory_sync.php requires ldap.php and reuses its connection and bind. The difference is the question being asked:
Sign-in (ldap.php) |
Import (directory_sync.php) |
|
|---|---|---|
| Question | "Is this one person real, and what are they allowed to be?" | "List everyone" |
| Scope | One entry, found by substituting into a filter | Whole subtrees, paged |
| Failure | This person cannot log in | Potentially everybody is marked as having left |
That last row is why this file is disproportionately defensive for its size.
Two lists, not one. sync_ou_includes holds ticked branches; sync_ou_excludes holds carve-outs within them. One DN per line, and dsyncScopes() is the only thing that reads them.
A branch means the whole branch, now and in future. So what is stored is the exceptions, not the members. Storing every member OU would freeze the selection at today's shape of the directory, and nothing would ever say it had gone stale.
dsyncScopes($provider) -> ['includes' => [...], 'excludes' => [...]]
It does three things:
-
Falls back. Empty includes β
sync_base_dnβldap_base_dn. - Lower-cases everything. A DN is case-insensitive; string comparison is not, and every test in this file is a string comparison.
- Prunes overlaps. Ticking a parent and its child drops the child, so one search covers it.
π΄ The upgrade path is the whole reason for rule 1. Both columns NULL is the only state an install predating the OU browser can be in, and it must import exactly who it imported yesterday. Without the fallback, upgrading imports nobody β and then the only thing between that and every person in the company being marked as left is the sanity brake. Asserted explicitly in
tests/directory-sync-scopes.php.
function dsyncDnIsUnder(string $dn, string $ancestor): bool
{
return $dn === $ancestor || str_ends_with($dn, ',' . $ancestor);
}Drop the , and OU=Sales,DC=x tests true against OU=WholesaleSales,DC=x. A carve-out then silently swallows an unrelated department β a wrong answer that looks exactly like a working feature, which is the worst kind. Asserted in both directions.
dsyncDnIsExcluded() runs over each entry after it comes back. This is not laziness: "not under this subtree" is a statement about position in the tree, and an LDAP filter tests attributes. It cannot be expressed as a filter at all.
dsyncFetchPeople() accumulates into $byDn. Overlapping ticks are already pruned, but referrals and aliases can still hand the same entry back twice β and a duplicate is not merely wasted work, it is counted twice by the brake. An inflated baseline is the dangerous half.
Four independent things stop this feature destroying a customer's user list. They are independent on purpose: each one covers a failure the others cannot see.
Runs before anything is written. If a run finds sync_brake_percent fewer people than sync_last_count, it stops with status stopped and changes nothing.
if ($pct <= 0 || $last === null || $last === 0) return null; // no baseline yet
if ($seen >= $last) return null; // grew, or level
β οΈ A preview never becomes the baseline. It changed nothing, so it proves nothing about the state of the world. Only a successful live run writessync_last_count.
π΄ The brake only guards against a DROP. People being added β which is what a vanished carve-out causes β is completely invisible to it. That is why Β§4.4 exists.
Missing once is noise; missing sync_deactivate_after runs is a fact. sync_missed_count increments, and only on reaching the threshold is somebody marked as left. 0 disables it entirely.
Nobody is ever deleted. Assets, tickets and handover documents all hang off user_id.
There is no separate preview implementation to drift from the real one. directorySyncRun($conn, $provider, 'preview') walks exactly the same code with writes suppressed at seven points β if (!$preview) around each UPDATE/INSERT, and dsyncHandleMissing() returning early.
β οΈ A new write must be added inside a$previewguard. A write that escapes one makes preview lie, and preview lying is worse than having no preview: it is trusted precisely because it is supposed to change nothing.
The run log is written either way. That is deliberate β what somebody was shown before they pressed the button is worth being able to check afterwards.
A ticked branch is stored as a DN, and a DN changes when somebody reorganises an OU. Both directions failed silently before this existed:
| Gone | Behaviour | Why |
|---|---|---|
| An include |
throw β the run fails, changes nothing, names the branch |
Importing the branches that do still exist is exactly how everybody in the missing one drifts into being marked as left. Fail-fast beats a partial import |
| An exclude | Runs, logs an error entry, sets the run message |
Unwanted people arriving is additive and undoable. Refusing would stop everybody's import over the smaller problem |
Skipped entirely when sync_ou_includes is empty β installs on a typed base DN have always behaved this way, and warning on every run of a working setup only teaches people to ignore warnings.
The brake message prepends rather than replaces (
$message = $brake . "\n\n" . $message). A vanished branch is very often why the count dropped, and reporting the effect while discarding the cause leaves somebody staring at "far fewer people than last time" with the explanation already thrown away.
dsyncFindExisting() tries three things, in descending order of reliability:
| Order | By | Survives |
|---|---|---|
| 1 |
user_sso_identities.subject (the GUID) |
Renames and OU moves |
| 2 | users.directory_username |
Records linked before GUIDs were captured |
| 3 | users.email |
Somebody who already existed here |
π΄ Step 3 is tenancy-scoped and must stay that way. Two companies can legitimately share an address β a contractor, a shared
admin@β and matching across them merges two customers' people. That is a data leak dressed as a convenience.
An email match is a conflict, not a silent adoption: sync_on_conflict decides between adopt (link them, which stops their portal password working) and flag (change nothing, log it).
'email' => $email !== '' ? $email : null,users.email carries a UNIQUE index. MySQL tolerates many NULLs and exactly one '' β so storing '' for a mailbox-less person means the second one is rejected as a duplicate. The fixture has three no-mail accounts specifically to keep this honest.
manager holds a DN, not a name, and the person it points at may be somebody this very run just created. So dsyncResolveManagers() runs after everybody exists, mapping DN β user_id from $dnToUserId.
userManagerIsSafe() (in includes/users.php) walks the chain before writing, bounded, rejecting cycles. The database cannot express "no cycles" in a self-referencing FK, so this is the only thing standing between an import and an infinite loop in the org chart.
Two tables, deliberately: counts alone are not something anybody can act on.
directory_sync_runs one row per run -> seen/created/updated/adopted/deactivated/conflict/error, status, message
directory_sync_entries one row per person -> action, detail
action is one of create Β· update Β· adopt Β· unchanged Β· deactivate Β· skip Β· conflict Β· error, ordered for display by FIELD(action, 'error','conflict','deactivate','adopt','create','update','skip','unchanged') β attention first, the 400 rows saying nothing last.
detail holds English prose, written at run time, not translation keys. The log is written once and read later; a key would need a locale to render in and the run has no user. dsyncFieldLabel() keeps column names out of it.
π These entries existed and were served by the API from the day the engine shipped. Nothing displayed them for two slices. Preview produced four numbers and asked to be trusted. If you add something to the log, check something renders it.
Every key comes back lower-cased regardless of what you asked for. dsyncValue() handles it; do not index $entry['sAMAccountName'] directly.
Without LDAP_CONTROL_PAGEDRESULTS you get 1000 people and no error. On the 1001st-person directory that reads as "everybody else has left" β which is precisely the input the brake exists to catch, and you would rather not be relying on it. DSYNC_PAGE_SIZE = 500.
DSYNC_UAC_DISABLED = 2. A directory without the attribute reports 0, which reads as enabled β the right default, since we should never invent a reason to deactivate somebody.
ldapStringifyId() (from ldap.php) converts it. Printing it raw produces mojibake; test_directory_mapping.php reports (binary) rather than emitting it.
Every entry point must call decryptValue($provider['ldap_bind_password']) before ldapBindService(). Miss it and the bind fails as "Invalid credentials", which reads like a wrong password rather than an un-decrypted one. Cost an afternoon once.
Splitting a DN naively cuts such a name in half and orphans everything beneath it. Use preg_split('/(?<!\\\\),/', $dn, 2) β see ouBrowseParent().
php tests/directory-sync-scopes.php # no fixture needed
bash tests/directory-sync.sh # needs freeitsm-samba-adBring the fixture up:
docker start freeitsm-samba-ad
bash docker/ldap-test/seed-ad-company.sh && bash docker/ldap-test/seed-ad-people.shπ΄ A count is not a test of a carve-out. "35 people instead of 37" is satisfied exactly as well by excluding the wrong two people. The assertions name who must be present and who must not. The first attempt at that control asserted on contractor names that had been invented rather than looked up, passed, and proved nothing β the real ones are
z.contractorandw.vendor.
β οΈ Pair every "cannot do X" with a positive control.tests/directory-sync.shproves the brake stops a run and that the same run proceeds with the brake off; that real branches produce no warning and that missing ones do. Without the control, "no warning" is indistinguishable from a check that never ran.
The fixture is awkward on purpose β a tidy directory would let us build a sync that only works on tidy directories. Contractors that must not sync, two people with the same display name, an email that is another person's username, three accounts with no mail, one person disabled in place and another disabled and moved, a three-deep manager chain, IT reporting to Finance, and non-ASCII names.
- Scheduling. The CLI exists; nothing drives it. The only unbuilt part of the feature.
- OU identity across renames. Detected (Β§4.4), not self-healed. Storing each OU's GUID would make a rename a non-event rather than a message.
- Custom attributes. The design page proposes a key/value side table; not built.
- Analysts. Import creates users only. JIT is probably the right answer for analysts β bulk-creating them is a security event, not a convenience.
-
msanduktranslations of thessonamespace.
- Importing people from a directory β the user-facing guide
- Directory sync β the design β why it works this way
- Extending directory sync β adding a field, supporting another directory
- LDAP β Developer Guide β sign-in, which shares the connection
- Setting up LDAP with Docker β the fixtures
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- Theming & Dark Mode
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
- MobileβFriendly
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- Problem Management
- Tasks
- Assets
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
- Forms
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)