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
+4 -2
View File
@@ -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) {