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
+56 -9
View File
@@ -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
];
}
/**