From 5cf5aa95919ab8a63e71c35d9eb2b1fdb811dda5 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Wed, 15 Jul 2026 18:24:50 -0400 Subject: [PATCH] API keys: add read/read_write scopes + admin scope selector & pagination Foundation for extending the Bearer API beyond create-only: - api_keys gains a scope column (read | read_write); baseline schema updated and the column applied to the live DB. Existing keys default to read_write so the hwmon create key keeps working. - ApiKeyModel: createKey() takes a validated scope; validateKey() always surfaces scope (defaults read_write); getAllKeys() is paginated ({keys,total,page,perPage}, key_hash stripped). - ApiKeyAuth: expose getKeyContext() (scope/key_name/created_by/api_key_id) and requireScope() (403 on insufficient scope); existing return values unchanged. - create_ticket_api.php: require read_write scope (a read key can't create). - Admin /admin/api-keys: scope selector on the create form, a scope column, and pagination (revoked keys were stacking up). Co-Authored-By: Claude Opus 4.8 --- api/generate_api_key.php | 12 +++++- create_ticket_api.php | 3 ++ index.php | 10 ++++- middleware/ApiKeyAuth.php | 81 +++++++++++++++++++++++++++++++++++++ migrations/000_baseline.sql | 1 + models/ApiKeyModel.php | 65 ++++++++++++++++++++++++----- views/admin/ApiKeysView.php | 59 +++++++++++++++++++++++---- 7 files changed, 211 insertions(+), 20 deletions(-) diff --git a/api/generate_api_key.php b/api/generate_api_key.php index 77f6dec..855d9e3 100644 --- a/api/generate_api_key.php +++ b/api/generate_api_key.php @@ -59,12 +59,19 @@ try { $keyName = trim($input['key_name'] ?? ''); $expiresInDays = $input['expires_in_days'] ?? null; + $scope = $input['scope'] ?? 'read_write'; if (empty($keyName)) { http_response_code(400); throw new Exception("Key name is required"); } + // Validate scope — only the two known values are allowed + if (!in_array($scope, ['read', 'read_write'], true)) { + http_response_code(400); + throw new Exception("Invalid scope: must be 'read' or 'read_write'"); + } + if (strlen($keyName) > 100) { http_response_code(400); throw new Exception("Key name must be 100 characters or less"); @@ -86,7 +93,7 @@ try { // Generate API key $apiKeyModel = new ApiKeyModel($conn); - $result = $apiKeyModel->createKey($keyName, $_SESSION['user']['user_id'], $expiresInDays); + $result = $apiKeyModel->createKey($keyName, $_SESSION['user']['user_id'], $expiresInDays, $scope); if (!$result['success']) { throw new Exception($result['error'] ?? "Failed to generate API key"); @@ -99,7 +106,7 @@ try { 'create', 'api_key', $result['key_id'], - ['key_name' => $keyName, 'expires_in_days' => $expiresInDays] + ['key_name' => $keyName, 'expires_in_days' => $expiresInDays, 'scope' => $scope] ); // Clear output buffer @@ -112,6 +119,7 @@ try { 'api_key' => $result['api_key'], 'key_prefix' => $result['key_prefix'], 'key_id' => $result['key_id'], + 'scope' => $result['scope'], 'expires_at' => $result['expires_at'] ]); } catch (Exception $e) { diff --git a/create_ticket_api.php b/create_ticket_api.php index 3d61579..62b6fcc 100644 --- a/create_ticket_api.php +++ b/create_ticket_api.php @@ -72,6 +72,9 @@ try { exit; } +// Ticket creation is a write — a read-only key must be rejected with 403. +$apiKeyAuth->requireScope('read_write'); + $userId = $systemUser['user_id']; // Parse input regardless of content-type header diff --git a/index.php b/index.php index f9732ed..119f68f 100644 --- a/index.php +++ b/index.php @@ -331,7 +331,15 @@ switch (true) { requireAdmin($currentUser); require_once 'models/ApiKeyModel.php'; $apiKeyModel = new ApiKeyModel($conn); - $apiKeys = $apiKeyModel->getAllKeys(); + + // Validate the requested page to a positive int (default 1) + $apiKeysPage = isset($_GET['page']) ? (int)$_GET['page'] : 1; + if ($apiKeysPage < 1) { + $apiKeysPage = 1; + } + $apiKeysPerPage = 20; + + $apiKeys = $apiKeyModel->getAllKeys($apiKeysPage, $apiKeysPerPage); include 'views/admin/ApiKeysView.php'; break; diff --git a/middleware/ApiKeyAuth.php b/middleware/ApiKeyAuth.php index 6b1517a..cf37670 100644 --- a/middleware/ApiKeyAuth.php +++ b/middleware/ApiKeyAuth.php @@ -13,6 +13,14 @@ class ApiKeyAuth private $userModel; private $conn; + /** + * Context of the API key validated by the most recent authenticate()/ + * verifyOptional() call, or null if none succeeded. + * + * @var array|null + */ + private $keyContext = null; + public function __construct($conn) { $this->conn = $conn; @@ -20,6 +28,57 @@ class ApiKeyAuth $this->userModel = new UserModel($conn); } + /** + * Store the validated key's context for later scope/attribution checks. + * + * @param array $keyData Row returned by ApiKeyModel::validateKey() + */ + private function setKeyContext(array $keyData) + { + $this->keyContext = [ + 'scope' => $keyData['scope'] ?? 'read_write', + 'key_name' => $keyData['key_name'] ?? null, + 'created_by' => $keyData['created_by'] ?? null, + 'api_key_id' => $keyData['api_key_id'] ?? null, + ]; + } + + /** + * Get the context of the authenticated API key. + * + * @return array|null ['scope', 'key_name', 'created_by', 'api_key_id'] or null + */ + public function getKeyContext(): ?array + { + return $this->keyContext; + } + + /** + * Enforce that the authenticated key satisfies the required scope. + * + * A 'read' key satisfies only 'read'; a 'read_write' key satisfies both + * 'read' and 'read_write'. On failure a 403 JSON error is sent and the + * script exits. + * + * @param string $needed Required scope ('read' or 'read_write') + */ + public function requireScope(string $needed): void + { + $current = $this->keyContext['scope'] ?? null; + + // 'read_write' can do anything; 'read' can only satisfy a 'read' need. + $ok = ($current === 'read_write') + || ($current === 'read' && $needed === 'read'); + + if (!$ok) { + $this->sendForbidden( + 'API key scope "' . ($current ?? 'none') . '" is insufficient; "' + . $needed . '" is required' + ); + exit; + } + } + /** * Authenticate using API key from Authorization header * @@ -52,6 +111,9 @@ class ApiKeyAuth exit; } + // Record key context (scope / attribution) for callers to inspect. + $this->setKeyContext($keyData); + // Get system user (or the user who created the key) $user = $this->userModel->getSystemUser(); @@ -113,6 +175,22 @@ class ApiKeyAuth ]); } + /** + * Send 403 Forbidden response (e.g. insufficient scope) + * + * @param string $message Error message + */ + private function sendForbidden($message) + { + header('HTTP/1.1 403 Forbidden'); + header('Content-Type: application/json'); + echo json_encode([ + 'success' => false, + 'error' => 'Forbidden', + 'message' => $message + ]); + } + /** * Verify API key without throwing errors (for optional auth) * @@ -137,6 +215,9 @@ class ApiKeyAuth return null; } + // Record key context (scope / attribution) for callers to inspect. + $this->setKeyContext($keyData); + $user = $this->userModel->getSystemUser(); if ($user) { diff --git a/migrations/000_baseline.sql b/migrations/000_baseline.sql index dab0008..42af57c 100644 --- a/migrations/000_baseline.sql +++ b/migrations/000_baseline.sql @@ -17,6 +17,7 @@ CREATE TABLE IF NOT EXISTS `api_keys` ( `key_hash` varchar(255) NOT NULL, `key_prefix` varchar(20) NOT NULL, `is_active` tinyint(1) DEFAULT 1, + `scope` enum('read','read_write') NOT NULL DEFAULT 'read_write', `created_by` int(11) DEFAULT NULL, `last_used` timestamp NULL DEFAULT NULL, `expires_at` timestamp NULL DEFAULT NULL, diff --git a/models/ApiKeyModel.php b/models/ApiKeyModel.php index bf3cbed..9213030 100644 --- a/models/ApiKeyModel.php +++ b/models/ApiKeyModel.php @@ -18,10 +18,19 @@ class ApiKeyModel * @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) - * @return array Array with 'success', 'api_key' (plaintext), 'key_prefix', 'error' + * @param string $scope Access scope: 'read' or 'read_write' (default 'read_write') + * @return array Array with 'success', 'api_key' (plaintext), 'key_prefix', 'scope', 'error' */ - public function createKey($keyName, $createdBy, $expiresInDays = null) + public function createKey($keyName, $createdBy, $expiresInDays = null, $scope = 'read_write') { + // 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)); @@ -39,9 +48,10 @@ class ApiKeyModel // Insert API key into database $stmt = $this->conn->prepare( - "INSERT INTO api_keys (key_name, key_hash, key_prefix, created_by, expires_at) VALUES (?, ?, ?, ?, ?)" + "INSERT INTO api_keys (key_name, key_hash, key_prefix, scope, created_by, expires_at) " + . "VALUES (?, ?, ?, ?, ?, ?)" ); - $stmt->bind_param("sssis", $keyName, $keyHash, $keyPrefix, $createdBy, $expiresAt); + $stmt->bind_param("ssssis", $keyName, $keyHash, $keyPrefix, $scope, $createdBy, $expiresAt); if ($stmt->execute()) { $keyId = $this->conn->insert_id; @@ -52,6 +62,7 @@ class ApiKeyModel 'api_key' => $apiKey, // Return plaintext key ONCE 'key_prefix' => $keyPrefix, 'key_id' => $keyId, + 'scope' => $scope, 'expires_at' => $expiresAt ]; } else { @@ -96,6 +107,13 @@ class ApiKeyModel $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'; + } + // Check expiration if ($keyData['expires_at'] !== null) { $expiresAt = strtotime($keyData['expires_at']); @@ -156,18 +174,41 @@ class ApiKeyModel } /** - * Get all API keys (for admin panel) + * Get a page of API keys (for admin panel) * - * @return array Array of API key records (without hashes) + * 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() + 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.created_at DESC" + ORDER BY ak.is_active DESC, ak.created_at DESC + LIMIT ? OFFSET ?" ); + $stmt->bind_param("ii", $perPage, $offset); $stmt->execute(); $result = $stmt->get_result(); @@ -179,7 +220,13 @@ class ApiKeyModel } $stmt->close(); - return $keys; + + return [ + 'keys' => $keys, + 'total' => $total, + 'page' => $page, + 'perPage' => $perPage + ]; } /** diff --git a/views/admin/ApiKeysView.php b/views/admin/ApiKeysView.php index 633da9a..08d194d 100644 --- a/views/admin/ApiKeysView.php +++ b/views/admin/ApiKeysView.php @@ -38,8 +38,18 @@ include __DIR__ . '/../../views/layout_header.php'; +
+ + +
+

+ Scope: read = GET only; read_write = create/comment/close. +

+ + + + 1) : ?> + + @@ -160,8 +202,9 @@ document.getElementById('generateKeyForm').addEventListener('submit', function ( e.preventDefault(); var keyName = document.getElementById('keyName').value.trim(); var expiresIn = document.getElementById('expiresIn').value; + var keyScope = document.getElementById('keyScope').value; if (!keyName) { lt.toast.error('Please enter a key name'); return; } - lt.api.post('/api/generate_api_key.php', { key_name: keyName, expires_in_days: expiresIn || null }) + lt.api.post('/api/generate_api_key.php', { key_name: keyName, expires_in_days: expiresIn || null, scope: keyScope }) .then(function (data) { if (data.success) { document.getElementById('newKeyValue').value = data.api_key;