Merge pull request 'Bearer API extension: list/read/comment/close + key scopes' (#26) from development into main
Lint / PHP (phpcs PSR-12) (push) Successful in 25s
Lint / JS (eslint) (push) Successful in 7s
Lint / PHP requirements (version + extensions) (push) Successful in 18s
Security / PHP Security (semgrep) (push) Successful in 1m2s
Lint / Deploy (push) Successful in 3s
Lint / Notify on failure (push) Has been skipped

This commit was merged in pull request #26.
This commit is contained in:
2026-07-15 19:19:24 -04:00
11 changed files with 736 additions and 25 deletions
+15 -1
View File
@@ -94,11 +94,22 @@ The following features are intentionally **not planned** for this system:
- **Required Fields**: Mark fields as required for validation
### API Key Management
- **Admin UI**: Generate and manage API keys at `/admin/api-keys`
- **Admin UI**: Generate and manage API keys at `/admin/api-keys` (paginated)
- **Bearer Token Auth**: Use API keys with `Authorization: Bearer YOUR_KEY` header
- **Key Scopes**: `read` (GET only) or `read_write` (create/comment/close). A `read` key cannot mutate anything, including creating tickets. Existing keys default to `read_write`.
- **Expiration**: Optional expiration dates for keys
- **Revocation**: Revoke compromised keys instantly
### Bearer API (automation / triage)
All Bearer-authenticated, rate-limited, and (like `create_ticket_api.php`) exempt from Authelia at the reverse proxy — the API key is the only credential. Comments/closes made via the API are attributed to the **key's name** (linked to the key's owner).
| Endpoint | Method | Scope | Purpose |
|----------|--------|-------|---------|
| `/create_ticket_api.php` | POST | read_write | Create a ticket (hwmonDaemon, external tools) |
| `/api/tickets_api.php` | GET | read | List/triage the queue (`?status=`, `?priority=`, `?host=` [title match], `?page=`, `?limit=`) **or** read one (`?ticket_id=NNN`) with its comments |
| `/api/ticket_comment_api.php` | POST | read_write | Add a comment: `{ticket_id, comment_text, markdown_enabled?}` |
| `/api/ticket_status_api.php` | POST | read_write | Change/close status (workflow-validated): `{ticket_id, status, comment?}``comment` is required for transitions that require one (e.g. → Closed); it is posted as the close reason in the same call |
### User Management & Authentication
- **SSO Integration**: Authelia authentication with LLDAP backend
- **Role-Based Access**: Admin and standard user roles
@@ -250,6 +261,9 @@ Content-Type: application/json
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/create_ticket_api.php` | POST | Create ticket via API key (hwmonDaemon, external tools) |
| `/api/tickets_api.php` | GET | Bearer: list/triage queue or read one ticket + comments |
| `/api/ticket_comment_api.php` | POST | Bearer: add a comment (read_write scope) |
| `/api/ticket_status_api.php` | POST | Bearer: change/close status, workflow-validated (read_write scope) |
| `/api/update_ticket.php` | POST | Update ticket with workflow validation |
| `/api/assign_ticket.php` | POST | Assign ticket to user |
| `/api/add_comment.php` | POST | Add comment to ticket |
+10 -2
View File
@@ -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) {
+125
View File
@@ -0,0 +1,125 @@
<?php
/**
* ticket_comment_api.php — Bearer-key endpoint to post a comment on a ticket.
*
* POST only. Requires 'read_write' scope.
*
* Identity = PER-KEY LABEL: the comment author (ticket_comments.user_name) is the
* API key's key_name and the linked user_id is the key's created_by.
*
* Body (JSON): {
* "ticket_id": "NNN" (required),
* "comment_text": "..." (required, non-empty),
* "markdown_enabled": bool (optional)
* }
* Response: {success:true, comment_id:...}
*/
header('Content-Type: application/json');
error_reporting(E_ALL);
ini_set('display_errors', 0);
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/middleware/ApiKeyAuth.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/CommentModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
try {
$conn = Database::getConnection();
} catch (Throwable $e) {
error_log('ticket_comment_api: DB connection failed: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Internal server error']);
exit;
}
$apiKeyAuth = new ApiKeyAuth($conn);
try {
$apiKeyAuth->authenticate();
} catch (Exception $e) {
// ApiKeyAuth already sent the 401 response.
exit;
}
// Posting a comment is a write — reject 'read' keys with 403 before any mutation.
$apiKeyAuth->requireScope('read_write');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed. Use POST.']);
exit;
}
$context = $apiKeyAuth->getKeyContext();
$keyName = $context['key_name'] ?? 'API';
$createdBy = ($context['created_by'] ?? null) !== null ? (int)$context['created_by'] : null;
$rawInput = file_get_contents('php://input');
$data = json_decode($rawInput, true);
if (!is_array($data)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid JSON body']);
exit;
}
$ticketId = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
if ($ticketId === '') {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'ticket_id is required']);
exit;
}
$commentText = isset($data['comment_text']) ? trim((string)$data['comment_text']) : '';
if ($commentText === '') {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'comment_text is required']);
exit;
}
$markdownEnabled = !empty($data['markdown_enabled']);
// Validate the ticket exists.
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket) {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit;
}
// Post the comment under the key's label / owner.
$commentModel = new CommentModel($conn);
$result = $commentModel->addComment($ticketId, [
'user_name' => $keyName,
'comment_text' => $commentText,
'markdown_enabled' => $markdownEnabled,
], $createdBy);
if (empty($result['success'])) {
error_log('ticket_comment_api: addComment failed for ticket ' . $ticketId
. ': ' . ($result['error'] ?? 'unknown'));
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to add comment']);
exit;
}
$commentId = $result['comment_id'] ?? null;
// Audit trail (action 'comment' / entity 'comment' are both whitelisted).
$auditLog = new AuditLogModel($conn);
$auditLog->log($createdBy, 'comment', 'comment', (string)$commentId, [
'ticket_id' => $ticketId,
'key_name' => $keyName,
'via_api' => true,
]);
echo json_encode(['success' => true, 'comment_id' => $commentId]);
exit;
+203
View File
@@ -0,0 +1,203 @@
<?php
/**
* ticket_status_api.php — Bearer-key endpoint to change a ticket's status.
*
* POST only. Requires 'read_write' scope.
*
* Body (JSON): {
* "ticket_id": "NNN" (required),
* "status": "..." (required target status),
* "comment": "..." (optional; REQUIRED when the transition
* requires_comment),
* "markdown_enabled": bool (optional, applies to the comment)
* }
* Response: {success:true, ticket_id, status}
*
* Mirrors api/update_ticket.php: workflow validation, requires_comment
* enforcement, updateTicket (updated_by/updated_at + closed_at handling), Matrix
* status-change notification, and StatsModel cache invalidation. When a comment
* is supplied it is posted first (per-key label) so "close with reason" is one call.
*/
header('Content-Type: application/json');
error_reporting(E_ALL);
ini_set('display_errors', 0);
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/middleware/ApiKeyAuth.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/CommentModel.php';
require_once dirname(__DIR__) . '/models/WorkflowModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/StatsModel.php';
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
try {
$conn = Database::getConnection();
} catch (Throwable $e) {
error_log('ticket_status_api: DB connection failed: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Internal server error']);
exit;
}
$apiKeyAuth = new ApiKeyAuth($conn);
try {
$apiKeyAuth->authenticate();
} catch (Exception $e) {
// ApiKeyAuth already sent the 401 response.
exit;
}
// Changing status is a write — reject 'read' keys with 403 before any mutation.
$apiKeyAuth->requireScope('read_write');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed. Use POST.']);
exit;
}
$context = $apiKeyAuth->getKeyContext();
$keyName = $context['key_name'] ?? 'API';
$createdBy = ($context['created_by'] ?? null) !== null ? (int)$context['created_by'] : null;
$rawInput = file_get_contents('php://input');
$data = json_decode($rawInput, true);
if (!is_array($data)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid JSON body']);
exit;
}
$ticketId = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
if ($ticketId === '') {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'ticket_id is required']);
exit;
}
$newStatus = isset($data['status']) ? trim((string)$data['status']) : '';
if ($newStatus === '') {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'status is required']);
exit;
}
$comment = isset($data['comment']) ? trim((string)$data['comment']) : '';
// Validate the ticket exists.
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket) {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit;
}
$currentStatus = (string)$ticket['status'];
// Validate the transition (API key is never admin).
$workflowModel = new WorkflowModel($conn);
if (!$workflowModel->isTransitionAllowed($currentStatus, $newStatus, false)) {
http_response_code(400);
echo json_encode([
'success' => false,
'error' => 'Status transition not allowed: ' . $currentStatus . ' -> ' . $newStatus,
]);
exit;
}
// Enforce requires_comment transitions server-side.
if ($workflowModel->transitionRequiresComment($currentStatus, $newStatus) && $comment === '') {
http_response_code(400);
echo json_encode([
'success' => false,
'error' => 'A comment is required for this status change',
'requires_comment' => true,
]);
exit;
}
// Post the comment first (per-key label) so a close-with-reason is one call.
if ($comment !== '') {
$commentModel = new CommentModel($conn);
$commentResult = $commentModel->addComment($ticketId, [
'user_name' => $keyName,
'comment_text' => $comment,
'markdown_enabled' => !empty($data['markdown_enabled']),
], $createdBy);
if (empty($commentResult['success'])) {
error_log('ticket_status_api: addComment failed for ticket ' . $ticketId
. ': ' . ($commentResult['error'] ?? 'unknown'));
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to add comment']);
exit;
}
}
// Apply the status change. updateTicket sets updated_by/updated_at and handles
// closed_at (set on close, cleared on reopen) via its own SQL.
$updateData = [
'ticket_id' => $ticketId,
'title' => $ticket['title'],
'description' => $ticket['description'],
'category' => $ticket['category'],
'type' => $ticket['type'],
'status' => $newStatus,
'priority' => (int)$ticket['priority'],
];
$updateResult = $ticketModel->updateTicket($updateData, $createdBy);
if (empty($updateResult['success'])) {
error_log('ticket_status_api: updateTicket failed for ticket ' . $ticketId
. ': ' . ($updateResult['error'] ?? 'unknown'));
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to update ticket status']);
exit;
}
// Notify, audit, and refresh stats only when the status actually changed.
if ($currentStatus !== $newStatus) {
NotificationHelper::sendStatusChangeNotification(
$ticketId,
$currentStatus,
$newStatus,
(string)$ticket['title'],
$keyName
);
NotificationHelper::notifyWatchers(
$conn,
$ticketId,
(string)$ticket['title'],
'status_changed',
['old_status' => $currentStatus, 'new_status' => $newStatus, 'changed_by' => $keyName],
$createdBy,
$ticket['visibility'] ?? 'public'
);
// Audit trail (action 'update' / entity 'ticket' are both whitelisted).
$auditLog = new AuditLogModel($conn);
$auditLog->log($createdBy, 'update', 'ticket', $ticketId, [
'status' => ['from' => $currentStatus, 'to' => $newStatus],
'key_name' => $keyName,
'via_api' => true,
]);
// Status change is a ticket-state change — refresh dashboard stats.
(new StatsModel($conn))->invalidateCache();
}
echo json_encode([
'success' => true,
'ticket_id' => $ticketId,
'status' => $newStatus,
]);
exit;
+139
View File
@@ -0,0 +1,139 @@
<?php
/**
* tickets_api.php Bearer-key read endpoint (list/triage + read-one).
*
* GET only. Requires 'read' scope (a 'read_write' key also satisfies it).
* Acts as a trusted automation/server credential: reads return the full queue
* (no per-user visibility filtering).
*
* GET ?ticket_id=NNN -> {success, ticket, comments}
* GET ?status=&priority=&host= -> {success, tickets, page, total, pages}
* &page=&limit=
*/
header('Content-Type: application/json');
error_reporting(E_ALL);
ini_set('display_errors', 0);
// Rate limiting (same pattern as the other Bearer API endpoints)
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api');
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/middleware/ApiKeyAuth.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/CommentModel.php';
try {
$conn = Database::getConnection();
} catch (Throwable $e) {
error_log('tickets_api: DB connection failed: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Internal server error']);
exit;
}
$apiKeyAuth = new ApiKeyAuth($conn);
try {
$apiKeyAuth->authenticate();
} catch (Exception $e) {
// ApiKeyAuth already sent the 401 response.
exit;
}
// Reads only need the 'read' scope.
$apiKeyAuth->requireScope('read');
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed. Use GET.']);
exit;
}
$ticketModel = new TicketModel($conn);
// ── READ ONE ──────────────────────────────────────────────────────────────
if (isset($_GET['ticket_id']) && trim((string)$_GET['ticket_id']) !== '') {
$ticketId = trim((string)$_GET['ticket_id']);
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket) {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit;
}
// Flat list of comments (newest first) — same fetch the ticket view uses.
$commentModel = new CommentModel($conn);
$comments = $commentModel->getCommentsByTicketId($ticketId, false);
echo json_encode([
'success' => true,
'ticket' => $ticket,
'comments' => $comments,
]);
exit;
}
// ── LIST / TRIAGE ───────────────────────────────────────────────────────────
$status = (isset($_GET['status']) && trim((string)$_GET['status']) !== '')
? trim((string)$_GET['status'])
: 'Open';
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
if ($page < 1) {
$page = 1;
}
$limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 25;
if ($limit < 1) {
$limit = 25;
}
if ($limit > 100) {
$limit = 100; // cap
}
$filters = [];
if (isset($_GET['priority']) && trim((string)$_GET['priority']) !== '') {
$priority = (int)$_GET['priority'];
if ($priority >= 1 && $priority <= 5) {
// Exact-priority match via the min/max range filter.
$filters['priority_min'] = $priority;
$filters['priority_max'] = $priority;
}
}
// hwmon puts the host in the title (e.g. "[hostname] ..."), so a host filter is a
// title substring match — served by getAllTickets's `search` param (title search).
$search = null;
if (isset($_GET['host']) && trim((string)$_GET['host']) !== '') {
$search = trim((string)$_GET['host']);
}
// user = null => getAllTickets skips visibility filtering and returns the full
// queue (this is a trusted server credential, not an end user).
$result = $ticketModel->getAllTickets(
$page,
$limit,
$status,
'ticket_id',
'desc',
null,
null,
$search,
$filters,
null
);
echo json_encode([
'success' => true,
'tickets' => $result['tickets'],
'page' => $result['current_page'],
'total' => $result['total'],
'pages' => $result['pages'],
]);
exit;
+3
View File
@@ -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
+9 -1
View File
@@ -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;
+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) {
+1
View File
@@ -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
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
];
}
/**
+94 -12
View File
@@ -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']) ?>&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" 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">&#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>
@@ -127,17 +169,56 @@ include __DIR__ . '/../../views/layout_header.php';
</div>
<pre><code>Authorization: Bearer YOUR_API_KEY</code></pre>
</div>
<p class="lt-text-xs lt-text-muted" style="margin-top:0.5rem">
Example create a ticket via cURL:<br>
<?php $apiBase = 'https://' . htmlspecialchars($GLOBALS['config']['APP_DOMAIN'] ?? 'your-instance', ENT_QUOTES); ?>
<p class="lt-text-sm lt-text-muted" style="margin-top:0.75rem">
<strong>Scopes:</strong> a <code>read</code> key may only use the <code>GET</code> endpoints;
a <code>read_write</code> key may also create tickets, post comments, and change status.
All endpoints are Bearer-authenticated and rate-limited. Comments and status changes made via
the API are attributed to the key's name.
</p>
<p class="lt-text-xs lt-text-muted" style="margin-top:0.75rem"><strong>Create a ticket</strong> (read_write):</p>
<div class="lt-code-block">
<div class="lt-code-header"><span class="lt-code-lang">CURL</span></div>
<pre><code>curl -X POST https://your-instance/create_ticket_api.php \
<pre><code>curl -X POST <?= $apiBase ?>/create_ticket_api.php \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"title":"My ticket","category":"General","type":"Issue","priority":3}'</code></pre>
</div>
<p class="lt-text-xs lt-text-muted" style="margin-top:0.5rem">API keys provide programmatic access to create and manage tickets. Keep keys secure and rotate them regularly.</p>
<p class="lt-text-xs lt-text-muted" style="margin-top:0.75rem"><strong>List / triage the queue</strong> (read). Filters: <code>status</code>, <code>priority</code> (1-5), <code>host</code> (title match), <code>page</code>, <code>limit</code>:</p>
<div class="lt-code-block">
<div class="lt-code-header"><span class="lt-code-lang">CURL</span></div>
<pre><code>curl "<?= $apiBase ?>/api/tickets_api.php?status=Open&priority=2&limit=25" \
-H "Authorization: Bearer YOUR_API_KEY"</code></pre>
</div>
<p class="lt-text-xs lt-text-muted" style="margin-top:0.75rem"><strong>Read one ticket + its comments</strong> (read):</p>
<div class="lt-code-block">
<div class="lt-code-header"><span class="lt-code-lang">CURL</span></div>
<pre><code>curl "<?= $apiBase ?>/api/tickets_api.php?ticket_id=123456789" \
-H "Authorization: Bearer YOUR_API_KEY"</code></pre>
</div>
<p class="lt-text-xs lt-text-muted" style="margin-top:0.75rem"><strong>Post a comment</strong> (read_write). <code>markdown_enabled</code> is optional:</p>
<div class="lt-code-block">
<div class="lt-code-header"><span class="lt-code-lang">CURL</span></div>
<pre><code>curl -X POST <?= $apiBase ?>/api/ticket_comment_api.php \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"ticket_id":"123456789","comment_text":"Investigating.","markdown_enabled":true}'</code></pre>
</div>
<p class="lt-text-xs lt-text-muted" style="margin-top:0.75rem"><strong>Change / close status</strong> (read_write, workflow-validated). <code>comment</code> is required for transitions that require one (e.g. closing) and is posted as the reason:</p>
<div class="lt-code-block">
<div class="lt-code-header"><span class="lt-code-lang">CURL</span></div>
<pre><code>curl -X POST <?= $apiBase ?>/api/ticket_status_api.php \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"ticket_id":"123456789","status":"Closed","comment":"Resolved: disk replaced."}'</code></pre>
</div>
<p class="lt-text-xs lt-text-muted" style="margin-top:0.75rem">Keep keys secure and rotate them regularly. Scope automation keys to <code>read</code> unless they need to write.</p>
</div>
</div>
@@ -160,8 +241,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;