Skip to content

Asset CI Link Developer Guide

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

Linking an asset to its configuration item - Developer Guide

How the asset ↔ CI link (#201) is built, and the rules a change must keep. The user page is Linking an asset to its configuration item; the reasoning is in the design.

In one breath: one table of periods (asset_ci_links), one service (AssetCiLinkService) behind the asset page, the CI page and REST, and one JS card (CiAssetLink) drawn on both pages. The link is an identity. It is never an edge in the CMDB graph.


1. The files

🗄️ schema · 🧠 rules · ✏️ write paths that call in · 🔌 REST · 🖥️ UI · 🧪 tests

🎨 File What it does there
🗄️ database/freeitsm.sql, includes/db_verify_schema.php, includes/db_verify_indexes.php, api/system/db_verify.php asset_ci_links, its two indexes ((asset_id, unlinked_at), (cmdb_object_id, unlinked_at)) and one FK (asset_id → assets, CASCADE). No FK on cmdb_object_id, on purpose
🧠 includes/services/asset_ci_links.php AssetCiLinkService - every rule: access, restriction, one live link per side, link / Replace / unlink, CI delete, merge, the card's details, history values
✏️ includes/services/cmdb.php deleteObject() calls AssetCiLinkService::ciDeleted() and clearAssetRefFields() inside its transaction, before the deletes
✏️ includes/services/asset_merge.php REFERENCES has ['asset_ci_links', 'asset_id', 'ci_links'], handled by AssetCiLinkService::mergeLinks(). NOT_REFERENCES has asset_ci_links.original_asset_id
✏️ api/assets/review_merge.php Passes ci_links_seen and ci_link_keep from the merge window
🖥️ api/assets/ci_link.php The page endpoint: both sides' reads, both pickers, the merge preview, link and unlink
🖥️ api/assets/save_asset_type.php, api/assets/get_asset_types.php Part C: asset_types.cmdb_class_id (saved only when sent; read on its own query so the list loads before Database Verification) and the active classes for the settings dropdown
🖥️ api/cmdb/asset_card_settings.php CMDB → Settings → Asset card (Cap::CMDB_ASSET_CARD)
🖥️ assets/js/ci-asset-link.js, assets/css/ci-asset-link.css The card. side: 'asset' on the asset page, side: 'cmdb' on the CI page. Also draws the asset side's "If this failed" line and the Create linked CI form
🖥️ asset-management/index.php Mounts the card in Key info section cmdb (#ciLinkSection); atlSaw('cmdb', …) feeds the "hidden by this type" notice
🖥️ cmdb/object.js, cmdb/object.php Mounts the card under Details (#cmdbAssetLink); scrolls to #o2Blast when the asset card's Show links there
🖥️ asset-management/settings/index.php The merge window's CI-link block (loadMergeCiLinks(), renderMergeCiLinks())
🖥️ includes/services/asset_type_layout.php cmdb in SECTIONS; hidden in the Peripheral and Mobile presets
🖥️ api/assets/get_asset_history.php Turns cmdb_link values (ci:<id>) into names, or restricted
🔌 api/v1/lib/routes.php, api/v1/resources/assets.php, api/v1/resources/cmdb.php, api/v1/lib/permissions.php, api/v1/spec.json, api/v1/lib/openapi_schemas.php Four routes, linked_ci / linked_asset blocks, the asset_ci_links permission, the catalogue entries and typed fields
🧪 tests/asset-ci-link.php 66 checks: page, service and REST, restriction, Replace races, delete, merge, companies
🧪 tests/asset-merge.php Its guard fails if an asset column is missing from REFERENCES / NOT_REFERENCES

What you don't touch: includes/cmdb_impact.php. The asset is not in the graph, so the impact walk never learns about the link. The asset card simply asks the CI's own blast radius.


2. The table: periods, never deleted

Column Rule
asset_id Follows a merge (the kept asset sees the whole history)
original_asset_id, asset_name The asset the link was made on and its name then. Never moved, never updated
cmdb_object_id No FK: a deleted CI's rows stay
linked_at / linked_by_id / link_source link_source is ui, api:<key name>, or integration:<name> (reserved for #199)
unlinked_at / unlinked_by_id / unlink_source / unlink_reason NULL while live. Reasons: unlinked, replaced, ci_deleted, asset_merged
ci_name / ci_class_name Written by close() when a period ends, so the history still reads after the CI is gone

One live link per asset and per CI is enforced in the service, not the schema (MySQL has no partial unique index). Every write locks the asset row, then the CI row (FOR UPDATE), then the live link rows, in that order everywhere, including unlink() from the CI side, which re-reads under the lock.


3. Access: two adapters, one shape

The service never reads a session or a key. It takes an ActorContext (company scope) and an access array:

['assets_read' => bool, 'cmdb_read' => bool, 'assets_write' => bool, 'cmdb_write' => bool]
Adapter Built by Read Write
Page accessForAnalyst() analystCanAccessModule() analystCanWriteModule() - today always full for Assets and CMDB; follows a future Read only level automatically
REST accessForApiKey($key, $forWrite) assets.read / cmdb_objects.read on the key The route already demanded asset_ci_links.create/delete

Restriction is decided by the other side's read flag: from the asset, cmdb_read; from the CI, assets_read. When it is false, everything about the other end becomes {restricted: true}: the current link, every history entry, conflict messages ("…linked to an asset you cannot open"), and REST blocks. Ids are dropped too: a restricted block never carries an id.

The asset card setting (cmdb_asset_card_fields) only filters which details assetView() returns. It is applied after the access decision, so it can never widen access.


4. The write paths

Link and Replace

link(asset, ci, replace, replaces[])
  lock asset, lock CI, same company?            -> different_company
  asset's live link is this CI?                 -> unchanged (idempotent retry)
  conflicts = live link on asset + live link on CI
  conflicts and not replace                     -> ci_already_linked / asset_already_linked
  replace and sorted(conflicts) != sorted(replaces) -> link_changed
  close each conflict 'replaced', insert, history lines

replaces is exactly what the person saw. A refused link sends back conflictIds() (ids only, which say nothing about the record), the UI confirms and resends them. If anyone changed the link in between, the sets differ and nothing happens.

Create linked CI (Part C)

createLinkedCi() holds one transaction: lock the asset → already linked? return that CI (created: false, the retry case) → the type's class (mappedClass(), active only) or no_class_mapped → possible_matches unless ignore_matches → CmdbService::createInTransaction() → link() → commit → then CmdbService::announceCreated().

createObject() now begins and commits only when it owns the transaction, so it can run inside this one; createInTransaction() also holds back the cmdb.object.created workflow event, so a create that rolls back - a required property the CMDB refuses, a link that fails - never announces a CI that does not exist. CmdbService::requiredProperties() is what the form asks for.

Unlink

$expectedLinkId (the card's link_id) must still be the live link, or link_changed. REST takes it as ?link_id=; leaving it out unlinks whatever is live.

CI delete

CmdbService::deleteObject() → AssetCiLinkService::ciDeleted($ids) for the CI and every descendant, inside the delete's transaction and before any row goes, because close() reads the CI's name and class. Then clearAssetRefFields() deletes asset_field_values of ref fields whose config.ref_kind is cmdb_object pointing at those ids, with a field:<key> history row storing the id (as other link-field history rows do).

Merge

AssetMergeService::merge(..., $ciLinks) with $ciLinks = ['seen' => [...], 'keep_link_id' => N]:

  • seen given and not equal to the live links of the two assets → ci_link_changed
  • both linked and keep_link_id is not one of them → ci_link_choice_required (also what a caller passing null gets)
  • the other link is closed asset_merged, then every row's asset_id moves to the kept asset

Neither CI is touched. Both errors throw inside the merge's transaction, so nothing is merged.


5. History without leaking names

asset_history rows for the link use field_name = 'cmdb_link' and values ci:<id>, never a name. historyValue() resolves them per reader: the CI's name (live, or ci_name from the link row once it is deleted) for someone who can open the CMDB, a restricted configuration item for someone who cannot, and for REST ci:<id> or restricted. If you add another place that shows asset history, run it through historyValue().


6. Traps

  • TRAP: the merge guard. A new column naming an asset in asset_ci_links must go in REFERENCES or NOT_REFERENCES, or tests/asset-merge.php fails. That is the point: original_asset_id must not move.
  • TRAP: ciDeleted() before the deletes. Called after, close() finds no CI and the history row loses its name.
  • TRAP: no PUT. The API router, its CORS header and the method override only know GET/POST/PATCH/DELETE (and IIS often blocks PUT), so linking is POST /assets/{id}/ci-link. It is still safe to retry.
  • TRAP: a static call resolves its class before its arguments. AssetCiLinkService::restBlock($conn, apiAssetCiAccess($key), …) fatals if the class is loaded only inside apiAssetCiAccess(). The resource file requires the service at the top.
  • Headless screenshots of the CI page stop after the fourth section: its entry animation never finishes without a compositor. Use --force-prefers-reduced-motion.

7. For #199

Hyper-V discovery binds CIs to discovered objects with the same shape: periods, actor and source at both ends, restricted readers, a decision refused if the state it was made on changed. An integration that links assets calls AssetCiLinkService::link() with link_source = 'integration:<name>' and an access array saying it may write. It never links on a name alone.

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally