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 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 18:24:50 -04:00
co-authored by Claude Opus 4.8
parent d535557e5a
commit 5cf5aa9591
7 changed files with 211 additions and 20 deletions
+10 -2
View File
@@ -59,12 +59,19 @@ try {
$keyName = trim($input['key_name'] ?? ''); $keyName = trim($input['key_name'] ?? '');
$expiresInDays = $input['expires_in_days'] ?? null; $expiresInDays = $input['expires_in_days'] ?? null;
$scope = $input['scope'] ?? 'read_write';
if (empty($keyName)) { if (empty($keyName)) {
http_response_code(400); http_response_code(400);
throw new Exception("Key name is required"); 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) { if (strlen($keyName) > 100) {
http_response_code(400); http_response_code(400);
throw new Exception("Key name must be 100 characters or less"); throw new Exception("Key name must be 100 characters or less");
@@ -86,7 +93,7 @@ try {
// Generate API key // Generate API key
$apiKeyModel = new ApiKeyModel($conn); $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']) { if (!$result['success']) {
throw new Exception($result['error'] ?? "Failed to generate API key"); throw new Exception($result['error'] ?? "Failed to generate API key");
@@ -99,7 +106,7 @@ try {
'create', 'create',
'api_key', 'api_key',
$result['key_id'], $result['key_id'],
['key_name' => $keyName, 'expires_in_days' => $expiresInDays] ['key_name' => $keyName, 'expires_in_days' => $expiresInDays, 'scope' => $scope]
); );
// Clear output buffer // Clear output buffer
@@ -112,6 +119,7 @@ try {
'api_key' => $result['api_key'], 'api_key' => $result['api_key'],
'key_prefix' => $result['key_prefix'], 'key_prefix' => $result['key_prefix'],
'key_id' => $result['key_id'], 'key_id' => $result['key_id'],
'scope' => $result['scope'],
'expires_at' => $result['expires_at'] 'expires_at' => $result['expires_at']
]); ]);
} catch (Exception $e) { } catch (Exception $e) {
+3
View File
@@ -72,6 +72,9 @@ try {
exit; exit;
} }
// Ticket creation is a write — a read-only key must be rejected with 403.
$apiKeyAuth->requireScope('read_write');
$userId = $systemUser['user_id']; $userId = $systemUser['user_id'];
// Parse input regardless of content-type header // Parse input regardless of content-type header
+9 -1
View File
@@ -331,7 +331,15 @@ switch (true) {
requireAdmin($currentUser); requireAdmin($currentUser);
require_once 'models/ApiKeyModel.php'; require_once 'models/ApiKeyModel.php';
$apiKeyModel = new ApiKeyModel($conn); $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'; include 'views/admin/ApiKeysView.php';
break; break;
+81
View File
@@ -13,6 +13,14 @@ class ApiKeyAuth
private $userModel; private $userModel;
private $conn; 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) public function __construct($conn)
{ {
$this->conn = $conn; $this->conn = $conn;
@@ -20,6 +28,57 @@ class ApiKeyAuth
$this->userModel = new UserModel($conn); $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 * Authenticate using API key from Authorization header
* *
@@ -52,6 +111,9 @@ class ApiKeyAuth
exit; exit;
} }
// Record key context (scope / attribution) for callers to inspect.
$this->setKeyContext($keyData);
// Get system user (or the user who created the key) // Get system user (or the user who created the key)
$user = $this->userModel->getSystemUser(); $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) * Verify API key without throwing errors (for optional auth)
* *
@@ -137,6 +215,9 @@ class ApiKeyAuth
return null; return null;
} }
// Record key context (scope / attribution) for callers to inspect.
$this->setKeyContext($keyData);
$user = $this->userModel->getSystemUser(); $user = $this->userModel->getSystemUser();
if ($user) { if ($user) {
+1
View File
@@ -17,6 +17,7 @@ CREATE TABLE IF NOT EXISTS `api_keys` (
`key_hash` varchar(255) NOT NULL, `key_hash` varchar(255) NOT NULL,
`key_prefix` varchar(20) NOT NULL, `key_prefix` varchar(20) NOT NULL,
`is_active` tinyint(1) DEFAULT 1, `is_active` tinyint(1) DEFAULT 1,
`scope` enum('read','read_write') NOT NULL DEFAULT 'read_write',
`created_by` int(11) DEFAULT NULL, `created_by` int(11) DEFAULT NULL,
`last_used` timestamp NULL DEFAULT NULL, `last_used` timestamp NULL DEFAULT NULL,
`expires_at` timestamp NULL DEFAULT NULL, `expires_at` timestamp NULL DEFAULT NULL,
+56 -9
View File
@@ -18,10 +18,19 @@ class ApiKeyModel
* @param string $keyName Descriptive name for the key * @param string $keyName Descriptive name for the key
* @param int $createdBy User ID who created 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 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) // Generate random API key (32 bytes = 64 hex characters)
$apiKey = bin2hex(random_bytes(32)); $apiKey = bin2hex(random_bytes(32));
@@ -39,9 +48,10 @@ class ApiKeyModel
// Insert API key into database // Insert API key into database
$stmt = $this->conn->prepare( $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()) { if ($stmt->execute()) {
$keyId = $this->conn->insert_id; $keyId = $this->conn->insert_id;
@@ -52,6 +62,7 @@ class ApiKeyModel
'api_key' => $apiKey, // Return plaintext key ONCE 'api_key' => $apiKey, // Return plaintext key ONCE
'key_prefix' => $keyPrefix, 'key_prefix' => $keyPrefix,
'key_id' => $keyId, 'key_id' => $keyId,
'scope' => $scope,
'expires_at' => $expiresAt 'expires_at' => $expiresAt
]; ];
} else { } else {
@@ -96,6 +107,13 @@ class ApiKeyModel
$keyData = $result->fetch_assoc(); $keyData = $result->fetch_assoc();
$stmt->close(); $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 // Check expiration
if ($keyData['expires_at'] !== null) { if ($keyData['expires_at'] !== null) {
$expiresAt = strtotime($keyData['expires_at']); $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( $stmt = $this->conn->prepare(
"SELECT ak.*, u.username, u.display_name "SELECT ak.*, u.username, u.display_name
FROM api_keys ak FROM api_keys ak
LEFT JOIN users u ON ak.created_by = u.user_id 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(); $stmt->execute();
$result = $stmt->get_result(); $result = $stmt->get_result();
@@ -179,7 +220,13 @@ class ApiKeyModel
} }
$stmt->close(); $stmt->close();
return $keys;
return [
'keys' => $keys,
'total' => $total,
'page' => $page,
'perPage' => $perPage
];
} }
/** /**
+51 -8
View File
@@ -38,8 +38,18 @@ include __DIR__ . '/../../views/layout_header.php';
<option value="365">1 year</option> <option value="365">1 year</option>
</select> </select>
</div> </div>
<div class="lt-form-group" style="flex:1;margin:0">
<label class="lt-label" for="keyScope">Scope</label>
<select id="keyScope" class="lt-select">
<option value="read_write" selected>read_write</option>
<option value="read">read</option>
</select>
</div>
<button type="submit" class="lt-btn lt-btn-primary" style="margin-bottom:0">GENERATE KEY</button> <button type="submit" class="lt-btn lt-btn-primary" style="margin-bottom:0">GENERATE KEY</button>
</form> </form>
<p class="lt-text-xs lt-text-muted" style="margin-top:0.5rem">
Scope: <strong>read</strong> = GET only; <strong>read_write</strong> = create/comment/close.
</p>
<!-- New key display (hidden by default) --> <!-- New key display (hidden by default) -->
<div id="newKeyDisplay" class="lt-frame-inner lt-mt-sm is-hidden"> <div id="newKeyDisplay" class="lt-frame-inner lt-mt-sm is-hidden">
@@ -63,6 +73,7 @@ include __DIR__ . '/../../views/layout_header.php';
<tr> <tr>
<th scope="col">Name</th> <th scope="col">Name</th>
<th scope="col">Key Prefix</th> <th scope="col">Key Prefix</th>
<th scope="col">Scope</th>
<th scope="col">Created By</th> <th scope="col">Created By</th>
<th scope="col">Created</th> <th scope="col">Created</th>
<th scope="col">Expires</th> <th scope="col">Expires</th>
@@ -72,14 +83,26 @@ include __DIR__ . '/../../views/layout_header.php';
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php if (empty($apiKeys)) : ?> <?php
<tr><td colspan="8" class="lt-empty">No API keys found. Generate one above.</td></tr> $apiKeysList = $apiKeys['keys'] ?? [];
<?php else : if (empty($apiKeysList)) : ?>
foreach ($apiKeys as $key) : ?> <tr><td colspan="9" class="lt-empty">No API keys found. Generate one above.</td></tr>
<?php $expired = $key['expires_at'] && strtotime($key['expires_at']) < time(); ?> <?php else :
foreach ($apiKeysList as $key) : ?>
<?php
$expired = $key['expires_at'] && strtotime($key['expires_at']) < time();
$scope = $key['scope'] ?? 'read_write';
?>
<tr id="key-row-<?= (int)$key['api_key_id'] ?>"> <tr id="key-row-<?= (int)$key['api_key_id'] ?>">
<td data-label="Name"><strong><?= htmlspecialchars($key['key_name']) ?></strong></td> <td data-label="Name"><strong><?= htmlspecialchars($key['key_name']) ?></strong></td>
<td data-label="Prefix" class="lt-text-xs"><code><?= htmlspecialchars($key['key_prefix']) ?>&hellip;</code></td> <td data-label="Prefix" class="lt-text-xs"><code><?= htmlspecialchars($key['key_prefix']) ?>&hellip;</code></td>
<td data-label="Scope">
<?php if ($scope === 'read') : ?>
<span class="lt-status lt-status-closed"><?= htmlspecialchars($scope) ?></span>
<?php else : ?>
<span class="lt-status lt-status-open"><?= htmlspecialchars($scope) ?></span>
<?php endif ?>
</td>
<td data-label="Created By" class="lt-text-xs"><?= htmlspecialchars($key['display_name'] ?? $key['username'] ?? 'Unknown') ?></td> <td data-label="Created By" class="lt-text-xs"><?= htmlspecialchars($key['display_name'] ?? $key['username'] ?? 'Unknown') ?></td>
<td data-label="Created" class="lt-text-xs lt-text-muted"><?= date('Y-m-d H:i', strtotime($key['created_at'])) ?></td> <td data-label="Created" class="lt-text-xs lt-text-muted"><?= date('Y-m-d H:i', strtotime($key['created_at'])) ?></td>
<td data-label="Expires" class="lt-text-xs <?= $expired ? 'lt-text-danger' : 'lt-text-cyan' ?>"> <td data-label="Expires" class="lt-text-xs <?= $expired ? 'lt-text-danger' : 'lt-text-cyan' ?>">
@@ -104,11 +127,30 @@ include __DIR__ . '/../../views/layout_header.php';
<?php endif ?> <?php endif ?>
</td> </td>
</tr> </tr>
<?php endforeach; <?php endforeach;
endif ?> endif ?>
</tbody> </tbody>
</table> </table>
</div> </div>
<!-- Pagination -->
<?php
$akPage = (int)($apiKeys['page'] ?? 1);
$akPerPage = max(1, (int)($apiKeys['perPage'] ?? 20));
$akTotal = (int)($apiKeys['total'] ?? 0);
$akPages = (int)ceil($akTotal / $akPerPage);
?>
<?php if ($akPages > 1) : ?>
<div class="lt-pagination" role="navigation" aria-label="API keys pagination">
<?php if ($akPage > 1) : ?>
<a href="/admin/api-keys?page=<?= $akPage - 1 ?>" class="lt-btn lt-btn-sm" aria-label="Previous page">&#xAB; Prev</a>
<?php endif ?>
<span class="lt-text-xs lt-text-muted">Page <?= $akPage ?> of <?= $akPages ?></span>
<?php if ($akPage < $akPages) : ?>
<a href="/admin/api-keys?page=<?= $akPage + 1 ?>" class="lt-btn lt-btn-sm" aria-label="Next page">Next &#xBB;</a>
<?php endif ?>
</div>
<?php endif ?>
</div> </div>
</div> </div>
@@ -160,8 +202,9 @@ document.getElementById('generateKeyForm').addEventListener('submit', function (
e.preventDefault(); e.preventDefault();
var keyName = document.getElementById('keyName').value.trim(); var keyName = document.getElementById('keyName').value.trim();
var expiresIn = document.getElementById('expiresIn').value; var expiresIn = document.getElementById('expiresIn').value;
var keyScope = document.getElementById('keyScope').value;
if (!keyName) { lt.toast.error('Please enter a key name'); return; } 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) { .then(function (data) {
if (data.success) { if (data.success) {
document.getElementById('newKeyValue').value = data.api_key; document.getElementById('newKeyValue').value = data.api_key;