Files
tinker_tickets/models/ApiKeyModel.php
T
jaredandClaude Sonnet 5 6609320c83 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
2026-09-12 01:17:53 -04:00

299 lines
9.2 KiB
PHP

<?php
/**
* ApiKeyModel - Handles API key generation and validation
*/
class ApiKeyModel
{
private $conn;
public function __construct($conn)
{
$this->conn = $conn;
}
/**
* Generate a new API key
*
* @param string $keyName Descriptive name for the key
* @param int $createdBy User ID who created the key
* @param int|null $expiresInDays Number of days until expiration (null for no expiration)
* @param string $scope Access scope: 'read' or 'read_write' (default 'read_write')
* @param bool $seeAllVisibility If true, the key bypasses ticket visibility (Confidential/
* Internal included); defaults to false (public tickets only)
* @return array Array with 'success', 'api_key' (plaintext), 'key_prefix', 'scope', 'error'
*/
public function createKey($keyName, $createdBy, $expiresInDays = null, $scope = 'read_write', $seeAllVisibility = false)
{
// Validate the requested scope — only the two known values are allowed
if (!in_array($scope, ['read', 'read_write'], true)) {
return [
'success' => false,
'error' => "Invalid scope: must be 'read' or 'read_write'"
];
}
// Generate random API key (32 bytes = 64 hex characters)
$apiKey = bin2hex(random_bytes(32));
// Create key prefix (first 8 characters) for identification
$keyPrefix = substr($apiKey, 0, 8);
// Hash the API key for storage
$keyHash = hash('sha256', $apiKey);
// Calculate expiration date if specified
$expiresAt = null;
if ($expiresInDays !== null) {
$expiresAt = date('Y-m-d H:i:s', strtotime("+$expiresInDays days"));
}
// Insert API key into database
$seeAllVisibilityInt = $seeAllVisibility ? 1 : 0;
$stmt = $this->conn->prepare(
"INSERT INTO api_keys (key_name, key_hash, key_prefix, scope, see_all_visibility, created_by, expires_at) "
. "VALUES (?, ?, ?, ?, ?, ?, ?)"
);
$stmt->bind_param("ssssiis", $keyName, $keyHash, $keyPrefix, $scope, $seeAllVisibilityInt, $createdBy, $expiresAt);
if ($stmt->execute()) {
$keyId = $this->conn->insert_id;
$stmt->close();
return [
'success' => true,
'api_key' => $apiKey, // Return plaintext key ONCE
'key_prefix' => $keyPrefix,
'key_id' => $keyId,
'scope' => $scope,
'see_all_visibility' => $seeAllVisibility,
'expires_at' => $expiresAt
];
} else {
$error = $this->conn->error;
$stmt->close();
return [
'success' => false,
'error' => $error
];
}
}
/**
* Validate an API key
*
* @param string $apiKey Plaintext API key to validate
* @return array|null API key record if valid, null if invalid
*/
public function validateKey($apiKey)
{
if (empty($apiKey)) {
return null;
}
// Hash the provided key
$keyHash = hash('sha256', $apiKey);
// Query for matching key
$stmt = $this->conn->prepare(
"SELECT * FROM api_keys WHERE key_hash = ? AND is_active = 1"
);
$stmt->bind_param("s", $keyHash);
$stmt->execute();
$result = $stmt->get_result();
if ($result->num_rows === 0) {
$stmt->close();
return null;
}
$keyData = $result->fetch_assoc();
$stmt->close();
// Ensure a scope is always present. On an un-migrated database the column
// does not exist yet (or is null), in which case we treat the key as
// full-access so existing integrations keep working.
if (!isset($keyData['scope']) || $keyData['scope'] === null || $keyData['scope'] === '') {
$keyData['scope'] = 'read_write';
}
// Unlike scope's backward-compatible fallback above, an un-migrated or
// null see_all_visibility defaults to the RESTRICTIVE value (public
// tickets only) — this column exists specifically to lock down a
// previously-unrestricted default, so a missing value must not fall
// back to the permissive behavior it's replacing.
$keyData['see_all_visibility'] = !empty($keyData['see_all_visibility']);
// Check expiration
if ($keyData['expires_at'] !== null) {
$expiresAt = strtotime($keyData['expires_at']);
if ($expiresAt < time()) {
return null; // Key has expired
}
}
// Update last_used timestamp
$this->updateLastUsed($keyData['api_key_id']);
return $keyData;
}
/**
* Update last_used timestamp for an API key
*
* @param int $keyId API key ID
* @return bool Success status
*/
private function updateLastUsed($keyId)
{
$stmt = $this->conn->prepare("UPDATE api_keys SET last_used = NOW() WHERE api_key_id = ?");
$stmt->bind_param("i", $keyId);
$success = $stmt->execute();
$stmt->close();
return $success;
}
/**
* Revoke an API key (set is_active to false)
*
* @param int $keyId API key ID
* @return bool Success status
*/
public function revokeKey($keyId)
{
$stmt = $this->conn->prepare("UPDATE api_keys SET is_active = 0 WHERE api_key_id = ?");
$stmt->bind_param("i", $keyId);
$success = $stmt->execute();
$stmt->close();
return $success;
}
/**
* Delete an API key permanently
*
* @param int $keyId API key ID
* @return bool Success status
*/
public function deleteKey($keyId)
{
$stmt = $this->conn->prepare("DELETE FROM api_keys WHERE api_key_id = ?");
$stmt->bind_param("i", $keyId);
$success = $stmt->execute();
$stmt->close();
return $success;
}
/**
* Get a page of API keys (for admin panel)
*
* Active keys are listed first, then newest first within each group.
*
* @param int $page 1-based page number
* @param int $perPage Rows per page
* @return array ['keys' => array, 'total' => int, 'page' => int, 'perPage' => int]
*/
public function getAllKeys($page = 1, $perPage = 20)
{
// Normalise pagination inputs
$page = max(1, (int)$page);
$perPage = (int)$perPage;
if ($perPage < 1) {
$perPage = 20;
}
$offset = ($page - 1) * $perPage;
// Total count for pagination controls
$total = 0;
$countResult = $this->conn->query("SELECT COUNT(*) AS total FROM api_keys");
if ($countResult) {
$countRow = $countResult->fetch_assoc();
$total = (int)($countRow['total'] ?? 0);
$countResult->free();
}
$stmt = $this->conn->prepare(
"SELECT ak.*, u.username, u.display_name
FROM api_keys ak
LEFT JOIN users u ON ak.created_by = u.user_id
ORDER BY ak.is_active DESC, ak.created_at DESC
LIMIT ? OFFSET ?"
);
$stmt->bind_param("ii", $perPage, $offset);
$stmt->execute();
$result = $stmt->get_result();
$keys = [];
while ($row = $result->fetch_assoc()) {
// Remove key_hash from response for security
unset($row['key_hash']);
$keys[] = $row;
}
$stmt->close();
return [
'keys' => $keys,
'total' => $total,
'page' => $page,
'perPage' => $perPage
];
}
/**
* Get API key by ID
*
* @param int $keyId API key ID
* @return array|null API key record (without hash) or null if not found
*/
public function getKeyById($keyId)
{
$stmt = $this->conn->prepare(
"SELECT ak.*, u.username, u.display_name
FROM api_keys ak
LEFT JOIN users u ON ak.created_by = u.user_id
WHERE ak.api_key_id = ?"
);
$stmt->bind_param("i", $keyId);
$stmt->execute();
$result = $stmt->get_result();
if ($result->num_rows > 0) {
$key = $result->fetch_assoc();
// Remove key_hash from response for security
unset($key['key_hash']);
$stmt->close();
return $key;
}
$stmt->close();
return null;
}
/**
* Get keys created by a specific user
*
* @param int $userId User ID
* @return array Array of API key records
*/
public function getKeysByUser($userId)
{
$stmt = $this->conn->prepare(
"SELECT * FROM api_keys WHERE created_by = ? ORDER BY created_at DESC"
);
$stmt->bind_param("i", $userId);
$stmt->execute();
$result = $stmt->get_result();
$keys = [];
while ($row = $result->fetch_assoc()) {
// Remove key_hash from response for security
unset($row['key_hash']);
$keys[] = $row;
}
$stmt->close();
return $keys;
}
}