Status: Research complete · module audited · Phase 0 in progress. Target: A Snipe-IT-class IT Asset Management System (ITAM) folded into the ERPat
Assetsmodule (route prefixams, module keyassets, menu group Safekeep). Design principle: the data model is already ~"Snipe-IT MVP + most of Phase 2". Reuse the working runtime, make the modelled-but-dormant lifecycle actionable, fix the blockers, then layer the missing item types — defer anything without a real user today.Visualize it: open
docs/mockups/assets-blueprint.htmlin a browser. Self-contained interactive prototype (Dashboard → Inventory → Asset detail → Checkout/Acceptance → Audit → Maintenance → Depreciation schedule → Labels/QR → Import) using the ERPat brand tokens, left sidebar, and light/dark toggle. No PHP, no build step — just open it.
0. TL;DR
The Assets module is already a large, mostly high-quality Snipe-IT-style build — 11 controllers,
16 models, 33 views, 4 cron jobs, exemplary idempotent migrations, and full CRUD for assets, models,
categories, locations, status labels, custom fields, fieldsets, depreciations, maintenances, groups,
vendors, comments, import/export, and label PDFs. It even ships the schema for the checkout lifecycle
and a chain-of-custody event log.
Three things block it from being a working ITAM:
- It fails the core test suite —
config/default_menu.phpis missing (every module must ship it;ModuleLoaderTest::test_every_module_ships_a_required_default_menu_configasserts it). Trivial fix, highest value. - CSV import breaks on any new manufacturer — the importer writes
created_atinto thebrandstable whose date column istimestamp, sobrand_idsilently becomes0. Headline feature, errors in real use. - The flagship lifecycle is dormant —
asset_entriescarriesendorsement_status / issued_at / due_at / returned_at / acknowledged / asset_events, and the language file already hasacknowledge_receipt / request_return / report_an_issue / endorsed_to— but no controller endpoint checks an asset out, records acceptance, returns it, or audits it. The module's own manifest advertises a "Snipe-IT-parity checkout/acknowledge/return lifecycle" that users cannot actually drive.
Fix those, wire the lifecycle to the UI, harden the injection/XSS surfaces, then add the missing item types (Licenses, Consumables, Components, stocked Accessories), a REST API, and reporting breadth. This plan sequences that as additive, value-ordered phases.
1. Current state
1.1 What already works (reuse as-is)
| Capability | Where | Status |
|---|---|---|
| Asset CRUD (tag, serial, model, category, status, location, vendor, cost, warranty, image, meta) | Entries.php + Asset_entries_model |
Works. |
| Asset Models / Categories (typed, EULA/acceptance flags) / Locations (hierarchical) / Groups | Asset_models, Categories, Locations, Asset_groups |
Works. |
| Status Labels (deployable / pending / archived semantics + color + default) | Asset_status_labels + seed |
Works (data only — see §1.3). |
| Custom Fields + Fieldsets (element types, format validation incl. regex, encrypted, required, ordering) | Asset_custom_fields, Asset_fieldsets, Asset_fieldset_fields |
Works. Values stored as JSON on asset_entries.meta; compiled field_schema cache on the category — a deliberate multi-tenant-safe divergence from Snipe-IT's per-field ALTER. |
| Depreciations (straight-line) + EOL date + nightly book-value compute | Asset_depreciations + ComputeAssetDepreciationJob |
Works. |
Maintenance log (type, supplier, dates, cost, warranty flag) → writes asset_events |
Asset_maintenances |
Works. |
| CSV import (header auto-map, template, checkout-on-import) + CSV export | Entries::import/export, Asset_importer_model |
Works except the brands bug (§1.3 / Bug #2). |
| Label / QR PDF (single + bulk) | Entries + export_labels_* views |
Works. |
| Comments (@mentions + notifications) | Entries + Asset_entry_comments_model |
Works. |
| Reminders: warranty expiry · return due · audit due · depreciation compute | RemindAsset*Job (×3) + ComputeAssetDepreciationJob |
Cron wiring correct. |
| Reports: activity log · depreciation schedule · audit-due · maintenance log | Asset_reports (4 read-only) |
Works. |
| Chain-of-custody event log | asset_events + Asset_events_model |
Model + reads only — no lifecycle writes (§1.3). |
1.2 Data model (as built)
asset_entries is the hub. Reference tables: asset_categories (typed, require_acceptance/eula_text,
fieldset_id, compiled field_schema), asset_models (→ category, brand, fieldset, depreciation, eol_months,
requestable), asset_locations (hierarchical), asset_status_labels (deployable/pending/archived/color),
asset_depreciations (months, depreciation_min, depreciation_type), shared brands/vendors.
Custom fields: asset_custom_fields (master) ↔ asset_fieldset_fields (pivot) ↔ asset_fieldsets, bound to a
model via asset_models.fieldset_id. Child records of an asset: asset_events (chain of custody / activity),
asset_maintenances, asset_accessories, asset_entry_comments. asset_groups.asset_entries holds a CSV of
entry IDs (FIND_IN_SET-style — consistent with team/establishment membership conventions).
Lifecycle columns already on asset_entries: status_id, assigned_type (user/location/asset),
issued_to, issued_at, due_at, returned_at, endorsement_status, acknowledged, acknowledged_at,
endorsed_by, endorsement_reference, last_audit_date, next_audit_date, condition, requestable.
asset_events: kind, title, body, target_type, target_id, occurred_at, accepted_at,
signature_file, stored_eula. Everything the lifecycle needs to persist already exists.
1.3 Gaps & defects (from the audit — full list in Appendix A)
- Missing
config/default_menu.php→ the core PHPUnit suite is red because of this module. (Bug #1, HIGH) - Importer manufacturer bug →
Asset_importer_model.php:562insertscreated_atintobrands(column istimestamp). (Bug #2, HIGH) - Lifecycle not actionable → no
checkout()/checkin()/acknowledge()/request_return()/audit()endpoints, and no auto-checkin when a non-deployable status is set (Snipe-IT's defining behavior). (Bug #3, HIGH) - SQL-injection defense gaps →
Asset_vendors_model.php:19-49andAsset_entry_comments_model.php:23-28interpolate$optionsinto SQL without(int)/escape()(not proven-exploitable at current call sites, but inconsistent with the rest of the module). (MEDIUM) - Maintenance ignores its own permit →
Asset_maintenances.php:95,159gate on the coarseassetview permit, notasset_maintenance. (MEDIUM) - Stored-XSS surfaces → asset detail header + several
_make_row()server strings echo free-text (serial/model/type/color) unescaped. (MEDIUM) - Undeclared cross-module dependency →
Entries.php:16/Asset_models.php:13load Inventory'sitemshelper; works only because every module dir is registered regardless of enablement. (MEDIUM) - ~30 hardcoded strings → notably the entire asset change-log message set (
Entries.php:331-422) and dropdown placeholders; violate thelang()rule. (LOW) - Missing page-shell wrappers on report/master full-page views; soft-delete filter omitted on some
secondary/breadcrumb JOINs; orphaned
asset_accessoriestable+model (no controller/views);module.jsonapi_endpoints:trueis unbacked. (LOW)
1.4 Missing standard files (vs a complete module — Content template)
config/default_menu.php (REQUIRED — test-enforced), CHANGELOG.md, README.md, composer.json,
docs/ (this plan + the mockup), tests/, and — because api_endpoints:true is declared — the API config
family (config/api_routes.php, config/euapi_routes.php, doc sidecars) or correcting the flag until the
API phase lands.
2. Recommended architecture (target simplicity)
- Make the dormant lifecycle the centerpiece, don't rebuild it. Add explicit controller actions on
Entries(checkout,checkin,acknowledge,request_return,audit) that write the existingasset_entrieslifecycle columns and anasset_eventsrow each. Every mutation = one event = the chain of custody. No schema change needed for the MVP lifecycle. - Honor the status-label state machine. Setting a status whose type is
archived/undeployable auto-checks-in (clears assignment); apendingstatus keeps the assignment (out-for-repair). This is the single behavior that makes status labels meaningful and is Snipe-IT's signature. - Acceptance/EULA reuses the schema that's already there. On checkout to a user, if the category has
require_acceptance/eula_text, present the EULA and capture acknowledgement + optional e-signature intoasset_events.stored_eula/accepted_at/signature_fileandasset_entries.acknowledged/acknowledged_at. - Keep custom fields as JSON
meta. Do not adopt Snipe-IT's per-fieldALTER— the JSON approach is correct for DB-per-tenant. Merge (never replace)metaon partial updates/imports. - New item types are additive tables + their own controllers, mirroring the existing entity pattern
(
get_details($options)model, permission-gated controller,index/modal_formviews, idempotent migration). - One shared dropdown/helper surface. Move the Inventory
items-helper calls behindassets_helper.php(or declare a realdependencies.modulesentry) so the asset/model forms don't silently break.
3. Data model — additions by phase
No new tables for the MVP lifecycle (Phases 0–2 use existing columns). Later phases add:
asset_licenses(name,product_key(encrypted),seats,category_id,manufacturer_id,supplier_id,purchase_cost,purchase_date,expiration_date,maintained,notes) +asset_license_seats(license_id,assigned_type+assigned_to= user OR asset,notes; one row per seat).asset_consumables(name,category_id,qty,min_amt,purchase_cost) +asset_consumable_userspivot (checkout-to-user-only, no check-in).asset_components(name,category_id,qty,min_amt,serial,purchase_cost) +asset_component_assetspivot (assigned_qty, partial return).- Stocked accessories — promote the orphaned
asset_accessoriesinto a catalog + checkout pivot (qty,min_amt, checkout to user/location), or repurpose the table. - Status labels: add
show_in_navfor parity; Depreciations: add amethodcolumn (straight-line / declining-balance / half-year) for finance parity.
All new tables: idempotent (table_exists/SHOW COLUMNS/SHOW INDEX guards), reversible down(), and the
audit block created_by, date_created, updated_at, deleted in order.
4. Phased delivery plan
Effort tags: S ≈ hours · M ≈ 1–2 days · L ≈ several days.
Phase 0 — Unblock & harden — S (this pass)
config/default_menu.php(REQUIRED) — Safekeep slice matching the 8menu.phpitem names. Fixes the red test.- Importer brands bug —
created_at→timestamp; checkinsert_id(); wrap the multi-write upsert in a transaction; merge (not replace)meta; write anasset_eventsrow for import-checkouts. - Injection hardening —
(int)/$this->db->escape()inAsset_vendors_model+Asset_entry_comments_model. - Maintenance permit — gate create/update/delete on
asset_maintenance. - XSS —
html_escape()the free-text asset fields in the detail header +_make_row()strings; validatecolor. - Cross-module helper — front the Inventory
itemsdropdowns behindassets_helper.phpor declare the dependency. - Standard files:
README.md,CHANGELOG.md,composer.json; reconcilemodule.jsonapi_endpointswith reality (flip false until Phase 4, or ship the API config).
Phase 1 — Make the lifecycle actionable (the flagship) — M (this pass)
Entries::checkout()— pick target (user / location / asset), setassigned_type/issued_to/issued_at/due_at, flip status to a deployed/deployable label, writeasset_events(kind=checkout), optionally notify assignee.- Acceptance/EULA — if the category requires it, present the EULA on checkout and store acceptance +
e-signature (
stored_eula/accepted_at/signature_file,acknowledged/acknowledged_at). Entries::checkin()— recordreturned_at/condition, clear assignment, back to a "Ready" label; apendingstatus keeps the assignment (repair),archived/undeployable auto-checks-in.Entries::acknowledge()(assignee accepts receipt) andEntries::request_return()/report_issue()(the lang keys already exist).- Gate all of the above on
asset_checkout; every action writes oneasset_eventsrow. - Localize the change-log messages (
Entries.php:331-422) →lang()keys.
Phase 2 — Audit, labels, notifications, reporting breadth — M
Entries::audit()— confirm presence/location/condition, setlast_audit_date=today +next_audit_datefrom a configurable cadence (default +90d), writeasset_events(kind=audit). Surfaces via the existingRemindAssetAuditDueJob.- Page-shell wrappers on the report/master views; QR deep-link on labels → the asset page.
- Reports: add checked-out-to-user and warranty-expiry; low-inventory once consumables/accessories land.
- Requestable self-service scaffolding — surface the
requestableflag (full request→approve→checkout is Phase 3+).
Phase 3 — Missing item types — L
- Licenses + seats, Consumables (checkout-only), Components (checkout-to-asset w/
assigned_qty), stocked Accessories — each: migration + model + controller +index/modal_form+ menu/default-menu/permission entries. - Low-inventory alerts (accessories/consumables below
min_amt).
Phase 4 — REST API — M
config/api_routes.php(/api/v1/assets…) +config/euapi_routes.php(/v1/api/…) + doc sidecars, covering assets, models, categories, statuses, locations, suppliers, custom fields, depreciations, maintenances, and checkout/checkin — enabling MDM/discovery CSV import. Only then setmodule.jsonapi_endpoints:truehonestly.
Phase 5 — Finance & differentiators — M/L
- Depreciation methods (declining-balance, half-year) for finance parity; inventory-valuation & per-department
rollup reports; department scoping (reuse HR
FIND_IN_SETmembership); optional PWA QR-audit, MDM/discovery import (Intune/Jamf CSV), procurement/PO + contract linkage to ERPat Finance, secure-disposal record.
5. Prototype spec (what docs/mockups/assets-blueprint.html shows)
Left sidebar on every page (mirrors the Learn portal shell), Safekeep brand teal #1d97a6, light/dark:
- Dashboard — KPI stats (total / deployed / ready / maintenance / audit-due / warranty-expiring), assets-by-status and assets-by-category strips, "needs attention" and "assigned to me" lists.
- Inventory — searchable/filterable asset grid + table (status ribbon, category icon, assignee), status/category/location filters.
- Asset detail — header (tag, serial, model, status, assignee), meta strip (cost, purchase, warranty, book value), tabs: Details / Custom fields / Activity (chain of custody) / Maintenance / Comments, and the action bar (Checkout / Checkin / Audit / Edit / Label).
- Checkout + Acceptance — target picker (user/location/asset), due date, and the EULA acceptance + e-signature step.
- Audit — scan-tag panel, confirm location/condition, next-audit date.
- Maintenance — log form (type, supplier, dates, cost, warranty flag) + history.
- Depreciation schedule — cost → book value curve + table (straight-line).
- Labels / QR — per-asset + bulk PDF label sheet preview with QR deep-links.
- Import — CSV column-mapper with checkout-on-import.
6. Risks & simplicity guardrails
- Don't rebuild what exists. The lifecycle is a wiring job, not a schema job. Resist re-modelling.
- Keep the JSON
metacustom-field approach. Per-fieldALTERis wrong for DB-per-tenant. - Every mutation writes exactly one
asset_eventsrow — the chain of custody is the product's spine; keep it complete. - Idempotent + reversible migrations only, audit block in order — the module's migrations already model this; hold the line.
- No raw SQL outside models;
save()by reference;lang()everything;deleted=0everywhere — the module mostly complies; the fixes close the exceptions, not open new ones. - Ship the API flag honest.
api_endpoints:trueonly once Phase 4 exists.
Appendix A — Audit findings (prioritized)
HIGH — (1) missing config/default_menu.php (fails ModuleLoaderTest:527); (2) importer created_at→brands
bug (Asset_importer_model.php:562); (3) checkout/audit lifecycle has no controller endpoints.
MEDIUM — (4) SQLi defense gaps (Asset_vendors_model.php:19-49, Asset_entry_comments_model.php:23-28);
(5) maintenance gated on asset not asset_maintenance (Asset_maintenances.php:95,159); (6) stored-XSS in
detail/_make_row views; (7) undeclared Inventory items-helper dependency (Entries.php:16, Asset_models.php:13).
LOW — (8) ~30 hardcoded strings (esp. Entries.php:331-422); (9) missing page-shell wrappers on report/master
views; (10) soft-delete omitted on some secondary JOINs; (11) importer robustness (no transaction, unchecked
insert(), meta replaced not merged, no event on import-checkout); (12) orphaned asset_accessories;
api_endpoints:true unbacked; dead Asset_locations_model load in Asset_groups.php.
Appendix B — Standard-file checklist
| File | Required? | Status |
|---|---|---|
config/default_menu.php |
REQUIRED (test-enforced) | add (Phase 0) |
README.md |
Expected | add (Phase 0) |
CHANGELOG.md |
Expected | add (Phase 0) |
composer.json |
If src/ exists |
add stub (Phase 0) |
docs/ + docs/mockups/ |
Polish | this plan + prototype |
config/api_routes.php / euapi_routes.php (+ sidecars) |
If api_endpoints:true |
Phase 4 (until then flip the flag) |
tests/ |
Polish | Phase 1+ (cover the lifecycle) |
module.json, config/{menu,module_config,permissions,routes,widgets}.php, helpers/, jobs/, language/, migrations/ |
— | present & correct |