GLPI 11 plugin to sync assets from VMware Workspace ONE UEM (AirWatch)
  • PHP 52%
  • Python 37.1%
  • JavaScript 7%
  • Shell 3.6%
  • CSS 0.3%
Find a file
2026-10-28 16:48:06 +01:00
front v1.6.33: les erreurs de synchro remontent en JSON avec le message exact (plus de carte GLPI générique), isolation par appareil et par profil 2026-10-28 10:22:38 +01:00
locales 1.6.37: nom composé {airwatch:OS}/{airwatch:OSVersion} = plateforme courte (Android/Apple/Windows) + numéro de version 2026-10-28 16:05:32 +01:00
src 1.6.37: nom composé {airwatch:OS}/{airwatch:OSVersion} = plateforme courte (Android/Apple/Windows) + numéro de version 2026-10-28 16:05:32 +01:00
tests 1.6.27: sync OS/hardware/network + status menu fix + name same-run writeback 2026-10-26 21:47:22 +01:00
tmp 1.6.35: un port réseau GLPI par carte AirWatch (DeviceNetworkInfo) 2026-10-28 14:09:50 +01:00
tools e2e: adaptation du login au flux SPA de GLPI 11 (csrf via meta tag index.php) 2026-10-28 16:48:06 +01:00
.gitignore Add .gitignore (build artifacts stay out of the tree, releases carry the zip) and the release-zip builder 2026-10-20 09:50:09 +02:00
airwatchsync.css Add dedicated Field mapping tab: dropdown-driven GLPI<->AirWatch field mappings 2026-10-19 16:35:50 +02:00
airwatchsync.js Add dedicated Field mapping tab: dropdown-driven GLPI<->AirWatch field mappings 2026-10-19 16:35:50 +02:00
airwatchsync.png Restore full plugin v1.1.1: sync engine, AirWatch client, mirror itemtype, config page, mock test tools; fix api_url normalization and mock pagination 2026-10-18 20:23:21 +02:00
airwatchsync.xml 1.6.37: nom composé {airwatch:OS}/{airwatch:OSVersion} = plateforme courte (Android/Apple/Windows) + numéro de version 2026-10-28 16:05:32 +01:00
deploy_test.py Restore full plugin v1.1.1: sync engine, AirWatch client, mirror itemtype, config page, mock test tools; fix api_url normalization and mock pagination 2026-10-18 20:23:21 +02:00
hook.php 1.6.18: composed AirWatch name, platform/status mapping tables, asset-tab device detection, GLPI-style delete buttons, drop Parc menu entry 2026-10-22 22:08:00 +02:00
README.md 1.6.35: un port réseau GLPI par carte AirWatch (DeviceNetworkInfo) 2026-10-28 14:09:50 +01:00
setup.php 1.6.37: nom composé {airwatch:OS}/{airwatch:OSVersion} = plateforme courte (Android/Apple/Windows) + numéro de version 2026-10-28 16:05:32 +01:00

AirWatch Sync — GLPI plugin

Synchronise devices enrolled in VMware Workspace ONE UEM (AirWatch) into GLPI assets (Computers or Phones), with per-entity profiles, per-field synchronisation directions, Basic or certificate (CMSURL) authentication and a dry-run mode.

Repository / releases: https://git.emitlinks.com/emitlinks/airwatchsync The current version is defined in setup.php (AIRWATCHSYNC_PLUGIN_VERSION).

How it works

  • On each run the plugin pulls the enrolled devices from the UEM console (/api/mdm/devices/search, paginated, with an optional hard cap).
  • Every device is linked to at most one GLPI asset. The link is stored in the mirror table glpi_plugin_airwatchsync_devices (itemtype, items_id, aw_device_id, entities_id, last_sync, action, raw). If a device has no mirror row yet, the plugin falls back to matching the GLPI asset by its serial number (the GLPI serial field, or the console's InternalDeviceIdentifier when the device has no serial).
  • Field values flow according to the field mappings (see below): each mapping has exactly one direction, AirWatch → GLPI or GLPI → AirWatch.
  • GLPI assets that no longer appear in the console can be marked as deleted (is_deleted = 1) — off by default.
  • A daily GLPI cron task (GlpiPlugin\Airwatchsync\SyncEngine::sync, 86400 s) runs the synchronisation; it can also be triggered from the UI ("Run synchronisation now", "Dry run") or from the API (below).
  • Each run of a profile writes its statistics (fetched / created / updated / unchanged / ignored / errors) which are shown on the Synced devices tab.

Features

  • Pulls /api/mdm/devices/search with pagination and a configurable device cap.
  • Creates a GLPI asset per AirWatch device, or updates the existing one.
  • Platform → itemtype mapping (default: Android/Apple → Phone, Windows/MacOS/Chrome → Computer), editable per profile in a correspondence table (one row per platform, the GLPI asset type as a dropdown).
  • AirWatch status → GLPI state mapping, resolved into existing glpi_states entries only (editable per profile in a correspondence table; a state that no longer exists leaves the asset state untouched instead of being created).
  • AirWatch device name template: the console device name can be composed from GLPI and AirWatch fields (e.g. {glpi:name} - {glpi:users_id} {airwatch:OperatingSystem}) and is pushed back to the console on every synchronisation.
  • Model / manufacturer are resolved into GLPI dropdowns automatically.
  • Per-entity sync profiles: each GLPI entity can have its own console URL, API key, auth mode, certificate, default state, device link template and field mappings. An entity without a profile inherits the nearest ancestor's profile, up to the global profile (entity 0).
  • Two authentication modes:
    • Basic — console username / password, plus the API key sent as the aw-tenant-code header.
    • Certificate (CMSURL) — a PKCS#7 signature of each request, as required by the UEM console's "certificate authentication". The certificate is uploaded from the interface; no server path is ever asked for.
  • Dry run: a full synchronisation that writes nothing to GLPI nor to the console, and reports every change that would have been applied.
  • Applications synchronisation: the applications installed on each device in AirWatch are pulled (GET /api/mdm/devices/{id}/apps, with a fallback to the new /apps/search endpoint) and populated into the "Item > Software" list of the GLPI asset, creating the GLPI software entries when missing. The feature is enabled per asset type in the profile configuration — computers (Computer assets) and/or smartphones & tablets (Phone assets) — and can be filtered by platform. Applications that disappear from AirWatch can be unlinked (opt-in).
  • Operating system, hardware and network synchronisation (1.6.27+): the OS family/version, RAM, CPU and storage and the network cards of each device are written into the GLPI asset (OS tab, Hardware tab, Network ports). Each network card from the AirWatch DeviceNetworkInfo array (Name, MACAddress, IPAddress, Vendor, ConnectionType) becomes its own GLPI network port — the card name is the port name, the vendor and the connection type are stored in the port comment, and the card IP is attached as an IP address. When DeviceNetworkInfo is absent, the top-level MacAddress / IPAddress fields are used as a single-card fallback. Every section is toggleable per profile and per asset type (computers / smartphones & tablets). Assets that carry a GLPI inventory agent (automatic update system) are never touched for these sections — the agent inventory always wins. Apple devices expose their storage under DeviceCapacity in the current v0 payload, which the plugin reads as a fallback after TotalDiskCapacity / StorageCapacity.
  • Per-asset actions: a tab on each Computer/Phone form ("AirWatch Sync") with a link to the device page in the console, a "Sync this device" button and a "Dry run" variant.
  • Deep links to the UEM console: the device name on the asset tab (and in the "Synced devices" list) links to the device summary page (default template {base}/AirWatch/Device/Details/Summary/{device_id}, placeholders {base}, {device_id}, {tenant}; fully overridable per profile).
  • French interface translation (locales/fr_FR.po / .mo).

Install

  1. Download the zip from the latest Forgejo release: https://git.emitlinks.com/emitlinks/airwatchsync/releases
  2. Copy the airwatchsync folder into <glpi>/plugins/ (or use Administration > Plugins > Import a plugin).
  3. In Administration > Plugins, install AirWatch Sync. If the plugin shows as "Not updated", click Update — until the migration runs, its hooks are not loaded (no menu entry, its pages return 404).
  4. Configure it: Administration > Plugins > AirWatch Sync > Configure. The same page is reachable from the Assets menu (AirWatch devices → Configuration) and from the Configuration link on any Computer/Phone form.

Configuration

The configuration page has three tabs: Connection, Field mapping, Synced devices. The Connection tab is split into labelled sections (General, Console, AirWatch API, Authentication, Network, GLPI, Field mappings) so each parameter is easy to find.

Sync profiles

Every row of the "Sync profiles" table is a profile bound to one GLPI entity. The global profile (entity 0) always exists and cannot be deleted.

  • Add profile for entity… creates a profile for an entity that has none yet (entities that already have one are greyed out); the new profile starts with the values inherited from its nearest ancestor.
  • Edit / Modify opens the profile in the form below.
  • Delete removes the profile (with confirmation). An optional checkbox also deletes the entity's own field mappings so no orphan rules remain; the entity then falls back to the inherited profile.

Connection tab — sections and fields

Section Field Meaning
General Profile name Display name (defaults to the entity name)
General Run this profile Master switch; disabled profiles are skipped by manual sync and by the cron
Console Console UI URL Base URL of the UEM console UI, used to build the device deep links (falls back to the host of the API URL)
Console Device link template URL template for the device deep link. Placeholders: {base}, {device_id}, {tenant}. Leave empty for the default {base}/AirWatch/Device/Details/Summary/{device_id}
AirWatch API API base URL e.g. https://uem.company.com. No trailing path needed — the plugin appends /api/mdm/devices/search unless the URL already contains it
AirWatch API API key the value of the aw-tenant-code header (the tenant code from the UEM console)
Authentication Authentication mode Basic (console username / password) or Certificate (CMSURL)
Authentication API username / password UEM console API credentials, required for Basic mode
Authentication Client certificate Uploaded file (.pfx, .p12, .pkcs12, or bare .pem + private key in the second field). The page shows the file name, certificate subject and expiry. The "Remove the certificate from this profile" checkbox deletes it. No path is ever asked for: the uploaded file is the source of truth
Authentication Certificate password PKCS#12 password (.pfx files)
Network Verify SSL certificate Disable for self-signed internal consoles
Network Request timeout (seconds) Per HTTP request
Network Devices per page Paging size of the API call (max 500)
Network Maximum devices per sync Safety cap (0 = no cap)
GLPI Default GLPI asset type Computer or Phone, used when the platform mapping has no entry for the device's platform
GLPI Default GLPI state for enrolled devices Dropdown of the states that exist in GLPI (glpi_states). Empty by default: when no state is mapped, the asset state is left untouched (no "In use" forcing)
GLPI Default entity for new assets Dropdown of the entities reachable by your profile (global profile only). A stored id that no longer exists is never used — the page shows a warning and the next reachable entity is applied
GLPI Create missing assets Create a GLPI asset for console devices without a match
GLPI Update existing assets Push the AirWatch → GLPI mapped values onto existing assets
GLPI Mark GLPI assets missing from AirWatch as deleted Sets is_deleted = 1 on linked assets absent from the console
Field mappings (table) The field mapping table, editable inline (see the next section)
Field mappings AirWatch name template Text field, e.g. {glpi:name} - {glpi:users_id} {airwatch:OperatingSystem}. {glpi:field} reads the GLPI asset (dropdown ids like users_id resolve to their display name), {airwatch:field} reads the console device. When set, the composed name is written to the AirWatch Name field on every synchronisation (create and update). Unknown variables become an empty string

Buttons at the bottom of the tab: Save, Test connection (opens a connection to the console and reports the device count), Dry run (full sync without writing) and Run synchronisation now. A Delete button is shown for non-global profiles.

Certificate (CMSURL) authentication, in detail

With a client certificate the UEM console expects each request to be signed with a PKCS#7 (S/MIME) signature sent in the Authorization: CMSURL'1 … header. The plugin does this transparently on the server side:

  1. Upload the certificate in the Authentication section. Recommended format: a PKCS#12 (.pfx) containing the certificate and its private key — the UEM console lets you download exactly this from Settings > API Access > Certificate authentication.
  2. If you only have a bare PEM certificate, upload it plus the private key in the second upload field.
  3. Enter the PKCS#12 password when there is one.
  4. Save, then Test connection.

The server needs the openssl binary (used to extract the key pair from the bundle and to build the signature). Uploaded files are stored under <glpi>/files/_plugins/airwatchsync/certs/; the profile only stores the file name — you never have to type a server path.

Field mapping (tab 2)

The mapping table is Champs GLPI | sens de la synchro | Champs AirWatch — every column is a dropdown:

GLPI field direction AirWatch field
name (Name) AirWatch → GLPI Name
serial (Serial number) AirWatch → GLPI SerialNumber
manufacturers_id (Manufacturer) AirWatch → GLPI Manufacturer
computermodels_id (Model, Computer) AirWatch → GLPI DeviceModel
phonemodels_id (Model, Phone) AirWatch → GLPI DeviceModel
states_id (State) AirWatch → GLPI DeviceStatus
… locations, phone number, UUID, contact, asset number (disabled by default)
  • Direction has exactly two values, one direction per mapping:
    • AirWatch → GLPI — the console is the source of truth; GLPI is overwritten with the console value.
    • GLPI → AirWatch — GLPI is the source of truth; the GLPI value is pushed to the console (the console API must allow device updates, otherwise the error is reported in the sync output).
  • GLPI field: the real columns of glpi_computers / glpi_phones (read live from the database) plus the custom fields declared in GLPI (glpi_fielddefs).
  • AirWatch field: the fields returned by /api/mdm/devices/search (DeviceID, Name, DeviceModel, SerialNumber, Imei, Manufacturer, AssetNumber, SiteName, UserName, PhoneNumber, DeviceStatus, Uuid, …) plus a "Custom field…" option that reveals a text input for any other console field name.
  • Asset type filter: apply the rule to all assets, only Computers, or only Phones (needed because, e.g., DeviceModel maps to computermodels_id for Computers and to phonemodels_id for Phones).
  • Transform: text (plain value), dropdown (resolve the text into the matching GLPI dropdown entry, creating it when missing), status (AirWatch status → GLPI state via the status map), date (normalise the value into a GLPI datetime).
  • Enabled checkbox per row, plus an Add mapping row (all dropdowns) and Restore defaults (re-seed the default set).
  • Inheritance: mappings belong to an entity. An entity that has no rules of its own shows the inherited rules (from its nearest ancestor, up to the global profile) read-only, with a one-click "Override for this entity" button that copies them into the entity so they become editable.

Platform and status correspondence tables (below the field mapping table, in the Field mappings section of the Connection tab). One row per entry, added/removed live with an Add row and Remove buttons:

  • Platform → GLPI asset type: the AirWatch platform name is a text field (platforms change across console versions), the GLPI type is a dropdown (Computer / Phone). Used to choose the itemtype when a device has no mirror row yet; falls back to the "Default GLPI asset type" when the platform is not listed.
  • AirWatch status → GLPI state: the AirWatch status is a text field, the GLPI state is a dropdown built from the states that exist in GLPI — no free text, so a typo or a missing state cannot happen. Only existing states are resolved; a state deleted from GLPI after being mapped is simply not applied (the asset state is left untouched) instead of being re-created in glpi_states.

Defaults:

platform: Android → Phone, Apple → Phone, Windows → Computer,
          MacOS → Computer, Chrome → Computer

status:   Enrolled → In use, Active → In use,
          Not Enrolled → Not in use, Unenrolled → Not in use,
          Retired → Out of service, Lost → Lost, Stolen → Lost,
          Pending → Waiting, EnrollmentPending → Waiting

(The status defaults are shown pre-filled in the table only when the profile has none stored yet; saving with them keeps the defaults.)

Synced devices (tab 3)

Read-only list of the mirror table: the GLPI asset (linked, with its entity), the AirWatch device ID, the console deep link, the last sync date, the last action (created / updated / unchanged / ignored / not created / missing) and the statistics of the last run.

Asset tab ("AirWatch Sync")

Computer and Phone forms get an extra tab which, when the asset is linked to a console device, shows:

  • the device name as a link to the UEM console device page (built from the profile's console URL + link template);
  • the AirWatch device ID and the last synchronisation (date + action);
  • Sync this device — synchronise only this asset against the console;
  • Dry run — the same, reporting the changes without writing anything.

These are the only way to update a single asset without running the whole profile.

Technical note: GLPI 11 renders item forms with Twig and only dispatches pre_show_item / post_show_item from CommonGLPI::displayStandardTab() (reached through ajax/common.tabs.php, never on the initial page load). An inline panel injected by a hook is therefore not possible; the tab registered with CommonGLPI::registerStandardTab() is the supported extension point.

Manual / API synchronisation

All actions are also available from plugins/airwatchsync/front/sync.php (JSON in / JSON out, requires the config UPDATE right and a valid GLPI CSRF token; a GET ?endpoint=token hands out a fresh token):

endpoint body Effect
test {} or {"profile": N} Connection test, returns the device count
sync {"dry_run": "1"?} or {"profile": N} Full synchronisation (one profile, or all enabled profiles); the dry run writes nothing
sync_one {"itemtype", "items_id", "device_id"?, "dry_run"?} Synchronise one GLPI asset (matched via the mirror row or the serial number)

Example:

# fresh token, then a dry run of profile 1
T=$(curl -s -b cookies '…/plugins/airwatchsync/front/sync.php?endpoint=token' \
      | php -r 'echo json_decode(stream_get_contents(STDIN))->csrf_token;')
curl -s -b cookies \
     -H "X-Requested-With: XMLHttpRequest" \
     -H "X-Glpi-Csrf-Token: $T" \
     -d 'endpoint=sync&profile=1&dry_run=1' \
     '…/plugins/airwatchsync/front/sync.php'

Sending X-Requested-With: XMLHttpRequest makes GLPI preserve the CSRF token (a plain form POST consumes it) — that is why the plugin's buttons submit as AJAX and re-fetch a fresh token when one gets rejected.

UEM console API (reference)

The plugin talks to the /api/mdm (MDM) API of a Workspace ONE UEM console, verified against the console's own OpenAPI 3.0 export (mdmv1):

Call Purpose
GET /api/mdm/devices/search?page=N&pagesize=M Device listing. Response DeviceSearchResultExtended: {Total, Page, PageSize, Devices: [DeviceExtended]}. Query params: page, pagesize, user, model, platform, lastseen, ownership, lgid, compliantstatus, seensince, orderby, sortorder.
GET /api/mdm/devices/{id} Single device (full Device object, ~69 fields). The plugin only falls back to the paged search if this route 404s.
PUT /api/mdm/devices/{id} Update a device. The current consoles answer 204 No Content (empty body) — the plugin treats any 2xx with no body as success. The only writable device fields are AssetNumber, Ownership and DeviceFriendlyName.
GET /api/mdm/devices/{id}/apps?page=N&pagesize=M Legacy installed-app list. Response DeviceAppsResult: {Total, Page, PageSize, DeviceApps: [DeviceApplicationInfo]}. Items carry ApplicationName, Version, AppVersion, ApplicationIdentifier (bundle id) and an integer Status: 1 Pending Install, 2 Installed, 3 Pending Removal, 4 Removed, 5 Unknown.
GET /api/mdm/devices/{deviceUuid}/apps/search?page=N&pagesize=M New installed-app list (addressed by the device UUID). Response AppV1PagedSearchResultsModel: {TotalResults, app_items: [AppItemV1Model]} with snake_case fields: name, installed_version, assigned_version, bundle_id, application_uuid, installed_status ("Installed"/"NotInstalled"), size, …

Notes:

  • The plugin reads both field generations: old consoles return DevicePlatform/DeviceModel/DeviceStatus/SiteName/LastSyncDate, current ones return Platform/Model/EnrollmentStatus/LocationGroupName/LastSeen. An alias table resolves either spelling, so legacy field mappings keep working.
  • The write-back of the name template uses DeviceFriendlyName (the writable field on current consoles); the read side resolves DeviceFriendlyName first, then DeviceReportedName, then Name.
  • App items are normalised regardless of the endpoint's key style (CamelCase vs snake_case); integer statuses are translated to their labels before the "keep/drop" filter, so apps that are Removed (status 4) or NotInstalled are dropped from the GLPI asset.
  • The OS, hardware (RAM/CPU/storage) and network (MAC/IP) details are written by this plugin when the corresponding section is enabled, except for assets that have a GLPI inventory agent: those keep their agent- written values (see the per-profile OS / Hardware / Network sections).

Scheduling

A cron task is registered on install: GlpiPlugin\Airwatchsync\SyncEngine::sync, every 86400 s, listed in Administration > Setup > Automatic actions with the comment "Synchronise the devices enrolled in Workspace ONE UEM (AirWatch)". It runs with GLPI's cron session, so each enabled profile it runs must be able to create/update assets in its entity, with its own credentials and certificate.

Requirements

  • GLPI 10 or 11 (tested on 11.0.11), PHP with curl and json, and — for certificate authentication — the openssl binary.
  • A UEM console API user:
    • Basic mode: a console account with API access (Settings > API Access) plus the API key / tenant code.
    • Certificate mode: a certificate issued from Settings > API Access > Certificate authentication (PKCS#12 preferred).

Troubleshooting

  • No "Configure" entry in the plugin list / plugin pages return 404: the plugin is in the Not updated state (a version bump without a migration). Run Administration > Plugins > Update, or from the CLI: php bin/console plugin:install airwatchsync --allow-superuser then php bin/console plugin:activate airwatchsync.
  • Saving redirects to the GLPI central page (GLPI 11): a non-AJAX POST with an expired CSRF token redirects to front/central.php when the URL carries the _redirected_from_profile_selector parameter. Since 1.6.10 the plugin's forms use per-form tokens and absolute actions (never inheriting the current URL's query string). Since 1.6.31 they are submitted as plain (non-AJAX) POSTs and retried once with a fresh token, so a rejected save can never redirect the browser. Make sure you are on ≥ 1.6.31 and reload the page (Ctrl+F5) after an upgrade.
  • "GLPI a rejeté la requête" (GLPI rejected the request) on sync or save (GLPI 11): a reverse proxy or WAF in front of GLPI may strip custom request headers. As an AJAX request (X-Requested-With) GLPI only validates the X-Glpi-Csrf-Token header, so a stripped header turns every request into a 403 even though the token in the body is valid. Since 1.6.31 the sync calls and the forms are sent as plain POSTs without the XHR flag, so GLPI validates _glpi_csrf_token from the body instead — the body always reaches the server. Make sure you are on ≥ 1.6.31 and hard-reload the page (Ctrl+F5).
  • GLPI under a sub-directory (e.g. https://host/glpi/): the plugin builds all its URLs from GLPI's root_doc, so nothing has to be configured. If a deployed copy still misbehaves, it predates 1.6.13 — upgrade.
  • 403 on an asset / asset tab not usable: this is a GLPI rights issue on the asset's entity, not a plugin fault (it reproduces with the plugin disabled).
  • An entity created outside GLPI does not appear in the entity dropdowns: GLPI's Symfony cache may hold stale sons_cache / ancestors_cache entries. Run php bin/console cache:clear as the web user — running it as root breaks the cache directory ACLs (fix with chown -R www-data:www-data <glpi>/files/_cache).
  • Certificate shows "unknown" subject/expiry: the uploaded file is not a readable certificate; upload a .pfx (with its password) or a PEM pair.
  • CMSURL test fails ("Mac verify error"): the certificate does not match the one expected by the console — re-download the PKCS#12 from the UEM console and upload it again.
  • HTTP 401 errorCode 1006 ("The data or the url used to sign the message is different from the one provided in the request"): the request path and the signed data did not match. Fixed in 1.6.14 — the signature is now built over the exact absolute path of the URL requested (no query string, case preserved), verified against the reference PowerShell CMSURL implementation. If you still see it on 1.6.14, check that the API base URL you configured has no trailing path and that the certificate registered in the console is the one you uploaded.
  • GLPI 11 phones have no IMEI column: the IMEI stays in the mirror table and is written into the asset comment.
  • The plugin does not delete GLPI assets: mark_missing only sets is_deleted = 1, never removes the asset.

Development

  • tools/mock_airwatch.php — a small AirWatch mock (paginated /api/mdm/devices/search, PUT device updates, Basic/CMSURL auth checks); the synchronisation is tested end-to-end against it.
  • tools/build_mo.py — compiles locales/fr_FR.po into locales/fr_FR.mo (GLPI does not compile .po files itself; the .mo must ship in the release zip).
  • tools/check_locale.py — audit: every string used in the code must exist in the .po.
  • tools/build_release_zip.py — builds the release zip for the current version (binaries go to the Forgejo releases only, never into the git tree).

License

GPL v3.