-
-
Notifications
You must be signed in to change notification settings - Fork 29
Linking Equipment to Contracts Developer Guide
How asset-to-contract linking is put together, and the one thing about it that is genuinely awkward.
The user page is Equipment covered by a contract. Related: Contracts Β· Assets Β· Roles and Permissions
Built for discussion #106 (dschipfel).
| File | What it does |
|---|---|
includes/contract_assets.php |
Everything. Reads, writes, the tenancy filter and the gate |
api/contracts/get_contract_assets.php |
Equipment on a contract |
api/contracts/search_contract_assets.php |
The picker |
api/contracts/save_contract_asset.php |
Link, or change the note |
api/contracts/delete_contract_asset.php |
Unlink |
api/assets/get_asset_contracts.php |
The other direction |
api/assets/search_linkable_contracts.php |
The picker, from the asset side |
contracts/view.php |
The Equipment panel and the picker modal |
includes/contract_report.php |
The report renderer and its CSS, shared by the page and the email |
contracts/equipment-report.php |
The printable page |
api/contracts/email_equipment_report.php |
The emailed copy |
asset-management/index.php |
The Contracts tab |
tests/contract-assets.php |
24 assertions, touches the database |
contract_assets
id INT AUTO_INCREMENT
contract_id INT NOT NULL -- FK contracts(id) ON DELETE CASCADE
asset_id INT NOT NULL -- FK assets(id) ON DELETE CASCADE
reference VARCHAR(190) NULL
linked_by_id INT NULL -- FK analysts(id) ON DELETE SET NULL
created_datetime DATETIME NULL DEFAULT CURRENT_TIMESTAMP
UNIQUE KEY uq_contract_asset (contract_id, asset_id)
KEY ix_ca_asset_id (asset_id)Registered in database/freeitsm.sql, includes/db_verify_schema.php, includes/db_verify_indexes.php and the foreign-key list in api/system/db_verify.php.
No tenant_id, for the same reason ticket_assets has none: the company comes from the asset. No is_demo either β join tables do not carry it, and demo cleanup deletes the parents and lets the cascade take the links. That cascade is load-bearing, not decoration: bypassing one with FOREIGN_KEY_CHECKS=0 is how an SSO link once outlived its account and locked somebody out permanently.
reference is on the link rather than the asset because it describes the asset's place in this contract. Move a handset to another agreement and you keep the handset and lose the line.
This is the whole reason includes/contract_assets.php exists instead of four endpoints each doing their own SQL.
-
assetscarries atenant_id. -
contractsdoes not, and neither dosuppliers,rfps,lms_coursesorworkflows.
So the asset is the only half of the link that can answer "whose is this?", and every read of the asset side has to be filtered with activeTenantFilter($conn, $analystId, 'a'). Four endpoints filtering separately is four chances to forget, and forgetting once puts one customer's equipment on another customer's contract page β looking exactly like a working feature.
If contracts ever gain a tenant_id, contractsForAsset() is the function that grows a filter; it deliberately has none today and says so in a comment.
A reader who may see three of ten linked assets is shown three and told nothing about the other seven.
A count of what you cannot see is a leak of its own. This is exactly the mistake the requester picker made β it scoped the list and not the count β and it was a live disclosure.
contractAssetCanReach() is called again on every write and every delete:
function contractAssetCanReach(PDO $conn, int $analystId, int $assetId): boolThe id in a POST body did not necessarily come from the list this server rendered. A missing asset and a forbidden one both return No such asset, deliberately β telling them apart tells you a row exists.
| Endpoint | Gate |
|---|---|
the four api/contracts/*
|
requireModuleAccessJson('contracts') |
api/assets/get_asset_contracts.php |
requireModuleAccessJson('assets') |
api/assets/search_linkable_contracts.php |
both β assets, then an explicit analystCanAccessModule(..., 'contracts')
|
The asset-side endpoint is on the assets module, because it serves the asset's own page. It then checks analystCanAccessModule($conn, $analystId, 'contracts') separately and returns permitted: false with an empty list, so the browser can say "you do not have access to the Contracts module" rather than "no contracts".
π΄ The asset-side picker needs both modules and refuses outright without contracts access. It returns contract numbers and titles, so without that second check a search box on an asset page would be a way to read the contract register without access to it. The read-only tab degrades politely; the picker does not degrade at all.
Writes are not duplicated. Linking and unlinking from the asset side call api/contracts/save_contract_asset.php and delete_contract_asset.php β the same endpoints the contract page uses. Only the search is mirrored, because the two ends genuinely search different things. One set of rules about who may link what.
INSERT INTO contract_assets (...) VALUES (...)
ON DUPLICATE KEY UPDATE reference = VALUES(reference)The unique key makes a second row impossible, and "it is already on here" is not an error anybody wants to read. Linking something already linked updates its note instead.
The note is trimmed to 190 characters in PHP rather than left to MySQL, and a note of only whitespace is stored as NULL rather than ''.
Neither would have been caught by reading the code:
-
contract_statuseshas nocolourcolumn. The first draft selectedst.colour. -
suppliershas noname. It islegal_nameandtrading_name, and the contracts list returns both.
A third one bit the test: assets has no created_datetime. It also has no NOT NULL column without a default, so a hostname alone is a valid fixture.
php tests/contract-assets.php # 24 assertions
Everything it makes is prefixed ZZCA and removed in a finally, including on failure.
The guards are tested from the attacker's side, with a positive control β four "this must refuse" assertions followed by one "and a real link still loads", so the refusals cannot be passing because everything refuses.
It also covers the cascade in both directions, because demo-data cleanup depends on it.
Verified beyond the unit tests: all five endpoints driven with curl against real rows, and the asset page driven in a real browser through a same-origin iframe β tab renders, badge counts, clicking activates the panel, the populated row carries number, title, reference, supplier, end and notice, and the notice colour resolves from --warning-text in both themes.
includes/contract_report.php holds the renderer and its CSS. One renderer, three destinations β the printable page, the emailed copy, and the CSV built from the same rows. This is the shape asset handover already uses, and for the same reason: a report that looks different depending on how it left the building is one people stop trusting.
π The CSS is a PHP function, not a .css file. A mail client will not fetch an external stylesheet, so it has to be inlined into the message, so it has to be a string. It is also deliberately literal β no custom properties, no theme, no dark-mode query β because a printed page is white and a mail client resolves none of those reliably.
"PDF" means printing to one. There is no PDF library in the stack; asset-management/handover.php and the RFP preview already work this way. Adding one to lay out a six-column table would be a dependency to maintain forever in exchange for a button every browser already has. ?print=1 opens the dialog on load; a plain visit does not, because a print dialog nobody asked for is how a page gets closed before it is read.
The CSV is built in the browser from get_contract_assets.php, matching software/index.php. Two things in it are not optional:
- Every field is quoted and its own quotes doubled. A model name with a comma is not exotic, and an unquoted one silently shifts every column after it.
- The string starts
'\uFEFF'. Without that byte-order mark Excel guesses at the local codepage and mangles every accented location name. Write it as the escape, never as a literal BOM character in the source β an invisible byte is one normalising tool away from vanishing, and nobody reviewing the file can see it is there.
contractAssetsFor() with the viewer's analyst id, so a report is never a way around the company filter. The emailed copy contains what the SENDER can see, which is the only honest reading: they are the one choosing to send it.
The email defaults to the contract owner (contract_owner_id to analysts.email) and an explicit address wins. No address and no owner is reported as nobody to send to rather than as a send failure β there is nothing wrong with the mail setup.
π΄ Messages go through showToast(), never alert(). A native alert names the host ("freeitsm.internal says"), blocks the page, and looks nothing like the rest of the product. This page already used showToast eleven times before these functions were added to it.
The requester also asked to filter or search the asset list by contract β "show me everything on contract X" from the Asset Management side. The picker searches, and both panels show their links, but that filter does not exist.
Renewal notifications were the fourth item on the request and already existed: contract.expiring is a workflow trigger in includes/workflow_scheduled.php, fired by cron/workflow_scheduled.php. Nothing was built for it.
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- π§ͺ Developer tests
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- Theming & Dark Mode
- ποΈ Recent β getting back to what you were doing
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
-
MobileβFriendly
- β³ π« Mobile: Tickets
- β³ π» Mobile: Assets
- β³ π Mobile: Calendar
- β³ π Mobile: Knowledge
- β³ π¦ Mobile: Service Status
- β³ πΌ Mobile: Watchtower
- β³ π§© Mobile: Problem Management
- β³ π Mobile: Change Management
- β³ πΏ Mobile: Software
- β³ β Mobile: Tasks
- β³ π Mobile: Forms
- β³ π Mobile: Contracts
- β³ π Mobile: Domains
- β³ π Mobile: People
- β³ π Mobile: LMS
- β³ πΊοΈ Mobile: CMDB
- β³ πΊοΈ Mobile: Network Mapper
- β³ π§ Mobile: Process Mapper
- β³ βοΈ Mobile: Workflow
- β³ π₯οΈ Mobile: System
- β³ π Mobile: Reporting
- β³ π Mobile: System Wiki
- β³ π Mobile: Self-Service Portal
- β³ π§° Mobile: Techniques & Tricks
-
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
- β³ π‘οΈ CSRF protection (S4) β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- π CardDAV contact sync
- 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: Domains
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ π·οΈ REST API: Cost centres
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ π Rota copy and paste β Developer Deep Dive
- β³ β Checklists & SOPs
- β³ βοΈ Mandatory fields
- β³ π·οΈ Ticket categories
- β³ π₯ Assigning tickets to a team, and escalation
- β³ π’ One board across every company
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
-
β³
βοΈ Telegram channel - β³ β CSAT company scope and filters β Developer Guide
- β³ π₯ Microsoft Teams channel
- β³ π¨οΈ Mattermost channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ β Record previews
- β³ π Ticket notes: internal or shared
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ π¨ Telling the analyst a ticket is theirs
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ π Confidential tickets
- β³ π₯ Portal managers
- β³ π Who has seen a ticket
- β³ π Reading long tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π½ Just my tickets, or no closed ones
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- β³ π Scheduled work in your own calendar
- Problem Management
- Tasks
-
Assets
- β³ π’ Moving an asset between companies
- β³ π Shared asset locations
- β³ π§βπΌ Assigning assets to analysts
- β³ π Warranty and lease alerts
- β³ π Saved table views
- β³ π¨οΈ Recording anything, and importing it
- β³ π·οΈ QR asset labels
- β³ π Who holds what, and handover documents
- β³ π₯οΈ The inventory agent (PowerShell)
- β³ ποΈ Proxmox VE servers
- β³ βοΈ VMware Cloud Director servers
- β³ π Linking equipment to tickets
- β³ βοΈ Follow-up tasks on a ticket
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
-
Forms
- β³ π¨ The form designer β Developer Guide
- β³ π Layout & the grid β Developer Guide
- β³ ποΈ Collections β grouping submissions
- β³ π Submissions as PDFs
- β³ β‘ What happens next β a form's own actions
- β³ π οΈ Sections & conditional logic β Developer Guide
- β³ π οΈ Lookup fields β Developer Guide
- β³ π‘οΈ Catalogue request approvals
- People
- Domains
- 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
- β³ π’ One board across every company
- β³ 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)
- What this is
-
π Bugs resolved
- β³ πΌοΈ Logo and courses broke on Apache with PHP-FPM
- β³ π’ Chat tickets ignored your ticket numbering
- β³ π Dates shown as a dash, or in server time
- β³ π Assets β Users showed people from other companies
- β³ π Restricted analysts could read other modules' data
- β³ πΌοΈ Replies with a picture in the thread failed to send
- β³ π Reply attachments never reached the customer
- β³ π οΈ Outbound email attachments β Developer Guide
- β³ π A global SSO provider was missing from the portal
- β³ π Behind a proxy, the SSO redirect said http
- β³ βοΈ The portal tagline moved when you saved it
- β³ π¨ The portal settings screen forgot what you saved
- β³ π‘οΈ The approvals inbox said "Error" and nothing else
- β³ π A table's answers were missing from the PDF
- β³ β A single-select column let you tick every option
- β³ π The portal ignored a form's field widths
- β³ π The tasks board stopped taking clicks
- β³ ποΈ #121 The index list is out of date after upgrading
- β³ π #133 The calendar subscription was empty
- β³ π #131 Tasks always reopened on the board
- β³ π₯ #129 Every page returned HTTP 500 after upgrading
- β³ π³ #127 A PHP warning above the System page
- β³ π #126 Notes stamped with the server's clock
- β³ π Storing every date in UTC
- β³ πͺ The portal was down for everyone signed in
- β³ βοΈ #120 Workflow notes could never be written
- β³ βοΈ #123 Three errors when running Database Verification
- β³ π #122 The description box was a stub in the corner
- β³ π£ Demo data deleted real accounts
- β³ π #117 Sign-in redirected to the wrong address
- β³ π¨ #108 The priority dot was invisible
- β³ β±οΈ #116 Time logged from the right-click menu
- β³ π #114 API keys refused by our own guard
- β³ ποΈ #110 Assigning a task told nobody
- β³ πͺ #107 Signed out while still working
- β³ π #103 "Share with Requester" reached nobody
- β³ π #102 Search found nothing for hyphens
- β³ πͺ #101 Source code editor opened behind
- β³ βοΈ #88 Subtasks could not be ticked off
- β³ π» #84 Asset deep link selected nothing
- β³ π« #79 A new ticket arrived with no status
- β³ π§ #79 A ticket from email did not say so
- β³ π #78 Bell opened to nothing
- β³ π¬ #77 Mail only collected from Inbox
- β³ π #74 The default password could not be changed
- β³ π¦ #70 Renaming an impact level
- β³ π€ #67 App-only mailboxes could not send
- β³ π #45 Verify only ever worked for Microsoft
- β³ π #45 IMAP reported as not authenticated
- β³ βοΈ An email template stopped escaping itself
- β³ π The portal dashboard showed the wrong time
- β³ π’ The folder said 99 and the list showed 96