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
+81
View File
@@ -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) {