-
Notifications
You must be signed in to change notification settings - Fork 15
Linking Equipment to Tickets Developer Guide
How an asset gets attached to a ticket, and every gate it passes on the way.
This is the implementation half of Linking equipment to tickets. Read that first if your question is "what does it do"; stay here if it is "why is there a second ownership check when the dropdown is already scoped".
The one-line summary: it is a deliberate near-copy of
ticket_cmdb_objects, with one schema difference (two creator columns, because a link can be made by an analyst or a portal user) and one behavioural difference (the picker opens with results already in it).
Colour key: π§ shared rule Β· π API Β· π₯οΈ page Β· ποΈ schema Β· βοΈ registration Β· π i18n Β· π¨ styling
| π¨ | File | Its job |
|---|---|---|
| ποΈ | database/freeitsm.sql |
ticket_assets β the join table, and the reasoning for its two creator columns |
| ποΈ | includes/db_verify_schema.php |
Columns only. Indexes and FKs live elsewhere |
| ποΈ | includes/db_verify_indexes.php |
GENERATED. uq_ticket_asset, ix_ta_asset_id
|
| βοΈ | scripts/gen_db_verify_indexes.php |
Regenerates the above from freeitsm.sql
|
| ποΈ | api/system/db_verify.php |
Creates the four FKs (fk_ta_ticket, fk_ta_asset, fk_ta_analyst, fk_ta_user) |
| π | api/tickets/save_ticket_asset.php |
Link. Idempotent. Three gates β see Β§3 |
| π | api/tickets/delete_ticket_asset.php |
Unlink. Gates on the ticket, not the asset |
| π | api/tickets/get_ticket_assets.php |
The hydrated card for the reading pane |
| π | api/tickets/search_linkable_assets.php |
The picker. Returns two lists β see Β§4 |
| π | api/assets/get_asset_tickets.php |
The reverse view. Two buckets β see Β§6 |
| π | api/self-service/get_my_assets.php |
The portal's entire permission rule β see Β§5 |
| π | api/self-service/create_ticket.php |
Re-checks ownership on write. Not optional |
| π₯οΈ | assets/js/inbox.js |
buildLinksSection (the strip), openStripPicker, syncLinksStripEmpty, loadTicketAssets, renderTicketAssets, openLinkAssetPicker, removeTicketAsset
|
| π¨ | assets/css/inbox.css |
.strip-pill-group, .strip-picker-*, .asset-picker-*
|
| π₯οΈ | asset-management/index.php |
The Tickets tab, loadAssetTickets, and its CSS |
| π₯οΈ | self-service/new-ticket.php |
loadMyEquipment, the hidden-by-default field |
| π§ | includes/tenancy.php |
analystCanAccessTicket, analystCanAccessAsset, activeTenantFilter, ticketTenantFilter
|
| βοΈ | includes/ticket_merge.php |
MERGE_MOVE_DEDUPE β see Β§7, and read it before editing |
| βοΈ | api/tickets/permanently_delete_ticket.php |
Comment only; the FK does the work |
| βοΈ | api/system/debug-tools/D002_delete_ticket.php |
Two lists, both need the table |
| π | lang/en/tickets.php |
tickets.assets.* |
| π | lang/en/asset-management.php |
asset-management.tickets.*, detail.tab_tickets
|
| π | lang/en/self-service.php |
self-service.new_ticket.equipment* |
ticket_cmdb_objects has one: created_by_analyst_id. Only an analyst can link a CI, so one column covers it.
ticket_assets has two:
`created_by_analyst_id` INT NULL, -- FK -> analysts
`created_by_user_id` INT NULL, -- FK -> usersBecause a link can be created from the ticket screen (an analyst) or from the self-service portal (an end user), and those are different tables with independent id sequences. There is no single column that can hold both without losing which one it means β analyst 7 and user 7 are different people.
Exactly one is set on any row. Both NULL would mean an automated path created it; nothing does that today.
There is no tenant_id. The company is inherited from the ticket and the asset, both of which carry their own, and the invariant that they match is enforced in application code (Β§3) β the same choice ticket_cmdb_objects made.
save_ticket_asset.php checks, in order:
-
analystCanAccessTicketβ can this analyst reach the ticket at all? -
analystCanAccessAssetβ can they reach the asset? The ticket gate says nothing about this. Without it, any asset id could be attached to a ticket the analyst legitimately owns, pulling another company's hostname and serial into their reading pane. Framed as not-found, never "forbidden", so the endpoint does not confirm the row exists. -
Same company β only when
isMultiTenant(). Both ends being reachable is not enough for an all-access analyst, who can reach both companies. A ticket and its asset must belong to the same one, or the link itself becomes the leak.
Gate 3 is the one that is easy to talk yourself out of, and it is the one that matters for the exact role that can defeat gates 1 and 2 together.
get_ticket_assets.php applies activeTenantFilter to the asset join even though gate 3 should make that impossible. A link created before a company was split β or before gate 3 existed β can still straddle two companies. The read must not depend on an invariant the write only recently started enforcing.
search_linkable_assets.php responds with:
{ "success": true, "requester": [...], "others": [...], "limit": 25 }-
requesterβ fromusers_assetsfor the ticket'suser_id. Returned even whenqis empty, which is what lets the picker open with something in it. "My monitor is flickering" is nearly always their own monitor, and that should be one click, not a search. -
othersβ only populated onceqis non-empty. Excludes anything already inrequester, so nothing appears twice.
Both exclude assets already linked to the ticket.
π The
users_assetsjoin is an INNER JOIN on purpose. That table carries no foreign key onasset_idand real installs have orphan rows. A LEFT JOIN renders them as blank entries in the dropdown.
WHERE (a.hostname LIKE ? OR a.manufacturer LIKE ? OR a.model LIKE ?
OR a.service_tag LIKE ? OR a.asset_tag LIKE ? OR l.name LIKE ?)That last clause is the difference between the feature working for shared equipment and not. The motivating case is a user reporting the TV in a meeting room: nobody is assigned it, and neither the reporter nor the analyst knows its hostname or serial. They know where it is. Remove l.name and there is nothing useful to type.
It depends on assets.location_id being populated. If an install has no locations set, searching a room name legitimately returns nothing β that is missing data, not a regression, and it is worth checking before debugging the query.
openLinkAssetPicker concatenates the two lists into one current array and records firstOtherIdx. Group headings are rendered as siblings of the result rows rather than as rows, so arrow-key navigation indexes only over selectable items and can never land on a heading.
get_my_assets.php is the whole of the portal's permission model, and it is deliberately tiny: no search, no browsing, no id parameter. The user comes from $_SESSION['ss_user_id'] and nothing else is reachable.
π΄
create_ticket.phpre-checks ownership againstusers_assetsanyway. The dropdown being scoped is not a check β the request is just JSON, and a hand-crafted one can name any id. Without the re-check, a portal user could attach (and then read back the make, model and serial of) equipment belonging to somebody else.Ids that are not theirs are silently skipped, not rejected: there is no legitimate way for the UI to send one, so the only sender is someone probing, and an error would tell them which ids exist.
This is the same lesson get_users.php taught in #54, where the tenancy clause scoped the ticket count but not the user list. A scoped list is not a check.
Nobody is assigned a meeting-room TV, so a requester cannot pick one. The two alternatives were both judged worse β exposing every unassigned asset makes cupboard stock browsable to all end users, and a "shared" flag asks end users to understand a distinction they should not have to. The requester describes it and an analyst attaches it. See the user page for the full write-up so this does not get re-proposed.
get_asset_tickets.php mirrors api/cmdb/get_object_tickets.php, including both lessons that endpoint learned:
-
Two independent gates, because it straddles two modules:
analystCanAccessAssetfor the asset, thenticketTenantFilterfor the tickets. Same reasoning as Β§3. -
t.deleted_datetime IS NULL. The CMDB version originally lacked this and was listing tickets sitting in the recycle bin.
Two buckets: open (all of them, newest-updated first) and closed (capped at 20, newest-closed first) plus total_closed so the UI can say "showing 20 of N". A device in service for years should not render its whole history to answer "has this broken before?".
Tracing ticket_cmdb_objects found three places, and found a bug in one of them:
| File | What to add |
|---|---|
includes/ticket_merge.php |
An entry in MERGE_MOVE_DEDUPE, and the table name in the header comment's MOVE list |
api/tickets/permanently_delete_ticket.php |
Comment only β the CASCADE FK does the work |
api/system/debug-tools/D002_delete_ticket.php |
Two lists: the delete order map and the pre-flight counts |
π΄
MERGE_MOVE_DEDUPEmaps table β column, and the column name goes straight into SQL.It had
'ticket_cmdb_objects' => 'object_id'. The real column iscmdb_object_id. That produced "Unknown column", which landed in acatchwhose comment reads "absent" β written for installs where the table does not exist yet β so the failure looked like an expected condition.And because the throw came from the first statement in the
try, the two after it never ran either: no de-dupe, nothing recorded for a later unmerge, and no move. CMDB links silently stopped following merges. Fixed in #1064.A wrong column name in a table-driven loop is not a mismatch. It is a silent no-op. Check the name against the schema when you add a row.
There is no UI login available to an agent, so verification runs against the API with a forged session.
# analyst
printf 'analyst_id|i:1;' > /c/wamp64/tmp/sess_mytest
# portal user
printf 'ss_user_id|i:128;' > /c/wamp64/tmp/sess_myportal
curl -k -b "PHPSESSID=mytest" "https://freeitsm.internal/api/tickets/get_ticket_assets.php?ticket_id=50"
β οΈ No trailing newline in the session file. PHP's decoder rejects it outright with "Failed to decode session object. Session has been destroyed", which reads exactly like an auth failure.
Controls worth repeating if you change any of this:
| Check | Why |
|---|---|
Post a bogus asset_id
|
Must be "Asset not found", not an FK error |
| Link the same pair twice | Must return already_linked, not 1062 |
Post foreign asset ids to create_ticket.php
|
Must link none of them |
| Permanently delete a ticket, then read the asset's tab | Proves the CASCADE exists β db_verify creates FKs inside catch {}, so their existence cannot be assumed from a clean verify run |
| Search a string that appears only in a location | The only way to prove the location join, and it needs an asset whose other fields do not contain the string |
It shipped as a bordered card below the thread, copying ticket_cmdb_objects. That was wrong, and buildLinksSection already said so in a comment written for the Jira work:
"A Jira issue IS a link, so it belongs in this strip rather than in a panel of its own β an analyst already looks here for 'what else is this connected to'."
Equipment and CIs are links by that definition. Both are pills in the strip now, reusing .pm-ticket-badge, and both are reached from the same Link to⦠menu.
The cost was never the number of sections. A section rendered a bordered card, a heading and 16px margins whether or not it had anything in it β so an ordinary email ticket paid full price six times over to be told "nothing here yet" six times. Empty state was most of the bill.
One row, two identities: π₯οΈ for equipment, ποΈ for CIs. Merging them into a single "Affected" list was considered and rejected β a broken mouse is an asset and will never be a CI, and the two links do different downstream work (impact analysis versus warranty and repeat-failure history). Making them look identical invites an analyst to link the wrong one and silently get no blast radius.
Not a sibling. mobile.js relocates the whole strip into a full-screen Links sheet on a phone, selecting by .problem-strip. A sibling stays behind in the reading pane, so the picker would open somewhere the user cannot see. It takes flex: 0 0 100% and wraps onto its own row.
-
.cmdb-section/.cmdb-link-cardand friends are now dead CSS in bothinbox.cssandmobile.css. Nothing emits that markup. Removing themobile.cssones means bumpingmobile.cssacross 21 pages, so it was left as a separate tidy-up. -
mobile.jsstill lists acmdbentry inSECTIONSpointing at#cmdbObjectsContainer, which no longer exists.relocateSectionsfinds no nodes and returns, so it is inert β but it is dead config, and the CI pills now travel inside the Links sheet instead.
- No v1 REST API resource. The tickets resource does not expose CI links either; matching that was preferred to inventing a surface on one side only.
- No ticket-thread event on link/unlink. Consistent with CMDB linking, which is also silent.
- No automatic linking. Nothing infers the asset from the requester or the message text, so an empty Tickets tab is the expected state for most assets.
-
No dark-mode override block. Not needed β
.asset-*is themed from--accent,--accent-soft,--surface,--border,--text*, all real tokens intheme.css.
π΄ The neighbouring
.cmdb-*block is not theme-aware. It hardcodes#fdf2f8/#fbcfe8/#be185dand has no dark-mode handling anywhere, so it renders light-pink cards on a dark page. Left alone here rather than changed as a side effect, but it is a real gap sitting directly above the new section.
- Linking equipment to tickets β the user-facing page
- Linking tickets β problems, changes, tickets and CMDB objects, which are a separate mechanism
- Multi-Tenancy Developer Guide β the scoping helpers used throughout
-
Database Verification β Developer Guide β why
$schemais columns-only
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)