Restrict API keys to public-visibility tickets by default (#70)

api/tickets_api.php (both the single-ticket read and the list/triage
path) bypassed ticket visibility entirely for any 'read'-scope key,
regardless of who it was issued to or what it was for — any key got
blanket read access to Confidential and Internal ticket titles,
descriptions, and comments, with no way to scope a key more narrowly.

Added see_all_visibility to api_keys (migration 006), defaulting to
false for both new and existing keys — the prior blanket-access
behavior is what's being restricted here, so unlike scope's own
un-migrated-database fallback (which defaults toward preserving old
behavior), a missing/null value here defaults to the new, restrictive
one. An admin can opt a specific key in via a new checkbox in the API
Key Management UI when it genuinely needs the full queue.

tickets_api.php now builds a synthetic "no special access" user and
runs it through TicketModel's existing per-user visibility plumbing
(getVisibilityFilter/canUserAccessTicket) instead of a separate SQL
path, so this stays in lockstep with however visibility rules evolve
for real users. That synthetic user_id is -1, not 0: testing surfaced
that canUserAccessTicket()'s confidential-ticket check does a PHP-level
(int) cast, and (int)null === 0, so an unassigned confidential ticket's
NULL assigned_to would otherwise false-positive-match a user_id of 0.

Verified against real MariaDB with public/confidential/internal test
tickets: a public-only-scoped key's list only returns the public
ticket, and canUserAccessTicket() correctly returns false for both the
confidential ticket (unassigned, then reassigned to a real user — both
cases) and the internal one; a see_all_visibility key sees all three,
unchanged from the prior behavior. Also verified createKey()/
validateKey()'s default-false and explicit-true paths round-trip
correctly through the real DB.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117oBw2jN4kALYeS8HPq4zV
This commit is contained in:
2026-09-12 01:17:53 -04:00
co-authored by Claude Sonnet 5
parent e91f4547b6
commit 6609320c83
7 changed files with 94 additions and 17 deletions
+30 -5
View File
@@ -4,8 +4,10 @@
* tickets_api.php — Bearer-key read endpoint (list/triage + read-one).
*
* GET only. Requires 'read' scope (a 'read_write' key also satisfies it).
* Acts as a trusted automation/server credential: reads return the full queue
* (no per-user visibility filtering).
* By default, a key only sees public-visibility tickets — Confidential and
* Internal tickets are excluded, the same as they would be for a regular user
* with no special access. An admin can mark a specific key see_all_visibility
* (API Key Management) when it genuinely needs the full queue.
*
* GET ?ticket_id=NNN -> {success, ticket, comments}
* GET ?status=&priority=&host= -> {success, tickets, page, total, pages}
@@ -48,6 +50,20 @@ try {
// Reads only need the 'read' scope.
$apiKeyAuth->requireScope('read');
// Keys are public-ticket-only by default (#70) — a key must be explicitly
// marked see_all_visibility to bypass Confidential/Internal restrictions.
// Reuse TicketModel's existing per-user visibility plumbing with a synthetic
// "no special access" user rather than a separate SQL path, so this stays in
// lockstep with however visibility rules evolve for real users. user_id is
// -1, not 0: canUserAccessTicket() does a PHP-level (int) cast for the
// confidential-ticket check, and (int)null === 0, so an unassigned
// confidential ticket's assigned_to would otherwise false-positive-match a
// synthetic user_id of 0. No real user_id is ever <= 0, so -1 can't collide.
$keyContext = $apiKeyAuth->getKeyContext();
$visibilityUser = !empty($keyContext['see_all_visibility'])
? null
: ['user_id' => -1, 'is_admin' => false, 'groups' => ''];
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed. Use GET.']);
@@ -67,6 +83,15 @@ if (isset($_GET['ticket_id']) && trim((string)$_GET['ticket_id']) !== '') {
exit;
}
// A public-only key gets a plain 404 for a non-public ticket — same as a
// regular user hitting a ticket they can't see — rather than a 403 that
// would confirm the ticket exists.
if ($visibilityUser !== null && !$ticketModel->canUserAccessTicket($ticket, $visibilityUser)) {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit;
}
// Flat list of comments (newest first) — same fetch the ticket view uses.
$commentModel = new CommentModel($conn);
$comments = $commentModel->getCommentsByTicketId($ticketId, false);
@@ -114,8 +139,8 @@ if (isset($_GET['host']) && trim((string)$_GET['host']) !== '') {
$search = trim((string)$_GET['host']);
}
// user = null => getAllTickets skips visibility filtering and returns the full
// queue (this is a trusted server credential, not an end user).
// $visibilityUser is null (skip filtering, full queue) only for a key marked
// see_all_visibility; otherwise it restricts to public tickets (see above).
$result = $ticketModel->getAllTickets(
$page,
$limit,
@@ -126,7 +151,7 @@ $result = $ticketModel->getAllTickets(
null,
$search,
$filters,
null
$visibilityUser
);
echo json_encode([