Confidential Tickets Developer Guide - edmozley/freeitsm GitHub Wiki
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.
π΄ The one rule
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.
Raise only
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.
Turning a department confidential
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.
β οΈ A trap found while testing: 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.
Before Database Verification
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.
The screens
| 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.
Keeping it inside FreeITSM (the exits)
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.
β οΈ The guard has to be on the server. Two AI paths never went near a ticket id: Ask AI built its question in the browser from the ticket's text, and the endpoint could not tell which ticket it came from. It now receives the id and refuses there.
β οΈ The Anthropic streaming path skips 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.
How it was tested
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.
See also
- Confidential tickets β the user-facing page
- Merging tickets β Developer Guide
- Database Verification β Developer Guide