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:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
+56
-9
@@ -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
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -38,8 +38,18 @@ include __DIR__ . '/../../views/layout_header.php';
|
||||
<option value="365">1 year</option>
|
||||
</select>
|
||||
</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>
|
||||
</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) -->
|
||||
<div id="newKeyDisplay" class="lt-frame-inner lt-mt-sm is-hidden">
|
||||
@@ -63,6 +73,7 @@ include __DIR__ . '/../../views/layout_header.php';
|
||||
<tr>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col">Key Prefix</th>
|
||||
<th scope="col">Scope</th>
|
||||
<th scope="col">Created By</th>
|
||||
<th scope="col">Created</th>
|
||||
<th scope="col">Expires</th>
|
||||
@@ -72,14 +83,26 @@ include __DIR__ . '/../../views/layout_header.php';
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($apiKeys)) : ?>
|
||||
<tr><td colspan="8" class="lt-empty">No API keys found. Generate one above.</td></tr>
|
||||
<?php else :
|
||||
foreach ($apiKeys as $key) : ?>
|
||||
<?php $expired = $key['expires_at'] && strtotime($key['expires_at']) < time(); ?>
|
||||
<?php
|
||||
$apiKeysList = $apiKeys['keys'] ?? [];
|
||||
if (empty($apiKeysList)) : ?>
|
||||
<tr><td colspan="9" class="lt-empty">No API keys found. Generate one above.</td></tr>
|
||||
<?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'] ?>">
|
||||
<td data-label="Name"><strong><?= htmlspecialchars($key['key_name']) ?></strong></td>
|
||||
<td data-label="Prefix" class="lt-text-xs"><code><?= htmlspecialchars($key['key_prefix']) ?>…</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" 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' ?>">
|
||||
@@ -104,11 +127,30 @@ include __DIR__ . '/../../views/layout_header.php';
|
||||
<?php endif ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach;
|
||||
endif ?>
|
||||
<?php endforeach;
|
||||
endif ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</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">« 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 »</a>
|
||||
<?php endif ?>
|
||||
</div>
|
||||
<?php endif ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user