Asset CI Link Developer Guide - edmozley/freeitsm GitHub Wiki
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.
ποΈ 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.
| 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.
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.
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.
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.
$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.
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).
AssetMergeService::merge(..., $ciLinks) with $ciLinks = ['seen' => [...], 'keep_link_id' => N]:
-
seengiven and not equal to the live links of the two assets βci_link_changed - both linked and
keep_link_idis not one of them βci_link_choice_required(also what a caller passingnullgets) - the other link is closed
asset_merged, then every row'sasset_idmoves to the kept asset
Neither CI is touched. Both errors throw inside the merge's transaction, so nothing is merged.
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().
-
TRAP: the merge guard. A new column naming an asset in
asset_ci_linksmust go inREFERENCESorNOT_REFERENCES, ortests/asset-merge.phpfails. That is the point:original_asset_idmust 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 insideapiAssetCiAccess(). 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.
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.