-
Notifications
You must be signed in to change notification settings - Fork 15
Raising Tickets for Others Developer Guide
How the requester picker works underneath: the request flow, where authorisation happens and why it happens twice, and the three bugs the feature fixed on the way through. The plain-language version is Raising a ticket for someone else.
Asked for in discussion #54. Shipped in 0a9ac8cb.
| File | Role |
|---|---|
tickets/index.php |
The combobox, the chosen-state chip, the inline new-requester panel, and the browse modal — all inside #newTicketModal
|
assets/js/inbox.js |
initRequesterPicker() and the req* functions; createNewTicket() sends the result |
assets/css/inbox.css |
.req-* rules, including the [hidden] override described in §7 |
lang/en/tickets.php |
tickets.new_ticket_modal.requester_* |
| File | Role |
|---|---|
api/tickets/get_users.php |
The search. Company-scoped (§4), ?search= and ?limit=
|
api/tickets/create_ticket.php |
Accepts user_id, authorises it (§5) |
| File | Role |
|---|---|
includes/services/tickets.php |
TicketsService::createTicket() — resolves the requester, creates one when needed (§6) |
includes/tenancy.php |
activeTenantFilter(), analystCanAccessUser(), resolveTenantForNewUser() — all pre-existing |
| File | Role |
|---|---|
tests/security-findings/run.php |
Four checks under Unscoped lists |
No schema change. users.tenant_id and the search columns already existed.
analyst types "dan"
│
│ 200ms debounce (one request per pause, not per keystroke)
▼
GET api/tickets/get_users.php?limit=8&search=dan
│ └── activeTenantFilter() on the LIST, not only the count §4
▼
reqRenderResults() → matches + an "add someone new" row that is ALWAYS last §3
│
├── picks a person ─────────► reqChoose() sets reqChosenUser = {id,…}
│ swaps input for the chip
│
└── picks "add new" ───────► reqShowNew() reveals name + email,
pre-filled from the term
▼
createNewTicket() posts EITHER {user_id: 150}
OR {from_name: "…", from_email: "…"}
▼
create_ticket.php analystCanAccessUser() ← the boundary check §5
▼
TicketsService::createTicket()
├── user_id present → SELECT the row, read the address back FROM it
└── otherwise → match on email, or INSERT with a resolved company §6
reqRenderResults() appends the add row on every render, including when there are matches:
const addRow = `<div class="req-opt req-opt-add" role="option" data-add="1">…</div>`;
e.results.innerHTML = empty + rows + addRow;The obvious design is to show it only on zero results. It is wrong for a specific and easily-missed reason: the case where you are adding somebody whose name resembles an existing person is exactly the case where a zero-results trigger never fires. Adding a second Daniel to a system that already has one would leave you with a list of Daniels and no way to say "not those, a new one".
It is also visually separated (border-top on .req-opt-add, a + avatar) because it is a different kind of action rather than the least-good match.
const looksLikeEmail = term.indexOf('@') !== -1;The term goes into the email box if it contains an @, the name box otherwise, and focus lands on the empty one. Retyping something you have already typed is what makes a fallback feel like a penalty for not finding the person.
This was a live cross-company leak, found while building on top of the endpoint.
get_users.php called ticketTenantFilter() and applied it to the per-user ticket count subquery only:
list($ttSql, $ttParams) = ticketTenantFilter($conn, $analystId, 't');
// …
(SELECT COUNT(*) FROM tickets t WHERE t.user_id = u.id{$ttSql}) as ticket_count
FROM users u -- ← no filter hereThe comment beside it explained that the count must not reveal a requester's activity in other companies — true, and the reason the file read as careful. Meanwhile FROM users u returned every requester on the install, with email addresses, to any analyst holding the tickets module. Scoping the number attached to a row is worth nothing when the row is there to be counted.
Same shape as the v1 /users list closed in the August security round; this is its analyst-facing twin. A searchable picker over an unscoped list would have made it materially worse, which is why it was fixed before the feature was built rather than after.
list($uSql, $uParams) = activeTenantFilter($conn, $analystId, 'u');
$sql .= " WHERE 1=1" . $uSql;
$params = array_merge($params, $uParams);
if (!empty($search)) {
$sql .= " AND (u.display_name LIKE ? OR u.email LIKE ? OR u.username LIKE ?)";
⚠️ The brackets are half the fix. The original search clause was a bareWHERE a LIKE ? OR b LIKE ? OR c LIKE ?. AppendingAND tenant_id = ?to that binds the company condition to the last OR branch only — every row matchingdisplay_nameorANDbinds tighter thanOR, so a tenancy clause added to an unbracketed OR list is decorative.
activeTenantFilter() rather than a hand-rolled clause because it already encodes that the Default company owns tenant_id IS NULL rows — so requesters who have never been assigned a company stay visible from Default instead of vanishing from every queue.
Measured: an analyst scoped to one company saw all 34 requesters before, and 1 after. An unscoped admin still sees theirs, which is the control proving it is a filter rather than a break.
?limit= was added at the same time (clamped 1–200, interpolated as an integer, never a bound parameter — MySQL will not take a placeholder in LIMIT). The picker asks for 8, the browse modal for 100.
if ($userId !== null && !analystCanAccessUser($conn, $analystId, $userId)) {
echo json_encode(['success' => false, 'error' => 'That requester was not found']);
exit;
}A scoped list is not a check. get_users.php governs what is easy to choose; user_id arrives in a JSON body and can be any integer. Without this line an analyst scoped to one company could file a ticket against another company's contact by editing one number — the same shape as the S1/S2 findings, arriving fresh with a new feature rather than inherited from an old one.
The refusal text matches a non-existent id on purpose: "not yours" and "not there" must stay indistinguishable, or the refusal confirms the record.
The service takes user_id on trust, exactly as it takes $tenantId on trust, and says so in a comment at the top of createTicket(). That is the existing division of labour in this codebase — boundaries authorise, services execute — and breaking it for one field would be worse than following it.
$requesterUserId = isset($in['user_id']) && (int)$in['user_id'] > 0 ? (int)$in['user_id'] : null;Optional and additive — every existing caller (the v1 API, inbound mail, the portal) still sends an address and is unaffected. When present it wins, because "this exact person" is a stronger statement than an address that has to be matched.
Two consequences worth noting:
The address requirement is now conditional. It is required only when there is no user_id, because picking somebody already identifies them — and a directory-backed requester may legitimately have no mailbox at all (GitHub #47).
The address is read back FROM the record, not from the request:
$pStmt = $conn->prepare("SELECT id, email, display_name FROM users WHERE id = ?");So the ticket and its initial email row describe the person that was actually picked, and a stale id fails loudly rather than creating something unexpected.
On the create path, the old code was:
$conn->prepare("INSERT INTO users (email, display_name, created_at) VALUES (?, ?, UTC_TIMESTAMP())")No tenant_id. Meanwhile api/tickets/save_user.php, includes/ldap.php and api/auth/oidc_callback.php all call resolveTenantForNewUser() to map the email domain to a company. Three paths did it properly and the fourth quietly did not — so a requester born from a manual ticket had no company at all on a multi-company install, their tickets sat in triage indefinitely, and a scoped analyst could not see them.
$newTenantId = function_exists('resolveTenantForNewUser')
? resolveTenantForNewUser($conn, $requesterEmail)
: null;null is still the right answer when the domain maps to nothing — "unknown, send to triage" rather than a guess. Verified both ways, which matters because NULL is also what a broken fix would produce: an unmapped domain still yields NULL, and @bateswells.co.uk (present in tenant_domains) now yields company 4.
.req-chosen[hidden], .req-picker[hidden], .req-new[hidden],
.req-results[hidden], .req-chosen-company[hidden] { display: none !important; }
⚠️ Thehiddenattribute works only through the user-agent rule[hidden] { display: none }, which any author rule settingdisplaybeats on specificity..req-chosen { display: flex }therefore rendered an empty chip above the search box permanently, with correct markup. Found in a screenshot; invisible to reading the HTML, because the HTML was right.
e.results.addEventListener('mousedown', (ev) => { … });The input's blur closes the list. On click the list is gone before the event lands, so the row never activates.
The dropdown uses transform-origin: top center and a 150ms cubic-bezier(0.23, 1, 0.32, 1) entry from scale(0.97) — it belongs to the field it hangs off, not to the middle of itself, and nothing in the real world appears from nothing. The browse button and the clear button take scale(0.92) on :active. All of it is disabled under prefers-reduced-motion.
The picker itself is not a modal, and that is the main interaction decision on the page: raising a ticket for somebody is a tens-of-times-a-day action, and a second surface to open and close is friction paid on every one of them to help a rare case. The modal exists behind the magnifier and you only meet it by asking.
Four checks in tests/security-findings/run.php under Unscoped lists:
| Check | Guards against |
|---|---|
get_users.php scopes the user LIST |
the leak returning |
| …and brackets the search terms | the OR-precedence trap |
create_ticket.php authorises a chosen id |
the picker becoming a cross-company write |
| a manual-ticket requester gets a company | the fourth path drifting again |
Sabotage-verified: removing the filter fails the first check.
Live, with controls:
- cross-tenant
user_id→ refused; the same analyst's own company → succeeds - the old
from_name+from_emailpayload → still works, so the v1 API and inbound mail are unaffected - unmapped domain →
NULL; mapped domain → company 4
⚠️ A measurement method that was wrong.--window-size=360does not produce a 360px viewport in headless Chrome on Windows — the minimum window width forces about 504, so a "360px" screenshot is a 360-wide crop of a 504-wide page. Both this feature and the row-display chips are now checked inside a 360px iframe, which does evaluate media queries at 360, and both reportdocument.body.scrollWidth === 360.
- Changing the requester on an existing ticket. The picker is a component in one modal; nothing consumes it elsewhere yet. It is the obvious next use and would need its own audit trail entry.
- Merging duplicate requesters. Two records for the same person created before this change stay two records.
- The portal. Requesters raising their own tickets are already identified by their session; there is nothing to pick.
- Raising a ticket for someone else — the plain-language version
- Tickets · Multi-Tenancy — Users and Self-Service
- Security hardening 2026-08 — Round three Developer Guide — the v1 twin of §4
- Multi-Tenancy — Pitfalls
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)