Re-check ticket visibility before surfacing in-app notifications (#48)
All four notification queries in api/notifications.php (assign, comment, status-change, mention) were scoped purely by created_by/assigned_to/ticket_watchers membership and historical audit_log contents — never by canUserAccessTicket(). If a ticket's visibility was later tightened, or a user's group/watcher access revoked, a notification still surfaced in their bell dropdown, disclosing the ticket's title and that activity occurred even though opening the ticket itself would now be blocked. Batch-fetches the tickets referenced by all candidate notifications (via the existing getTicketsByIds()) and filters out any whose current state canUserAccessTicket() would reject for the requesting user, before formatting the response — so a notification for a ticket the user can no longer see simply disappears rather than lingering as a disclosure. Verified against real MariaDB with a running server: an assignment notification is visible while the user is the assignee of a public ticket, and disappears once the ticket is reassigned away and made confidential. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lhz7pGMaoTfL5sdYS5XiKv
This commit is contained in:
+40
-1
@@ -15,8 +15,10 @@
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once dirname(__DIR__) . '/models/UserPreferencesModel.php';
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
|
||||
$prefsModel = new UserPreferencesModel($conn);
|
||||
$ticketModel = new TicketModel($conn);
|
||||
|
||||
// ── POST: mark all read (update last_seen timestamp) ──────────────
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
@@ -204,7 +206,44 @@ foreach (array_merge($assignRows, $commentRows, $statusRows, $mentionRows) as $r
|
||||
$all[] = $row;
|
||||
}
|
||||
usort($all, fn($a, $b) => strcmp($b['created_at'], $a['created_at']));
|
||||
$all = array_slice($all, 0, 30);
|
||||
|
||||
// Re-check current ticket visibility before surfacing anything: a
|
||||
// notification's audit_log entry reflects historical activity, but the
|
||||
// ticket's visibility (or the user's group/watcher standing) may have
|
||||
// tightened since. Without this, a notification still discloses the
|
||||
// ticket's title and that activity occurred to someone who currently
|
||||
// shouldn't see it, even though the ticket view's own access check would
|
||||
// correctly reject them from opening it.
|
||||
$candidateTicketIds = [];
|
||||
foreach ($all as $row) {
|
||||
$details = json_decode($row['details'] ?? '{}', true) ?? [];
|
||||
$actionType = ($row['action_type'] === 'create' && $row['entity_type'] === 'comment')
|
||||
? 'comment'
|
||||
: $row['action_type'];
|
||||
$tid = ($actionType === 'comment' || $actionType === 'mention')
|
||||
? ($details['ticket_id'] ?? 0)
|
||||
: $row['entity_id'];
|
||||
if ($tid) {
|
||||
$candidateTicketIds[(string)$tid] = true;
|
||||
}
|
||||
}
|
||||
$ticketsById = !empty($candidateTicketIds)
|
||||
? $ticketModel->getTicketsByIds(array_keys($candidateTicketIds))
|
||||
: [];
|
||||
|
||||
$all = array_filter($all, function ($row) use ($ticketsById, $currentUser, $ticketModel) {
|
||||
$details = json_decode($row['details'] ?? '{}', true) ?? [];
|
||||
$actionType = ($row['action_type'] === 'create' && $row['entity_type'] === 'comment')
|
||||
? 'comment'
|
||||
: $row['action_type'];
|
||||
$tid = (string)(($actionType === 'comment' || $actionType === 'mention')
|
||||
? ($details['ticket_id'] ?? 0)
|
||||
: $row['entity_id']);
|
||||
$ticket = $ticketsById[$tid] ?? null;
|
||||
return $ticket && $ticketModel->canUserAccessTicket($ticket, $currentUser);
|
||||
});
|
||||
|
||||
$all = array_slice(array_values($all), 0, 30);
|
||||
|
||||
// Format for response
|
||||
$notifications = [];
|
||||
|
||||
Reference in New Issue
Block a user