Merge development into main: Matrix-notification visibility-leak batch (#46, #69, #71, #72)

- Redact ticket title for non-public tickets in create/status-change Matrix notifications (#46)
- Exclude shared notify list and redact title in notifyWatchers() for non-public tickets (#71)
- Redact ticket title for non-public tickets in assignment notifications (#72)
- Verify mentioned-user access before sending @mention notifications (#69)
This commit is contained in:
2026-09-11 11:26:58 -04:00
6 changed files with 65 additions and 21 deletions
+10 -3
View File
@@ -177,9 +177,16 @@ try {
$ticketTitle = $ticket['title'] ?? "Ticket #{$ticketId}";
$ticketVisibility = $ticket['visibility'] ?? 'public';
// @mention notifications — resolve usernames → Matrix IDs via Synapse Admin API
if (!empty($mentionedUsers)) {
$mentionedUsernames = array_column($mentionedUsers, 'username');
// @mention notifications — resolve usernames → Matrix IDs via Synapse Admin API.
// Only notify mentioned users who actually have access to this ticket;
// otherwise a mention would DM them the ticket's title and comment text
// even though canUserAccessTicket() would deny them the ticket itself.
$accessibleMentionedUsers = array_filter(
$mentionedUsers,
fn($u) => $ticketModel->canUserAccessTicket($ticket, $u)
);
if (!empty($accessibleMentionedUsers)) {
$mentionedUsernames = array_column($accessibleMentionedUsers, 'username');
$mentionedMatrixIds = SynapseHelper::resolveUsernames($mentionedUsernames);
if (!empty($mentionedMatrixIds)) {
NotificationHelper::sendMentionNotification($ticketId, $ticketTitle, $commentText, $authorDisplay, $mentionedMatrixIds);
+2 -1
View File
@@ -76,7 +76,8 @@ if ($assignedTo === null || $assignedTo === '') {
$ticket['title'] ?? "Ticket #{$ticketId}",
$assigneeName,
$assigneeMatrix,
$changedByDisplay
$changedByDisplay,
$ticket['visibility'] ?? 'public'
);
}
}
+2 -1
View File
@@ -171,7 +171,8 @@ if ($currentStatus !== $newStatus) {
$currentStatus,
$newStatus,
(string)$ticket['title'],
$keyName
$keyName,
$ticket['visibility'] ?? 'public'
);
NotificationHelper::notifyWatchers(
$conn,
+2 -1
View File
@@ -267,7 +267,8 @@ try {
$currentTicket['status'],
$updateData['status'],
$updateData['title'],
$changedBy
$changedBy,
$currentTicket['visibility'] ?? 'public'
);
NotificationHelper::notifyWatchers(
$this->conn,
+48 -14
View File
@@ -40,20 +40,40 @@ class NotificationHelper
return array_values(array_filter(array_map('trim', explode(',', $raw))));
}
/**
* Redact a ticket title for the shared Matrix notify list when the
* ticket isn't public, matching how sendCommentNotification() and
* notifyWatchers() already redact comment/activity previews for the
* same list.
*/
private static function redactedTitle(string $title, string $visibility): string
{
return $visibility === 'public' ? $title : '(restricted ticket — title hidden)';
}
// ─── Public event methods ─────────────────────────────────────────────────
/**
* New ticket created (manual or automated/API).
*
* $ticketData['visibility'] ('public', 'internal', or 'confidential') is
* used to redact the title sent to the shared MATRIX_NOTIFY_USERS list
* for non-public tickets, same as sendCommentNotification()'s preview
* redaction. Defaults to 'public' for callers (e.g. the hwmonDaemon
* Bearer-API paths) that never set a non-default visibility.
*/
public static function sendTicketNotification($ticketId, array $ticketData, string $trigger = 'manual'): void
{
preg_match('/^\[([^\]]+)\]/', $ticketData['title'] ?? '', $m);
$visibility = $ticketData['visibility'] ?? 'public';
$title = $ticketData['title'] ?? 'Untitled';
preg_match('/^\[([^\]]+)\]/', $title, $m);
$source = $m[1] ?? ($trigger === 'automated' ? 'Automated' : 'Manual');
self::fire([
'event' => 'ticket_created',
'ticket_id' => $ticketId,
'title' => $ticketData['title'] ?? 'Untitled',
'title' => self::redactedTitle($title, $visibility),
'priority' => (int)($ticketData['priority'] ?? 4),
'category' => $ticketData['category'] ?? 'General',
'type' => $ticketData['type'] ?? 'Issue',
@@ -73,13 +93,16 @@ class NotificationHelper
* @param string $newStatus
* @param string $ticketTitle
* @param string|null $changedByDisplay Display name of the user who changed status
* @param string $visibility Ticket visibility; non-public titles are
* redacted before being sent to the shared
* notify list, same as sendTicketNotification().
*/
public static function sendStatusChangeNotification($ticketId, string $oldStatus, string $newStatus, string $ticketTitle, ?string $changedByDisplay = null): void
public static function sendStatusChangeNotification($ticketId, string $oldStatus, string $newStatus, string $ticketTitle, ?string $changedByDisplay = null, string $visibility = 'public'): void
{
self::fire([
'event' => 'status_changed',
'ticket_id' => $ticketId,
'title' => $ticketTitle,
'title' => self::redactedTitle($ticketTitle, $visibility),
'old_status' => $oldStatus,
'new_status' => $newStatus,
'changed_by' => $changedByDisplay,
@@ -166,11 +189,12 @@ class NotificationHelper
* @param array $extraData Merged into the payload (old_status/new_status, author, etc.)
* @param int|null $excludeUserId Don't notify the actor themselves
* @param string $visibility Ticket visibility: 'public', 'internal', or
* 'confidential'. notify_users includes the
* shared list, which may contain users without
* access to non-public tickets, so any comment
* body preview in $extraData is redacted for
* non-public tickets.
* 'confidential'. The shared notify list may
* contain users without access to non-public
* tickets, so for those tickets it's excluded
* entirely (only actual watchers are notified)
* and both the title and any comment/body
* preview in $extraData are redacted.
*/
public static function notifyWatchers(\mysqli $conn, $ticketId, string $ticketTitle, string $event, array $extraData = [], ?int $excludeUserId = null, string $visibility = 'public'): void
{
@@ -230,13 +254,17 @@ class NotificationHelper
return;
}
// Remove the global notify list duplicates and build payload
$allNotify = array_unique(array_merge($matrixIds, self::notifyUsers()));
// The shared notify list may include users without access to
// non-public tickets, so only mix it in for public tickets — for
// internal/confidential tickets, notify actual watchers only.
$allNotify = $visibility === 'public'
? array_unique(array_merge($matrixIds, self::notifyUsers()))
: $matrixIds;
$payload = array_merge($extraData, [
'event' => $event,
'ticket_id' => $ticketId,
'title' => $ticketTitle,
'title' => self::redactedTitle($ticketTitle, $visibility),
'url' => UrlHelper::ticketUrl($ticketId),
'notify_users' => array_values($allNotify),
]);
@@ -252,8 +280,14 @@ class NotificationHelper
* @param string|null $assigneeName Display name of new assignee
* @param string|null $assigneeMatrix Matrix user ID of new assignee (to DM)
* @param string|null $changedByDisplay
* @param string $visibility Ticket visibility; non-public titles are
* redacted before being sent to the shared
* notify list, same as sendTicketNotification().
* The assignee is DMed directly regardless,
* since they now have standing access to the
* ticket by virtue of being assigned to it.
*/
public static function sendAssignmentNotification($ticketId, string $ticketTitle, ?string $assigneeName, ?string $assigneeMatrix, ?string $changedByDisplay = null): void
public static function sendAssignmentNotification($ticketId, string $ticketTitle, ?string $assigneeName, ?string $assigneeMatrix, ?string $changedByDisplay = null, string $visibility = 'public'): void
{
$notifyUsers = self::notifyUsers();
// Also notify the assignee directly if we know their Matrix ID
@@ -267,7 +301,7 @@ class NotificationHelper
self::fire([
'event' => 'assigned',
'ticket_id' => $ticketId,
'title' => $ticketTitle,
'title' => self::redactedTitle($ticketTitle, $visibility),
'assignee' => $assigneeName,
'changed_by' => $changedByDisplay,
'url' => UrlHelper::ticketUrl($ticketId),
+1 -1
View File
@@ -38,7 +38,7 @@ class CommentModel
}
$placeholders = str_repeat('?,', count($usernames) - 1) . '?';
$sql = "SELECT user_id, username, display_name FROM users WHERE username IN ($placeholders)";
$sql = "SELECT user_id, username, display_name, is_admin, `groups` FROM users WHERE username IN ($placeholders)";
$stmt = $this->conn->prepare($sql);
$types = str_repeat('s', count($usernames));