-
Notifications
You must be signed in to change notification settings - Fork 15
Directory Sync
Status: built and shipped, apart from scheduling. This page records the whole design conversation β the questions, the answers, the reasoning, the three places the first answer was wrong, and what changed once it met a real directory. It is the thinking behind the feature, not the manual for it: for that, see LDAP and Active Directory.
Sections marked Built say how the idea actually turned out, which is not always how it was planned β Β§3.9 in particular records a design note here that was only half right.
The request, verbatim, by email:
"And another thing: how do I import my Active Directory users to assign them the assets?"
Related: LDAP and Active Directory (sign-in, which already exists) Β· LDAP β Developer Guide Β· Setting up LDAP with Docker Β· Multi-tenancy: users and self-service
FreeITSM has had LDAP/AD sign-in since #47. It binds with a service account, gates on a group, and creates the person's record when they first sign in β just-in-time provisioning.
That is a complete answer to "let my staff log in" and no answer at all to the question asked, for one reason:
To assign a laptop to Jane, Jane must already exist. JIT means Jane does not exist until she signs in β and the people you most want to assign assets to are exactly the people who will never sign in.
The no-mailbox warehouse and shop-floor staff the self-service portal was built for are the same population that holds equipment and never logs into anything. So the asset register cannot be populated ahead of the humans arriving, which is the normal direction of work: kit is issued to a new starter before they turn up.
A search of the codebase for any bulk import or sync returned nothing. ldapFindUser() substitutes one login into one filter β it is built to answer "is this person real", not "list everyone".
| Thing | Where | Why it matters here |
|---|---|---|
| Per-company providers | auth_providers.tenant_id |
"sync per company" needs no schema change |
| Multiple providers per company | multiple rows | "one or more sources per company" already possible |
| A stable identity anchor |
user_sso_identities.subject + auth_providers.ldap_attr_guid
|
This is objectGUID. Tracking a person across an OU move or a rename is already architecturally solved β the hard part of question 4 was done before we started |
| Attribute mapping, partially |
ldap_attr_username, _email, _name, _guid
|
The pattern to extend, rather than invent |
| A two-flavour test directory | docker/ldap-test/ |
Samba AD and OpenLDAP, so code cannot silently grow one directory's assumptions |
And one thing that does not exist and blocks everything:
π΄
usershas nois_activecolumn. There is no way to record that somebody has left. The only options today are delete β which destroys asset history, tickets and handover records β or nothing.
These were asked in one go after the survey above. They are answered in the order asked.
Users first, and they are separate problems.
Analysts get permissions, so bulk-creating them is a security event, not a convenience β and analysts already have a reasonable answer in JIT plus a group gate. An analyst who has never signed in does not need to exist.
Asset holders are the exact opposite: the entire point is that they exist before they ever log in.
Already supported. auth_providers.tenant_id and users.tenant_id exist. A sync inherits its provider's company.
Already possible. The interesting part is not the plumbing, it is what happens when the same human appears in two directories β see Β§4, where the first answer to this was wrong.
This is the question that decides whether the feature is safe, and it deserves more caution than the others combined.
Never delete: assets, tickets and handover documents all hang off user_id.
Three rules:
- A sanity brake. If a run finds dramatically fewer people than the last one β more than about 20% missing β it stops and reports rather than deactivating anybody. A typo in a base DN, a service account quietly losing read rights, or a directory being slow would otherwise deactivate an entire company in one pass. That failure would end trust in the feature permanently, and it would be our bug presenting as their disaster.
- N consecutive misses before acting. Missing once is noise. Missing three runs is a fact.
-
Record
last_seen_in_source, so "when did this person stop appearing" is answerable after the event.
Deactivate, never delete β which needs the is_active column from Β§2.
There is a bonus here worth building for deliberately: a deactivated person still holding equipment is an offboarding alert. "Three people who left this month are holding nine items between them" is a report every IT manager wants and almost none have.
q.deleon disabled but still in Sales; y.tanaka disabled and moved to Leavers).
Today users is an authentication record, not a person: id, email, display_name, password_hash, preferred_name, TOTP and MFA columns, auth_provider_id, theme_preference, tenant_id, username.
Proposed additions:
| Field | Why |
|---|---|
is_active |
Β§3.4/Β§3.5 β nothing else will do |
department, job_title
|
"who in Finance has a laptop" |
office |
π only 1 of 566 assets on the development install has a location set. AD's physicalDeliveryOfficeName populates the very field the ticket asset-picker needs and nobody fills in by hand |
phone, mobile
|
contacting whoever is holding a thing |
employee_id |
matching against HR and payroll exports |
manager_id |
π already wanted and absent β catalogue-request approvals (#928 slice 2) has been blocked on this. AD gives you the org chart for free |
directory_username |
Β§4.3 |
last_seen_in_source, sync_source, is_managed
|
bookkeeping for Β§3.4 and Β§3.13 |
Yes, but not as columns. A key/value side table for whatever a particular customer's directory happens to carry, with the fields above kept as real columns because you will want to search and filter on them. Columns for what FreeITSM understands; key/value for what it merely stores.
Essential, not optional β and the test directory proves why: Samba AD uses sAMAccountName, OpenLDAP uses uid, and nothing can guess between them. The pattern already exists in ldap_attr_*; this extends it.
Built, as a tab of its own. The eleven attribute boxes had ended up split across two tabs β four on Signing in, seven on Importing people β which read as two unrelated settings rather than the single map they are. They are now one table: FreeITSM's field on the left, the directory's attribute on the right, in the order the sentence is actually written.
The Test button is the point of it. An attribute name is abstract and has to be taken on trust; employeeID β NW-1011 does not. A mistyped attribute is the mistake nobody catches, because nothing fails β a column is simply empty, and it goes unnoticed for weeks.
An empty result is ambiguous on its own, so the test also lists every attribute the sample person carries. That is what separates "the attribute name is wrong" from "this person has not filled it in":
| Sample | Attributes carried | Result |
|---|---|---|
| Nia Hughes | 38 | every mapped field found a value |
| Wendy Warehouse | 33 | six mapped fields empty β and the list shows she genuinely has none of them |
It tests the values on the form, not the saved ones: testing saved values would only ever confirm the last thing saved, which is not the question anybody is asking. And if neither the sign-in name nor the unique id resolves, it says so outright and in red, because an import would skip that person entirely β the table and the attribute list still render, since those are exactly what show you why.
The best idea on the list. Typing a base DN is where people fail; browsing a tree is where they succeed.
Built. Two things about it were decided during the build rather than here.
A ticked branch means the whole branch, including anything added to it later. The alternative β ticking exactly the OUs you want, and nothing else β was rejected because a new top-level OU then never imports and nothing says so: the new starters are simply absent. So what is stored is the exceptions, not the members: which branches are in, and which have been carved back out of them. Storing every member OU would freeze the selection at today's shape of the directory, which is the same silent-staleness problem wearing a different hat.
That makes "everybody in Staff except Contractors" expressible while still picking up Staff/Legal when HR creates it next year. Unticking something inside a ticked branch writes a carve-out, and the tree draws it struck through.
β οΈ The comma in the suffix test is load-bearing. "Is this DN under that branch?" is a string comparison, and without requiring,before the ancestor,OU=SalesmatchesOU=WholesaleSalesβ so a carve-out silently swallows an unrelated department. A wrong answer that looks exactly like a working feature; asserted in both directions intests/directory-sync-scopes.php.
Head counts are what make a tick checkable. Each branch shows how many people sit in it directly and how many are in the whole branch, using the same filter the import uses β a different one would promise 24 and deliver 19, which is worse than showing no number at all. They come from one paged search rolled up in PHP, not a search per OU.
The concern above was real, and the GUID self-healing was not built. DNs are stored on their own. What was built instead is detection β because the failure has two halves and only one of them had been noticed:
| What changed in AD | What the next run does | Why |
|---|---|---|
| A selected branch is renamed or moved | Refuses, and changes nothing, naming the branch | Importing the branches that do still exist is precisely how the damage happens: everybody in the missing branch looks like they have left, and after sync_deactivate_after runs they are marked as such |
| A carved-out branch is renamed or moved | Proceeds, and says so loudly | The people you excluded start arriving. Unwanted, but additive and undoable β refusing would stop everybody's import over the smaller problem |
The second row is the half the original note missed, and it is the more dangerous one to leave silent: the sanity brake only guards against a sudden drop, so people being added is invisible to it. Nothing in the system would ever have mentioned it.
The check costs one base-scope existence test per stored branch, and there are usually one or two. It is skipped entirely for installs still using a typed starting point, which have always behaved this way β warning on every run of a working setup only teaches people to ignore warnings.
GUID self-healing is still the better answer and still worth doing: it would make a rename a non-event rather than a message. Detection came first because the thing worth killing is the silent wrong answer, and a message you can act on kills it.
Yes, following the pattern of the email send log: a per-run summary plus per-record detail, recording what changed. "47 updated" is useless. "47 updated, 3 deactivated, and here they are" is what somebody would actually read.
Yes, and mandatory for the first run of a new source. A dry run that shows exactly what would be created, updated and deactivated, writing nothing.
This is the natural partner to the sanity brake: together they turn "I hope this is right" into "I can see what it will do".
Built in slice 2 β and then found to be only half-built, which is worth recording because the half that was missing was not the hard one.
A preview produced four numbers: "31 found, 1 added, 1 updated, 0 marked as left". That asks to be trusted, which is the opposite of what a preview is for. The per-person detail had been written to directory_sync_entries since the engine was first built, and get_directory_sync_log.php had served it by run_id the whole time. Nothing ever called it. The information existed, was correct, and was invisible.
Previewing now opens the list of everyone the import would touch, with what would happen to each and β for a change β which field, from what, to what:
| Person | What happens | Detail |
|---|---|---|
| Priya Newstarter | Will be added | Email: p.newstarter@northwind.test; Job title: Sales Executive; Department: Sales |
| Nia Hughes | Will be updated | Job title: 1st Line Analyst β 2nd Line Analyst; Phone: 020 7946 0011 β 020 7946 0099 |
The tense follows the mode. A preview says "Will be added"; a completed import says "Added". Labelling a preview row "Added" states as fact something nobody has agreed to yet, and not-having-happened is the entire value of the feature.
Every row of the import history opens the same view, so a run from three weeks ago answers "updated how, and who?" exactly as readily as the one just performed. Two smaller things surfaced while building it: the detail was printing the database's column names (job_title:) rather than yours, and a person about to be created said only "Would be created." β true, and impossible to check. A new arrival now lists what their record will hold, or says plainly "No details beyond a name", which is itself worth seeing before an import.
asset-management/users.php is read-only today β search, select, view assets, print a handover.
The dilemma raised was: it belongs under Assets, but users also relate to tickets. That dilemma dissolves once you separate two decisions:
- Sync configuration is not in question. LDAP providers are configured at System β SSO, and the sync settings belong beside the directory they describe. Putting them under Asset Management would mean configuring the same AD twice, and the next module that wants directory data makes it three.
- The people screen is the real question, and the industry pattern is consistent: the person is a core record owned by the platform, and modules provide filtered views of it. The module that happened to need people first should not own them.
Decision: one screen, two doors. The screen becomes the canonical directory under System β Users; Asset Management keeps its entry point, linking to the same screen filtered to people holding equipment. One place a person is managed, and nobody has to learn that people live under Assets for historical reasons.
.htaccess β see issue #68 for what that costs when it is done the other way round.
Agreed in principle, but "source of truth" has to be per-field or it will bite.
If AD owns display_name and an analyst corrects a typo in FreeITSM, the next sync silently reverts it, and they conclude FreeITSM lost their edit.
Decision: directory-owned fields are shown read-only in the UI for directory-managed people, with a note saying where the value comes from. If it cannot be edited, nothing is silently overwritten. Fields FreeITSM owns stay editable.
The most useful part of this page. Each was caught by a question rather than by testing, which is worth noting in itself.
The original answer to Β§3.3 offered a setting: when the same human appears in two sources, either match them by email or create two separate records.
The second option does not exist. users.email carries a UNIQUE index (uq_users_email). A second row with the same address is rejected by the database. The setting as described could never have been implemented, and the schema comment had already anticipated the whole question:
-- NULL because a directory (LDAP) user may genuinely have no mailbox β
-- warehouse and shop-floor staff are never given one (GitHub #47). The
-- UNIQUE index below still applies: MySQL permits many NULLs in a unique
-- index, so any number of mailbox-less people coexist while real addresses
-- stay unique.
--
-- β οΈ An absent address MUST be stored as NULL, never ''.π The lesson: check the constraints before designing a setting that depends on them. The database had already made this decision and the design was arguing with it.
Reframed, the setting is still worth having β it is just a different question:
When sync meets somebody who is already here: β Adopt them β attach the directory identity to the existing record (default) β Leave them alone and flag it for review
The consequence that makes this a genuine decision: adopting sets auth_provider_id, and from that moment their portal password stops working. Sign-in takes the pinned-provider branch and refuses the local password. That is either exactly right β the directory becomes the source of truth for authentication too β or an unexpected lockout for somebody who never asked for it.
Two constraints regardless of the setting:
- π΄ Matching is within one company only. Two tenants can legitimately share
admin@, or a contractor's address. Cross-company matching would merge two customers' people: a data leak wearing a convenience costume. β οΈ Email matching means a second directory can take over a record created by the first. Fine when both are yours; less fine for an MSP. An argument for the setting existing, not against the default.
The concern raised: "if my AD username is smithj, it would be annoying to get a mangled username because another company also has a smithj."
Correct, and worth understanding precisely. username is globally unique (uq_users_username), but sAMAccountName is only unique within one directory.
Why global uniqueness is there, and is not simply a mistake: the multi-company portal routes sign-in by email domain (resolveTenantIdForAddress). Somebody with no email has no domain to route on, so when they type smithj there is nothing to say which company they belong to. Global uniqueness is what makes username sign-in work at all in the absence of company context.
So the blast radius is narrow:
| Install | Affected |
|---|---|
| Single company | Never |
| Multi-company, everyone has an email | Never β email is legitimately globally unique |
| Multi-company, mailbox-less staff at two companies sharing a username | Yes |
Decision: split the two jobs that one column was doing.
| Column | Means | Unique |
|---|---|---|
directory_username (new)
|
what the directory calls them β smithj, always, faithfully |
per provider |
username |
what they type into the portal | globally, as now |
Sync stores smithj in directory_username and never mangles it, so matching and re-linking are unaffected by any collision. username is populated only for people who actually sign in β which most mailbox-less asset holders never do β and on the rare genuine collision FreeITSM reports it rather than silently inventing smithj2.
Explicitly not done now: making username unique per tenant. The proper fix is per-company portal URLs so the tenant is known before sign-in, and that is a multi-tenancy feature deserving its own discussion β not something to smuggle in through an AD import.
Sliced so that stopping early still leaves something useful.
| Slice | Contents | Standalone value |
|---|---|---|
| 1 β | Schema (is_active, the person fields, manager_id, directory_username) + the expanded users screen |
Useful with no directory at all β and unblocks catalogue approvals, which has been waiting on manager_id
|
| 2 β | Sync engine: manual run, preview, log, sanity brake. Base DN typed by hand | The actual request answered |
| 3 β | OU browser β , attribute-mapping interface β , preview detail β , scheduling still to do | Makes it pleasant instead of merely possible |
| 4 | Analysts, if still wanted once users are done | Probably unnecessary β JIT may be the right answer for analysts |
Scheduling is the only part of slice 3 outstanding. Everything else in this page is built and tested against the Samba fixture.
| File | Needs the fixture? | Covers |
|---|---|---|
tests/directory-sync-scopes.php |
No | The scope arithmetic: what counts as "under" a branch, overlapping ticks, carve-outs, and the upgrade fallback. 19 assertions, runs anywhere |
tests/directory-sync.sh |
Yes β freeitsm-samba-ad
|
End to end against the real directory: the brake, deactivation, reporting lines, non-ASCII names, OU selection and the missing-branch warnings. 26 assertions, skips cleanly when the container is not running |
β οΈ An end-to-end check that a carve-out worked cannot be a count: excluding the wrong two people satisfies it exactly as well as excluding the right two. 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.
docker/ldap-test/seed-ad-people.sh grows the Northwind test directory to ~30 people across 8 OUs, awkward on purpose, because a tidy fixture would let us build a sync that only works on tidy directories:
-
OU=Contractorsthat must not be synced β proves OU selection selects - two different people with the same display name β matching must not fall back to names
- somebody whose email is another person's username β a near miss
- three accounts with no
mailattribute at all - one person disabled but still in Sales; another disabled and moved to Leavers
- a manager chain three deep, and IT reporting to Finance β a sync assuming managers share a department gets it wrong
- non-ASCII names, an apostrophe, a double-barrelled surname, a deeply nested OU
-
Should adoption clear the local
password_hash? Leaving it is harmless but untidy; clearing it is irreversible. Undecided. - What happens to a person's assets when they are deactivated? The offboarding report in Β§3.5 is the obvious answer, but whether deactivation should prompt for return is not settled.
-
Nested groups as a sync filter β the fixture has them (
NW-All-Staffcontains groups, not people). Sign-in already handles the AD chain matching rule; whether sync should filter by group as well as OU is not decided. - Scheduling β sync belongs on the existing scheduled-task machinery, but nothing has been designed about frequency, overlap or what happens when a run takes longer than its interval. The only part of slice 3 not built.
- OU identity across renames β see Β§3.9. A renamed branch is now detected and reported rather than silently wrong, but storing each OU's GUID and self-healing would make a rename a non-event. Worth doing; not urgent now that it cannot fail quietly.
- LDAP and Active Directory β directory sign-in, which exists today
- LDAP β Developer Guide β how the sign-in code works
- Setting up LDAP with Docker β the test directories
- Multi-tenancy: users and self-service β why the portal routes by email domain
- Email send log β the logging pattern Β§3.10 follows
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
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ π οΈ 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)