Skip to content

API Inventory and Evidence Design

Ed Mozley edited this page Oct 20, 2026 · 2 revisions

REST API: custom asset fields, source-aware inventory and change evidence - design (#194)

Status: proposal - nothing here is built yet. This page sets out what I plan to build for issue #194, and why, written before any code so the approach can be checked first. Comments on the issue are welcome. The open decisions are in section 7.

The request, from issue #194 (raised by @Dr4inder):

"Three additional capabilities would make it easier to build reliable external inventory synchronisation and attach technical execution evidence without depending on browser sessions or internal UI endpoints."

In plain words

Three additions to the REST API, each useful on its own, and each able to ship in its own release:

  1. Custom asset fields through the API. The API already returns an asset's custom fields - the ones an administrator adds under Assets β†’ Settings β†’ Fields - but quietly ignores them when you try to write them. A script that records "last scanned by our discovery tool on 3 March" in a custom field gets a success response and nothing saved.
  2. Inventory from another tool, with its source and its date. A discovery tool, an RMM or a CMDB export should be able to send a device's software, network adapters and disks - saying which tool it came from, when the scan was taken, and whether each list is complete. A three-day-old snapshot must not look like a device that just checked in, an incomplete list must not wipe what is already there, and one tool must never erase another tool's data.
  3. Attaching evidence to a change through the API. A deployment pipeline should be able to attach its report (a CSV, a JSON log) to the change it implemented, recorded in the change's audit trail like any upload - without being able to approve, edit or delete anything.

1. The principles

  • Reuse, do not duplicate. Custom fields already have one write path with validation, uniqueness and history (AssetFieldsService::saveValues()); inventory already has one reconciliation path (the agent's); change attachments already have one upload path and one audit line. The API gets the same paths, moved where needed so the UI, the agent and the API all call one function.
  • Silence is a bug. A key that is not understood, a list that is not processed, a report that is ignored as out of date - each is said in the response, never reported as success.
  • Existing callers keep working. The inventory agent, existing REST keys and existing integrations behave exactly as today unless they opt in (acceptance criterion 6).
  • Company boundaries hold. Every operation checks the asset's or change's company against the key's, as the rest of the API does.

2. What FreeITSM does today

Mapped from the code at 3.11.0, not from memory:

The request needs Today Verdict
Write custom field values over REST GET /assets/{id} returns a fields object; POST / PATCH go through AssetsService's fixed field map and ignore fields - and every other unknown key - while answering 200 ❌ Silently ignored
A custom-field write path with validation and history AssetFieldsService::saveValues(): type rules, required fields, per-company uniqueness, one history row per changed field, and unknown keys are refused βœ… Have it, used by the UI and the importer
Import inventory over REST Only the agent endpoint (api/external/system-info/submit), on the older key system, with agent semantics ⚠️ Agent only
Keep the scan time separate from the import time The agent stamps assets.last_seen = now - correct for an agent, which reports what it sees right now ❌ No scan time
Know which tool each row came from asset_disks.source and software_inventory_detail.source exist (agent / intune). Network adapters and physical disks have no source ⚠️ Half there
Not wipe data on a partial report The agent skips a section that is empty (a fix from September 2026, after a WMI blip emptied 226 rows) - but it cannot say "this list is complete and genuinely empty" ⚠️ Partial
One source never erasing another Software is reconciled per source. But software_inventory_detail has a UNIQUE key on (device, application) - so the same application reported by two sources on one device collides, despite the code assuming they coexist. No install has hit it yet (no device has both agent and Intune software today); a second import source would make it common ❌ Latent bug
Replayed or out-of-order reports No report identity, no ordering ❌ None
Upload a change attachment over REST GET /changes/{id} lists attachments; there is no upload operation. The UI's upload logic lives in api/change-management/upload_attachment.php, not in ChangesService ❌ UI only
Audit an attachment, with its hash ChangesService::auditAttachment(): an audit line with the file name and sha256 βœ… Have it
Retry an upload without a duplicate Nothing ❌ None

3. The proposal, in three parts

Part A - Custom asset fields through the API

POST /assets and PATCH /assets/{id} accept the same fields object GET returns, keyed by the field keys shown there:

PATCH /assets/412
{ "fields": { "rmm_device_id": "DEV-88213", "last_source_scan": "2026-03-03T02:14:00Z" } }
  • Written through AssetFieldsService::saveValues() - the same type rules, required fields, per-company uniqueness and per-field history as the UI and the spreadsheet importer. No second validation path.
  • PATCH semantics: a key you leave out is unchanged; a key sent as null clears the value, refused if the field is required - exactly as the UI does.
  • Errors are specific: a key the asset does not have (unknown, or from a set not attached to its type) is a 422 naming the key and listing the keys the asset does have; a bad value is a 422 naming the key and the rule it broke; a duplicate is a 409.
  • One transaction. On POST, the asset and its fields are created together - a field that fails validation leaves no half-made asset behind. On PATCH, core fields and custom fields commit or fail together.
  • The asset's type decides its fields. A request that changes asset_type_id and sends fields in the same call is checked against the new type's fields.
  • Unknown top-level keys are the one compatibility question - see section 7, decision 1.

Part B - Source-aware inventory import

A new operation:

POST /assets/{id}/inventory
{
  "source": "lansweeper",
  "source_device_ref": "LS-0042-ABC",
  "observed_at": "2026-03-03T02:14:00Z",
  "report_id": "lansweeper-LS-0042-ABC-20260303T021400Z",
  "sections": {
    "software":         { "complete": true,  "items": [ { "name": "7-Zip", "publisher": "Igor Pavlov", "version": "23.01" } ] },
    "network_adapters": { "complete": false, "items": [ { "name": "Ethernet", "mac": "00:11:22:33:44:55", "ip": "10.0.0.12" } ] },
    "disks":            { "complete": true,  "items": [] }
  }
}

Source identity.

  • source is a short name for the tool (lansweeper, rmm, cmdb-export). The names FreeITSM writes itself - agent, intune, proxmox, vcloud - are reserved, so an API key can never touch their data.
  • source_device_ref is the tool's own id for the device. FreeITSM remembers which asset each (source, device reference) belongs to, per company. A report that names a device reference already linked to a different asset is refused with 409 device_ref_conflict - never silently moved. Re-linking is a deliberate act (decision 3).

Time.

  • observed_at is when the tool took the scan; FreeITSM also records when the report arrived. Both are kept, per source, per asset, and returned by GET /assets/{id} as inventory_sources: [{source, source_device_ref, observed_at, imported_at, sections}].
  • assets.last_seen is not touched. It keeps its meaning - "the FreeITSM agent last reported" - so importing an old snapshot never makes a device look as if it has just checked in (acceptance criterion 2).
  • Older never overwrites newer. A report whose observed_at is older than the newest already applied for that source and device is not applied, and the response says so ("result": "stale"), with the newer time. Per section, so a fresh software list and an older adapter list from the same tool can each be judged on their own.
  • Replays are harmless. report_id (or a hash of the body when it is absent) is remembered for 30 days; sending the same report again returns the first result without applying anything twice (acceptance criterion 4).

Completeness - the four cases, documented and tested (acceptance criterion 3):

A section is... What happens to this source's rows Other sources' rows
Complete, with items Brought exactly into line: added, updated, and anything this source reported before but not now is removed Untouched
Complete, empty ("complete": true, "items": []) All of this source's rows removed - the tool says the device genuinely has none Untouched
Partial ("complete": false) Items added or updated; nothing removed Untouched
Omitted Nothing happens Untouched
Failed - the item list itself is invalid Nothing happens; the response names the section and why Untouched

One implementation, not two. The agent endpoint's software, adapter and disk sync becomes a shared set of functions (includes/asset_inventory.php), parameterised by source. The agent becomes one caller - source agent, each non-empty section treated as complete, an empty one skipped exactly as today, and last_seen stamped as today - and the REST operation is another. The agent's behaviour does not change.

The schema changes this needs:

  • asset_network_adapters and asset_physical_disks gain source (existing rows become agent), so adapters and drives can be reconciled per source like software and volumes already are.
  • The software unique key becomes (device, application, source) - which also fixes the latent collision in section 2.
  • A new asset_inventory_sources table: one row per (asset, source), with the device reference, observed_at and imported_at per section, and the last report ids.

The response says what happened, per section: applied (added / updated / removed counts), stale, skipped (omitted), or failed with the reason - and the asset's current inventory sources. A report with any failed section is 207-style partial success: the good sections apply, the failed ones are named.

Manual data is never touched. Inventory sections only. The asset's own fields (hostname, model, serial, custom fields) are changed only through PATCH (Part A), so a manually maintained value is never overwritten by an import.

Part C - Evidence on a change through the API

POST /changes/{id}/attachments          (multipart/form-data: file, and optional client_reference)
GET  /changes/{id}/attachments
GET  /changes/{id}/attachments/{attachment_id}/content
  • One upload path. The UI's upload logic moves into ChangesService::addAttachment(), which the UI endpoint and the API both call: the same file-type and size rules, the same protected storage, the same sha256, and the same audit line in the change's history - attributed to the analyst the key acts as, with the key's name.
  • A narrow permission. A new key permission, change_attachments.create, separate from changes.update. A key holding only that (and change_attachments.read) can add evidence and nothing else - it cannot vote, approve, edit, cancel or delete. Read only access to Change Management still refuses it, as everywhere.
  • Evidence never moves the change. Uploading does not change its status, does not start or invalidate an approval round, and does not touch its text. It is allowed at any stage, including after implementation - which is when an implementation report exists.
  • Retries reconcile. A caller may send a client_reference (or an Idempotency-Key header). If an attachment with that reference already exists on the change, the call returns it with 200 and stores nothing - so a pipeline whose first upload timed out can safely send again. GET /changes/{id}/attachments?client_reference=... answers "did it arrive?" without uploading.
  • The response returns the attachment's id, name, size, type, sha256, who and when - a stable reference.

4. The issue's acceptance criteria

# Criterion Where
1 Save and retrieve custom fields over REST, with meaningful errors Part A
2 Imports keep their source and observation time; stale data never looks current Part B - Time
3 Complete, partial, omitted and empty sections have documented, tested behaviour; one source cannot erase another Part B - the four cases
4 Replayed or out-of-order reports create no duplicates and regress nothing Part B - Time
5 A scoped key uploads a change attachment, seen in the change view with attribution and audit; uncertain results reconcile Part C
6 Existing consumers, agents, company boundaries and approval rules unaffected Section 1; Part B - one implementation; Part C - evidence never moves the change; decision 1
7 Payloads, permissions, errors and retries in the API docs and OpenAPI All parts: the docs page, the OpenAPI description and the REST API wiki pages

5. Found while mapping it

  • The software list cannot hold two sources for one device (section 2) - fixed as part of Part B.
  • The REST API answers 200 to a field it ignores, on PATCH /assets/{id}. Part A deals with fields; the general case is decision 1.
  • A third, older software endpoint (api/external/software-inventory/submit) writes software rows without saying which source they are, so they default to agent. It will call the shared functions too, as source agent.

6. The order to build it in

Each part is useful on its own and can ship in its own release, smallest first:

  1. Part A - custom fields. Small; mostly wiring to code that exists.
  2. Part C - change evidence. Contained; the upload moves into the service first, so the UI benefits too.
  3. Part B - inventory. The largest: the schema changes, the shared sync functions (with the agent moved onto them and its behaviour unchanged), then the REST operation.

7. Open decisions

# Decision My recommendation
1 Unknown top-level keys on POST / PATCH /assets - today ignored with a 200 Keep accepting them, but say so: the response carries "ignored": ["colour", ...]. Refusing them outright would be the cleaner contract, but it would break existing scripts on upgrade (criterion 6). A key could opt in to strict refusal later.
2 Who may import inventory A new key permission, asset_inventory.write, separate from assets.update - an integration that feeds inventory should not also be able to rename assets.
3 A device reference that moves to another asset (a reimaged machine, a replaced board) Refuse with 409 and name the asset it is linked to. Re-linking is an explicit DELETE /assets/{old}/inventory/{source} first, which also removes that source's rows from the old asset. Silent moves are what the issue asks to avoid.
4 How long replay protection lasts 30 days of report ids. Older replays are caught by observed_at anyway.
5 Showing imported inventory in the UI Yes, with a small source badge on each software, adapter and disk row in the asset view, and an "Inventory sources" panel listing each source with its scan time. The data is only useful if people can tell where it came from.
6 Downloading an attachment's content over REST Yes (GET .../content), gated by change_attachments.read - evidence an integration uploaded should be retrievable by the same integration.

8. Deliberately out of scope

As the issue says: no source-specific connectors (FreeITSM accepts reports; the tool that produces them stays outside), and nothing that executes anything. Inventory import does not create assets - it reports on an asset you name; finding or creating the asset is the caller's job, using the existing GET /assets?hostname= and POST /assets. Attachments on other record types (tickets, problems) are not part of this, though Part C's service method makes them straightforward later.


9. As built (3.12.0)

All three parts were contributed by @Dr4inder as pull requests and merged with fixes - PR #195 custom fields, PR #196 change evidence, PR #197 inventory. What was kept, changed and left out of each is in the review. Where the build differs from the design above:

Design As built Why
report_id, remembered for 30 days No report id. Each section keeps the observation time and a fingerprint of its content; the same time and content is replayed, the same time and different content fails Never expires - @Dr4inder's concern that replay protection should not end when a cache does
Built-in source names reserved Refused by name, and REST rows are stored as source = 'rest:<name>', so they can never be confused with agent or intune rows Belt and braces
Who may write a source - open One analyst per (company, source name), the first to write it; a rotated key for the same analyst keeps it (asset_inventory_source_owners) @Dr4inder's clarification 1
asset_inventory.write asset_inventory.create to report, asset_inventory.delete to unlink The API's permission verbs are read / create / update / delete
inventory_sources on GET /assets/{id} GET /assets/{id}/inventory Keeps the asset response the same size
A failed section named, 207 As designed, plus 422 all_sections_failed when nothing applied, and retry = send the same report again @Dr4inder's clarification 3
Items name, version, mac, ip (illustrative) The inventory tables' own names: display_name, display_version, mac_address, ip_address, ... The read endpoints use them already
One implementation for the agent and the API Two, for now. The REST path shares the catalogue lookup rules and the workflow trigger; the agent path is unchanged apart from leaving other sources' rows alone Moving the agent onto the new code is a change to the busiest write path in FreeITSM and deserves its own release
Physical drives as a section; an Inventory sources panel on the asset page Not yet - each row shows its source in the Software and Disks tabs Follow-ups

Related pages

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally