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:
@@ -67,6 +67,7 @@ try {
|
||||
$keyName = trim($input['key_name'] ?? '');
|
||||
$expiresInDays = $input['expires_in_days'] ?? null;
|
||||
$scope = $input['scope'] ?? 'read_write';
|
||||
$seeAllVisibility = !empty($input['see_all_visibility']);
|
||||
|
||||
if (empty($keyName)) {
|
||||
http_response_code(400);
|
||||
@@ -100,7 +101,7 @@ try {
|
||||
|
||||
// Generate API key
|
||||
$apiKeyModel = new ApiKeyModel($conn);
|
||||
$result = $apiKeyModel->createKey($keyName, $_SESSION['user']['user_id'], $expiresInDays, $scope);
|
||||
$result = $apiKeyModel->createKey($keyName, $_SESSION['user']['user_id'], $expiresInDays, $scope, $seeAllVisibility);
|
||||
|
||||
if (!$result['success']) {
|
||||
throw new Exception($result['error'] ?? "Failed to generate API key");
|
||||
@@ -113,7 +114,7 @@ try {
|
||||
'create',
|
||||
'api_key',
|
||||
$result['key_id'],
|
||||
['key_name' => $keyName, 'expires_in_days' => $expiresInDays, 'scope' => $scope]
|
||||
['key_name' => $keyName, 'expires_in_days' => $expiresInDays, 'scope' => $scope, 'see_all_visibility' => $seeAllVisibility]
|
||||
);
|
||||
|
||||
// Clear output buffer
|
||||
@@ -127,6 +128,7 @@ try {
|
||||
'key_prefix' => $result['key_prefix'],
|
||||
'key_id' => $result['key_id'],
|
||||
'scope' => $result['scope'],
|
||||
'see_all_visibility' => $result['see_all_visibility'],
|
||||
'expires_at' => $result['expires_at']
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
|
||||
+30
-5
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user