Skip to content

Confidential Tickets Developer Guide

Ed Mozley edited this page Oct 2, 2026 · 5 revisions

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 sensitivity until ticketSensitivityReady() 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.js made 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

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally