-
-
Notifications
You must be signed in to change notification settings - Fork 29
Confidential Tickets Developer Guide
Discussion #62, step 1 of 3 Β· Ships in 2.8.0 Β· User page: Confidential tickets
Step 1 of portal managers. It adds a sensitivity (normal / confidential) to every ticket; portalTicketAccess() (step 3) turns it into nothing, a stub or the whole ticket for a manager, per managers_confidential. This page covers how a ticket gets its sensitivity, and the one rule anyone creating tickets must keep.
Every code path that creates a ticket must call ticketSensitivityApplyDefaults() after its insert. So must every path that changes a ticket's department. If one doesn't, a ticket arriving through an HR mailbox, or filed into HR, stays Normal. Nothing errors and nothing looks wrong, and a manager can later read it.
This rule exists because the obvious design doesn't work here. TicketsService::createTicket() looks like the place for one hook, but when this was built, only 2 of the 11 creation paths went through it:
| # | Path | Where the hook is | What can make it confidential |
|---|---|---|---|
| 1 | Analyst UI, REST API v1 | TicketsService::createTicket() |
chosen sensitivity, department, mailbox |
| 2 | Email ingest (Graph, Gmail, IMAP) |
api/tickets/check_mailbox_email.php, before ticketDispatchCreated()
|
mailbox |
| 3 | Self-service portal | api/self-service/create_ticket.php |
requester's tick, mailbox |
| 4 | Catalogue approval | includes/catalogue_approvals.php |
department |
| 5 | Workflow "Create a ticket" | workflow/includes/engine.php |
department |
| 6 | WhatsApp / SMS / Slack | includes/messaging/ingest.php |
nothing yet (no department, no mailbox) |
| 7 | Web chat | includes/webchat/webchat.php |
nothing yet |
| 8 | Merge (into a new ticket, or an existing one) | includes/ticket_merge.php |
any merged ticket confidential |
| 9 | Split | includes/ticket_split.php |
source confidential |
Paths 6 and 7 call it even though it does nothing there today. Every path follows the rule, so a default added to those channels later can't be missed. The demo importer relies on the column default (normal).
Department changes go through TicketsService::updateTicket(), which covers the ticket screen, bulk actions and REST API PATCH. One screen bypasses it, System β Orphaned tickets (api/system/assign_ticket_department.php), so that screen calls the helper itself.
includes/ticket_sensitivity.php:
function ticketSensitivityApplyDefaults(PDO $conn, int $ticketId, ?int $mailboxId = null): bool
{
if (!ticketSensitivityReady($conn)) {
return false;
}
$stmt = $conn->prepare(
"SELECT d.name, d.default_sensitivity
FROM tickets t JOIN departments d ON d.id = t.department_id
WHERE t.id = ?"
);
$stmt->execute([$ticketId]);
$dept = $stmt->fetch(PDO::FETCH_ASSOC);
if ($dept && $dept['default_sensitivity'] === 'confidential') {
return ticketSensitivityRaise($conn, $ticketId, 'the ' . $dept['name'] . ' department is confidential');
}
if ($mailboxId) {
$stmt = $conn->prepare("SELECT name, default_sensitivity FROM target_mailboxes WHERE id = ?");
$stmt->execute([$mailboxId]);
$mb = $stmt->fetch(PDO::FETCH_ASSOC);
if ($mb && $mb['default_sensitivity'] === 'confidential') {
return ticketSensitivityRaise($conn, $ticketId, 'arrived through the ' . $mb['name'] . ' mailbox');
}
}
return false;
}ticketSensitivityRaise() only ever moves a ticket to confidential. It is an UPDATE β¦ WHERE sensitivity <> 'confidential', and it writes an audit row with no analyst (NULL: a rule did this, not a person) and the reason in the value, e.g. Confidential - the HR department is confidential.
Nothing automatic lowers a ticket. Moving it out of HR, or switching HR's setting off, leaves it confidential. The only way back to Normal is updateTicket() with sensitivity: 'normal', meaning an analyst on the ticket screen or a REST API PATCH, and the audit records who did it. The portal endpoint api/self-service/mark_confidential.php can only raise.
Why so strict: marking something confidential by mistake costs nothing, but unmarking it by mistake can't be undone once a manager has read it.
api/tickets/save_department.php marks the tickets already in the department when the setting goes from normal to confidential ("every ticket in, or moved into"). It returns raised, which the settings page shows as a message. Turning it off changes nothing.
PDO::lastInsertId() reports on the last query run. The first version read it after ticketSensitivityReady() had run its own SELECTs, got 0, and so saved a new department's setting against no row at all: HR, created as confidential, came out normal. The ID is now read immediately after the INSERT.
New code reaches an install before its administrator runs Verification, and the inbox list is the busiest query in the product. So:
- Every read (
get_emails,get_email_detail,get_departments,get_mailboxes, the portal list and detail) selects'normal' AS sensitivityuntilticketSensitivityReady()says the column exists. -
Asking for Confidential is refused, not dropped.
createTicket(),updateTicket()and the portal's create all return "Run System β Database Verificationβ¦" before writing anything. The first version quietly returned success, which would have left someone believing a ticket was confidential when it wasn't.
| Where | What |
|---|---|
| Ticket properties | Sensitivity select (red when confidential) and a Confidential marker in the collapsed summary bar |
| Ticket list | padlock before the subject |
| New ticket | Sensitivity field |
| Department move |
assign_ticket.php returns the sensitivity after the save, so the open ticket turns red at once when a move raises it |
| Settings β Departments / Mailboxes | the confidential checkbox; a Confidential chip in the department list |
| Portal | This is confidential tick, Mark confidential (one way, confirmed), padlock and badge |
| REST API v1 |
sensitivity in ticket output; accepted on create and PATCH |
inbox.css went to v=73 on all 178 pages that load it, and inbox.js to v=137.
Everything is in includes/ticket_sensitivity.php, so there is one answer per kind of exit:
| Helper | Used by |
|---|---|
ticketIsConfidential($conn, $id) |
everything below |
ticketAiAllowed($conn, $id) + TICKET_AI_CONFIDENTIAL_ERROR
|
each single-ticket AI endpoint, straight after its access check: api/tickets/ai_summary.php, ai_read.php, ai_cleanup_reply.php, ai_merge_summary.php, api/knowledge/writeup_stream.php, api/messaging/ai_summary.php, ai_suggest_reply.php, and api/knowledge/ai_chat.php when a ticket_id comes with the question (the inbox's Ask AI now sends it) |
ticketAiConfidentialPolicy() |
the setting ticket_ai_confidential - block (never saved = block) or allow
|
ticketAiExclusionSql($conn, $alias) |
many-ticket AI: problem root cause and suggestions, the knowledge write-up's cluster subjects, gapWindowSql() (gap analysis) |
ticketAiSubjectSql($conn, $alias) |
Warbot's tools: the ticket still counts, its subject reads "Confidential ticket" |
ticketRedactForOutbound($conn, $payload) |
WorkflowEngine::action_send_webhook() - an allow-list (ids, number, status, priority, department, team, dates), because a deny-list of content fields misses the next one somebody adds. {{ticket.full}} / the full-record preset is skipped for a confidential ticket: the full record is the content |
ticketEmailRecipientsAllowed($conn, $id, $to) + TICKET_EMAIL_CONFIDENTIAL_SKIP
|
WorkflowEngine::action_send_email() (3.0.0). True for a normal ticket; for a confidential one only when every address (comma or semicolon separated, case-insensitive) is the ticket's requester (users.email via tickets.user_id) or an active analyst. Anything else returns skipped with the sentence as its reason, before a mailbox is looked up. The ticket id is the action's arg or the payload's ticket, so a "send from this mailbox" email quoting {{ticket.subject}} is held too. A failed lookup refuses |
TICKET_CONFIDENTIAL_SUBJECT + ticketSensitivitySelectSql($conn, $alias)
|
the two calendar exits (3.0.0): calendarSyncEventFromTicket() in includes/calendar_sync/push.php (Graph and CalDAV) and api/tickets/schedule_feed.php (.ics). Title becomes T-123 β Confidential ticket, the requester leaves the body, status and priority stay. The SELECT helper returns 'normal' before Database Verification so neither query breaks on an old schema |
π An allow-list of recipients, not a deny-list. Nobody can list who must not see an HR ticket - a manager's address, a forwarding list and a supplier all look alike. Who may is short and known: the person who raised it, and the service desk.
π Raising re-syncs the calendar. ticketSensitivityRaise() (a mailbox, department, split or merge rule) calls calendarSyncReconcileTicket(), so a ticket already in somebody's Outlook loses its subject at once rather than at its next edit. An analyst's own change goes through TicketsService::updateTicket(), which already reconciles after every save. calendarSyncEventFromTicket() no longer assumes TicketsService is loaded (the reconcile can now run from the portal's mark confidential); it falls back to 60 minutes like the task path.
Tested by tests/confidential-exits.php (18 checks): the recipient rule both ways (requester, capitals, analyst, mixed lists, empty), in a rolled-back transaction; the real action_send_email skipping for an existing confidential ticket via the arg and via the payload - on the path that returns before any mailbox, so nothing is sent; the calendar event with a positive control (a normal ticket keeps subject and requester) and a row without the column reading as normal; and the SELECT expression run for real, because the calendar code swallows its own errors.
aiProviderChat() (rfpAiCallAnthropicStreaming()), so a guard inside the shared AI client would have missed half the calls - which is why each endpoint checks for itself.
Trackers: api/integrations/escalate_ticket.php returns confidential with the preview and refuses the escalation without confirm_confidential; the modal shows a warning and the box (renderEscalateConfidential() in assets/js/inbox.js). action_escalate_to_tracker and action_send_note_to_tracker return skipped: confidential - skipped rather than failed, so the rest of the workflow runs.
Tested on docker/proxy-test: every AI endpoint refuses a confidential ticket and lets a normal one through to its own configuration check (the write-up checks its provider first, so on an unconfigured stack it never reaches the guard); the setting flips it both ways; exclusion and masking counted in SQL (77 tickets, 9 confidential β 68); a real Slack-preset webhook for a confidential ticket was queued as {"text":"New: Confidential ticket ()"} - no subject, email or reply - with a normal ticket's keeping its subject; both workflow tracker actions skipped; the escalation refused without the box and passed with it. Negative control: with the redaction line commented out, three of the webhook checks failed.
On the docker/proxy-test stack (throwaway data), through the real endpoints:
- Before Verification: lists load; Confidential is refused on create and update; Normal still saves.
-
Creating:
- nothing chosen β normal;
- chosen β confidential;
- HR mailbox β confidential;
- Helpdesk mailbox β normal;
- HR department β confidential.
-
Moving and switching:
- moved into HR β confidential;
- moved back out β still confidential;
- analyst chooses Normal β normal;
- invalid value β refused;
- bulk move into a confidential department β confidential;
- department switched on β existing tickets raised (
raised: 2); - switched off β nothing lowered.
-
Portal:
- tick β confidential;
- no tick β normal;
- HR mailbox β confidential;
- Mark confidential on your own ticket β confidential;
- on someone else's β Ticket not found.
- Split and merge: split off a confidential ticket β confidential; normal target after merging in a confidential ticket β confidential.
-
Both interfaces driven in headless Chrome: list padlocks, the select, the summary marker, the new-ticket field, both settings checkboxes, the portal switch, badge and button. A real department move through the dropdown turned the open ticket red without reopening it. Negative control: a deliberately broken
inbox.jsmade the harness fail.
Not tested: the email-ingest hook with a real delivered email. It was checked by reading the code ($mailboxId is in scope where the hook sits), and the same helper was proven through the service's mailbox path. The workflow and catalogue hooks were also checked by reading only.
- Confidential tickets β the user-facing page
- Merging tickets β Developer Guide
- Database Verification β Developer Guide
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