Compare commits

...
Author SHA1 Message Date
jaredandClaude Sonnet 5 5709c3134f Verify mentioned-user access before sending @mention notifications (#69)
Lint / PHP (phpcs PSR-12) (push) Successful in 40s
Lint / JS (eslint) (push) Successful in 16s
Lint / PHP requirements (version + extensions) (push) Successful in 47s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 2m31s
Lint / Deploy (push) Successful in 4s
sendMentionNotification(), called from add_comment.php, had no
visibility check at all — unlike sendCommentNotification()/
notifyWatchers() which redact the comment preview for non-public
tickets. Mentioning a user with zero standing access to a confidential
ticket (not creator/assignee/admin, not in visibility_groups) sent
them a Matrix DM with the full ticket title AND comment text — worse
than #46 since it's delivered directly to an individual rather than
diluted into a shared list.

add_comment.php now filters mentioned users through
canUserAccessTicket() before resolving Matrix IDs, skipping the
notification entirely for anyone without access (one of the two
options the issue names as acceptable). getMentionedUsers() needed to
start selecting is_admin and groups alongside user_id/username/
display_name, since canUserAccessTicket() requires them. Verified
against real MariaDB: a user mentioned on a confidential ticket they
don't own/aren't assigned to is correctly denied, a user in the
matching visibility_groups for an internal ticket is correctly
allowed, and the same user is correctly denied on a different internal
ticket whose group they're not in.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nCxwFFsy8ouMWzn56rPVP
2026-09-08 21:49:42 -04:00
jaredandClaude Sonnet 5 60bafae8a0 Redact ticket title for non-public tickets in assignment notifications (#72)
sendAssignmentNotification() had the same missing-visibility gap as
#46 in a separate function: assigning a user to a confidential/internal
ticket broadcast the ticket title to the shared Matrix notify list
unconditionally when MATRIX_NOTIFY_ASSIGNMENTS is enabled.

Threaded visibility through using the same redactedTitle() helper
added for #46, wired up from the already-fetched ticket row in
assign_ticket.php. The assignee is still DMed directly regardless,
since being assigned gives them standing access to the ticket — but
because notify_users is one shared payload, they see the same redacted
title as everyone else on it rather than a personalized one. Verified
end-to-end with a local HTTP server capturing the webhook payload for
both public and confidential tickets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nCxwFFsy8ouMWzn56rPVP
2026-09-08 21:49:34 -04:00
jaredandClaude Sonnet 5 90d798966b Exclude shared notify list and redact title in notifyWatchers() for non-public tickets (#71)
notifyWatchers() only redacted the comment/activity preview for
non-public tickets — the shared MATRIX_NOTIFY_USERS list was still
merged into notify_users unconditionally, and the ticket title was
never redacted at all. A status-change/comment notification for a
confidential ticket with watchers still broadcast that ticket's title
to the shared list, even though the function's own docblock intended
to protect non-public tickets from it.

For non-public tickets, the shared list is now excluded entirely
(only actual watchers are notified) and the title is redacted via the
same redactedTitle() helper added for #46. Verified against real
MariaDB with a real watcher row: for a confidential ticket, the
captured webhook payload has only the watcher's Matrix ID (no shared
list) and a redacted title; for the same ticket made public, the
shared list is included and the title passes through unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nCxwFFsy8ouMWzn56rPVP
2026-09-08 21:49:17 -04:00
jaredandClaude Sonnet 5 442cd1d6f6 Redact ticket title for non-public tickets in create/status-change Matrix notifications (#46)
sendTicketNotification() and sendStatusChangeNotification() always sent
the ticket title to the shared MATRIX_NOTIFY_USERS list regardless of
visibility, unlike sendCommentNotification()/notifyWatchers() which
already redact the comment/activity preview for non-public tickets.
Creating or changing the status of a confidential ticket broadcast its
title to a shared Matrix room, defeating the point of the Confidential
visibility level.

Added a shared redactedTitle() helper and threaded visibility through
both functions (sendTicketNotification reads it from the existing
$ticketData['visibility'] key; sendStatusChangeNotification takes a new
optional parameter, wired up in both callers from the already-fetched
ticket row). Verified end-to-end with a local HTTP server capturing the
actual webhook payloads: public tickets pass the title through
unchanged, confidential/internal tickets get the redacted placeholder.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nCxwFFsy8ouMWzn56rPVP
2026-09-08 21:49:06 -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}"; $ticketTitle = $ticket['title'] ?? "Ticket #{$ticketId}";
$ticketVisibility = $ticket['visibility'] ?? 'public'; $ticketVisibility = $ticket['visibility'] ?? 'public';
// @mention notifications — resolve usernames → Matrix IDs via Synapse Admin API // @mention notifications — resolve usernames → Matrix IDs via Synapse Admin API.
if (!empty($mentionedUsers)) { // Only notify mentioned users who actually have access to this ticket;
$mentionedUsernames = array_column($mentionedUsers, 'username'); // 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); $mentionedMatrixIds = SynapseHelper::resolveUsernames($mentionedUsernames);
if (!empty($mentionedMatrixIds)) { if (!empty($mentionedMatrixIds)) {
NotificationHelper::sendMentionNotification($ticketId, $ticketTitle, $commentText, $authorDisplay, $mentionedMatrixIds); NotificationHelper::sendMentionNotification($ticketId, $ticketTitle, $commentText, $authorDisplay, $mentionedMatrixIds);
+2 -1
View File
@@ -76,7 +76,8 @@ if ($assignedTo === null || $assignedTo === '') {
$ticket['title'] ?? "Ticket #{$ticketId}", $ticket['title'] ?? "Ticket #{$ticketId}",
$assigneeName, $assigneeName,
$assigneeMatrix, $assigneeMatrix,
$changedByDisplay $changedByDisplay,
$ticket['visibility'] ?? 'public'
); );
} }
} }
+2 -1
View File
@@ -171,7 +171,8 @@ if ($currentStatus !== $newStatus) {
$currentStatus, $currentStatus,
$newStatus, $newStatus,
(string)$ticket['title'], (string)$ticket['title'],
$keyName $keyName,
$ticket['visibility'] ?? 'public'
); );
NotificationHelper::notifyWatchers( NotificationHelper::notifyWatchers(
$conn, $conn,
+2 -1
View File
@@ -267,7 +267,8 @@ try {
$currentTicket['status'], $currentTicket['status'],
$updateData['status'], $updateData['status'],
$updateData['title'], $updateData['title'],
$changedBy $changedBy,
$currentTicket['visibility'] ?? 'public'
); );
NotificationHelper::notifyWatchers( NotificationHelper::notifyWatchers(
$this->conn, $this->conn,
+48 -14
View File
@@ -40,20 +40,40 @@ class NotificationHelper
return array_values(array_filter(array_map('trim', explode(',', $raw)))); 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 ───────────────────────────────────────────────── // ─── Public event methods ─────────────────────────────────────────────────
/** /**
* New ticket created (manual or automated/API). * 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 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'); $source = $m[1] ?? ($trigger === 'automated' ? 'Automated' : 'Manual');
self::fire([ self::fire([
'event' => 'ticket_created', 'event' => 'ticket_created',
'ticket_id' => $ticketId, 'ticket_id' => $ticketId,
'title' => $ticketData['title'] ?? 'Untitled', 'title' => self::redactedTitle($title, $visibility),
'priority' => (int)($ticketData['priority'] ?? 4), 'priority' => (int)($ticketData['priority'] ?? 4),
'category' => $ticketData['category'] ?? 'General', 'category' => $ticketData['category'] ?? 'General',
'type' => $ticketData['type'] ?? 'Issue', 'type' => $ticketData['type'] ?? 'Issue',
@@ -73,13 +93,16 @@ class NotificationHelper
* @param string $newStatus * @param string $newStatus
* @param string $ticketTitle * @param string $ticketTitle
* @param string|null $changedByDisplay Display name of the user who changed status * @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([ self::fire([
'event' => 'status_changed', 'event' => 'status_changed',
'ticket_id' => $ticketId, 'ticket_id' => $ticketId,
'title' => $ticketTitle, 'title' => self::redactedTitle($ticketTitle, $visibility),
'old_status' => $oldStatus, 'old_status' => $oldStatus,
'new_status' => $newStatus, 'new_status' => $newStatus,
'changed_by' => $changedByDisplay, 'changed_by' => $changedByDisplay,
@@ -166,11 +189,12 @@ class NotificationHelper
* @param array $extraData Merged into the payload (old_status/new_status, author, etc.) * @param array $extraData Merged into the payload (old_status/new_status, author, etc.)
* @param int|null $excludeUserId Don't notify the actor themselves * @param int|null $excludeUserId Don't notify the actor themselves
* @param string $visibility Ticket visibility: 'public', 'internal', or * @param string $visibility Ticket visibility: 'public', 'internal', or
* 'confidential'. notify_users includes the * 'confidential'. The shared notify list may
* shared list, which may contain users without * contain users without access to non-public
* access to non-public tickets, so any comment * tickets, so for those tickets it's excluded
* body preview in $extraData is redacted for * entirely (only actual watchers are notified)
* non-public tickets. * 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 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; return;
} }
// Remove the global notify list duplicates and build payload // The shared notify list may include users without access to
$allNotify = array_unique(array_merge($matrixIds, self::notifyUsers())); // 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, [ $payload = array_merge($extraData, [
'event' => $event, 'event' => $event,
'ticket_id' => $ticketId, 'ticket_id' => $ticketId,
'title' => $ticketTitle, 'title' => self::redactedTitle($ticketTitle, $visibility),
'url' => UrlHelper::ticketUrl($ticketId), 'url' => UrlHelper::ticketUrl($ticketId),
'notify_users' => array_values($allNotify), 'notify_users' => array_values($allNotify),
]); ]);
@@ -252,8 +280,14 @@ class NotificationHelper
* @param string|null $assigneeName Display name of new assignee * @param string|null $assigneeName Display name of new assignee
* @param string|null $assigneeMatrix Matrix user ID of new assignee (to DM) * @param string|null $assigneeMatrix Matrix user ID of new assignee (to DM)
* @param string|null $changedByDisplay * @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(); $notifyUsers = self::notifyUsers();
// Also notify the assignee directly if we know their Matrix ID // Also notify the assignee directly if we know their Matrix ID
@@ -267,7 +301,7 @@ class NotificationHelper
self::fire([ self::fire([
'event' => 'assigned', 'event' => 'assigned',
'ticket_id' => $ticketId, 'ticket_id' => $ticketId,
'title' => $ticketTitle, 'title' => self::redactedTitle($ticketTitle, $visibility),
'assignee' => $assigneeName, 'assignee' => $assigneeName,
'changed_by' => $changedByDisplay, 'changed_by' => $changedByDisplay,
'url' => UrlHelper::ticketUrl($ticketId), 'url' => UrlHelper::ticketUrl($ticketId),
+1 -1
View File
@@ -38,7 +38,7 @@ class CommentModel
} }
$placeholders = str_repeat('?,', count($usernames) - 1) . '?'; $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); $stmt = $this->conn->prepare($sql);
$types = str_repeat('s', count($usernames)); $types = str_repeat('s', count($usernames));